Merge pull request 'Match the marketing homepage, About page, and folder header to the latest Figma' (#74) from adilallo/fix/CR-173-home-about-feature-grid into main

Reviewed-on: #74
This commit was merged in pull request #74.
This commit is contained in:
2026-09-10 03:41:43 +00:00
40 changed files with 501 additions and 269 deletions
+2 -9
View File
@@ -36,11 +36,6 @@ export default function AboutPage() {
const headerSegments = asArray<AboutHeaderSegment>(page.aboutHeader.segments);
const statsItems = asArray<StatItem>(page.stats.items);
const statsAsOf =
typeof page.stats.asOf === "string"
? page.stats.asOf
: String(page.stats.asOf ?? "");
const faqItems = asArray<FaqAccordionItem>(page.faq.items);
const tripleColumns = asArray<TripleTextBlockColumn>(page.tripleTextBlock.columns);
@@ -57,10 +52,8 @@ export default function AboutPage() {
titlePrefix={page.stats.titlePrefix}
titleEmphasis={page.stats.titleEmphasis}
titleSuffix={page.stats.titleSuffix}
items={statsItems.map((item) => ({
...item,
asOf: statsAsOf,
}))}
asOfPrefix={page.stats.asOfPrefix}
items={statsItems}
/>
<TripleTextBlock columns={tripleColumns} />
<Book
+1 -1
View File
@@ -93,7 +93,7 @@ const Logo = memo<LogoProps>(
? "text-[var(--color-content-invert-primary)]"
: "text-[var(--color-content-default-primary)]";
const wordmarkVisibilityClass =
size === "topNavFolderTop" || size === "topNavHeader"
size === "topNavHeader"
? wordmark
? "hidden md:block"
: "hidden"
+3
View File
@@ -4,6 +4,9 @@ export interface StatProps {
value: string;
label: string;
asOf?: string;
asOfPrefix?: string;
sourceHref?: string;
sourceName?: string;
shapeVariant?: StatShapeVariant;
className?: string;
}
+21
View File
@@ -11,9 +11,14 @@ function StatView({
value,
label,
asOf,
asOfPrefix,
sourceHref,
sourceName,
shapeVariant,
className = "",
}: StatViewProps) {
const sourcedYear = Boolean(asOfPrefix || sourceHref);
return (
<article
className={`relative flex h-auto min-h-[182px] w-full flex-col items-start justify-between rounded-[var(--radius-measures-radius-xlarge,20px)] bg-[var(--color-surface-invert-primary,white)] px-[var(--spacing-scale-024)] py-[var(--spacing-scale-032)] sm:h-[170px] sm:min-h-0 sm:p-[var(--spacing-scale-024)] ${className}`.trim()}
@@ -34,7 +39,23 @@ function StatView({
</div>
{asOf ? (
<p className="w-full text-xx-small-paragraph text-[var(--color-content-invert-tertiary,#2d2d2d)]">
{sourcedYear ? (
<>
{asOfPrefix ? <span>{`${asOfPrefix} `}</span> : null}
{sourceHref ? (
<a href={sourceHref} className="underline">
{asOf}
{sourceName ? (
<span className="sr-only">{` (${sourceName})`}</span>
) : null}
</a>
) : (
<span className="underline">{asOf}</span>
)}
</>
) : (
asOf
)}
</p>
) : null}
</article>
@@ -1,7 +1,8 @@
"use client";
/**
* Figma: "Navigation / Top" (22078-808559)
* Figma: "Navigation / Top" (22078-808559); Folder-top 430639 (18582:22056),
* 6401023 (18580:17113).
*/
import { memo, useCallback } from "react";
@@ -39,6 +40,9 @@ const FOLDER_NAV_ITEM_RESPONSIVE_CLASS =
const CREATE_RULE_RESPONSIVE_CLASS =
"lg:p-[var(--spacing-scale-012)] lg:gap-[var(--spacing-scale-006)] lg:text-medium-label xl:p-[var(--spacing-scale-016)] xl:gap-[var(--spacing-scale-008)] xl:text-x-large-label";
const FOLDER_CREATE_RULE_RESPONSIVE_CLASS =
"md:p-[var(--spacing-scale-008)] md:gap-[var(--spacing-scale-002)] md:text-x-small-label lg:p-[var(--spacing-scale-012)] lg:gap-[var(--spacing-scale-006)] lg:text-medium-label xl:p-[var(--spacing-scale-016)] xl:gap-[var(--spacing-scale-008)] xl:text-x-large-label";
const TopContainer = memo<TopProps>(
({ folderTop = false, loggedIn = false, profile = false, logIn = true }) => {
const pathname = usePathname();
@@ -120,6 +124,9 @@ const TopContainer = memo<TopProps>(
const navSelected =
(loggedIn && pathname === "/profile") ||
(!loggedIn && pathname === "/login");
const loginClassName = folderTop
? FOLDER_NAV_ITEM_RESPONSIVE_CLASS
: NAV_ITEM_RESPONSIVE_CLASS;
if (loggedIn) {
return (
@@ -128,7 +135,7 @@ const TopContainer = memo<TopProps>(
size="X Small"
mode="default"
state={navSelected ? "selected" : "default"}
className={NAV_ITEM_RESPONSIVE_CLASS}
className={loginClassName}
ariaLabel={ariaLabel}
>
{label}
@@ -149,7 +156,7 @@ const TopContainer = memo<TopProps>(
size="X Small"
mode="default"
state={navSelected ? "selected" : "default"}
className={NAV_ITEM_RESPONSIVE_CLASS}
className={loginClassName}
ariaLabel={ariaLabel}
>
{label}
@@ -165,11 +172,19 @@ const TopContainer = memo<TopProps>(
palette="inverse"
onClick={handleCreateRuleClick}
ariaLabel={t("ariaLabels.createNewRule")}
className={CREATE_RULE_RESPONSIVE_CLASS}
className={
folderTop
? FOLDER_CREATE_RULE_RESPONSIVE_CLASS
: CREATE_RULE_RESPONSIVE_CLASS
}
>
<AvatarContainer
size="small"
className="lg:-space-x-[var(--spacing-scale-010)] xl:-space-x-[13px]"
className={
folderTop
? "md:-space-x-[9px] lg:-space-x-[var(--spacing-scale-010)] xl:-space-x-[13px]"
: "lg:-space-x-[var(--spacing-scale-010)] xl:-space-x-[13px]"
}
>
{avatarImageSources.map((src, index) => (
<Avatar
@@ -177,7 +192,11 @@ const TopContainer = memo<TopProps>(
src={src}
alt={tTopNav(`avatarAlts.${3 - index}`)}
size="small"
className="lg:h-[var(--spacing-scale-024)] lg:w-[var(--spacing-scale-024)] xl:h-[var(--spacing-scale-032)] xl:w-[var(--spacing-scale-032)]"
className={
folderTop
? "md:h-[var(--spacing-scale-018)] md:w-[var(--spacing-scale-018)] lg:h-[var(--spacing-scale-024)] lg:w-[var(--spacing-scale-024)] xl:h-[var(--spacing-scale-032)] xl:w-[var(--spacing-scale-032)]"
: "lg:h-[var(--spacing-scale-024)] lg:w-[var(--spacing-scale-024)] xl:h-[var(--spacing-scale-032)] xl:w-[var(--spacing-scale-032)]"
}
/>
))}
</AvatarContainer>
+18 -13
View File
@@ -37,17 +37,17 @@ function TopView({
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaData) }}
/>
<header
className="w-full overflow-hidden bg-transparent"
className="w-full overflow-visible bg-transparent"
role="banner"
aria-label={t("ariaLabels.homePageNavigationHeader")}
>
<nav
className="relative mx-auto flex h-[50px] items-end justify-between gap-[var(--spacing-scale-008)] pl-[var(--spacing-scale-008)] pr-[var(--spacing-scale-016)] pt-[var(--spacing-scale-010)] sm:h-[62px] sm:px-[var(--spacing-scale-010)] sm:pr-[var(--spacing-scale-020)] sm:pt-[var(--spacing-scale-010)] md:h-[68px] md:px-[var(--spacing-scale-016)] md:pr-[var(--spacing-scale-032)] md:pt-[var(--spacing-scale-016)] lg:h-[68px] lg:pl-[var(--spacing-scale-024)] lg:pr-[var(--spacing-scale-056)] lg:pt-[var(--spacing-scale-016)] xl:h-[88px] xl:pl-[var(--spacing-scale-048)] xl:pr-[var(--spacing-scale-056)] xl:pt-[var(--spacing-scale-024)]"
className="relative mx-auto flex h-[50px] items-end justify-between pl-[var(--spacing-scale-008)] pr-[var(--spacing-scale-016)] pt-[var(--spacing-scale-010)] sm:h-[62px] sm:px-[var(--spacing-scale-010)] sm:pr-[var(--spacing-scale-020)] sm:pt-[var(--spacing-scale-010)] md:h-[68px] md:px-[var(--spacing-scale-016)] md:pr-[var(--spacing-scale-032)] md:pt-[var(--spacing-scale-016)] lg:h-[68px] lg:pl-[var(--spacing-scale-024)] lg:pr-[var(--spacing-scale-056)] lg:pt-[var(--spacing-scale-016)] xl:h-[88px] xl:pl-[var(--spacing-scale-048)] xl:pr-[var(--spacing-scale-056)] xl:pt-[var(--spacing-scale-024)]"
role="navigation"
aria-label={t("ariaLabels.mainNavigation")}
>
<div className="HeaderTab header-breakpoint-transition relative flex h-[var(--spacing-scale-040)] min-w-0 flex-1 items-center self-end rounded-tl-[var(--radius-measures-radius-medium)] rounded-tr-[var(--radius-measures-radius-medium)] bg-[var(--color-surface-inverse-brand-primary)] pl-[var(--spacing-scale-012)] pr-[var(--spacing-scale-012)] sm:mr-[var(--spacing-scale-008)] sm:h-[52px] sm:rounded-t-[var(--radius-measures-radius-xlarge)] sm:pr-[var(--spacing-scale-006)] md:h-[52px] md:rounded-t-[var(--radius-measures-radius-xlarge)] md:pl-[var(--spacing-scale-024)] md:pr-[var(--spacing-scale-012)] lg:h-[52px] lg:rounded-t-[var(--radius-measures-radius-xlarge)] lg:pl-[var(--spacing-scale-024)] lg:pr-[var(--spacing-scale-048)] xl:h-[64px] xl:rounded-t-[var(--radius-measures-radius-xlarge)] xl:pl-[var(--spacing-scale-032)] xl:pr-[var(--spacing-scale-120)]">
<div className="shrink-0">
<div className="HeaderTab header-breakpoint-transition relative flex h-[var(--spacing-scale-040)] w-fit min-w-0 items-center self-end overflow-visible rounded-tl-[var(--radius-measures-radius-medium)] rounded-tr-[var(--radius-measures-radius-medium)] bg-[var(--color-surface-inverse-brand-primary)] pl-[var(--spacing-scale-012)] pr-[var(--spacing-scale-012)] sm:mr-[var(--spacing-scale-008)] sm:h-[52px] sm:w-auto sm:flex-1 sm:rounded-t-[var(--radius-measures-radius-xlarge)] sm:pr-[var(--spacing-scale-006)] md:h-[52px] md:min-w-0 md:rounded-t-[var(--radius-measures-radius-xlarge)] md:pl-[var(--spacing-scale-024)] md:pr-[var(--spacing-scale-012)] lg:mr-[var(--spacing-scale-024)] lg:h-[52px] lg:rounded-t-[var(--radius-measures-radius-xlarge)] lg:pl-[var(--spacing-scale-024)] lg:pr-[var(--spacing-scale-048)] xl:mr-[var(--spacing-scale-032)] xl:h-[64px] xl:rounded-t-[var(--radius-measures-radius-xlarge)] xl:pl-[var(--spacing-scale-032)] xl:pr-[var(--spacing-scale-120)]">
<div className="relative z-[1] shrink-0">
<Logo
size={logoSize}
wordmark
@@ -55,7 +55,10 @@ function TopView({
/>
</div>
<div className="ml-auto shrink-0" data-top="nav">
<div
className="relative z-[1] shrink-0 md:absolute md:top-1/2 md:left-[calc(50vw-var(--spacing-scale-016))] md:-translate-x-1/2 md:-translate-y-1/2 lg:left-[calc(50vw-var(--spacing-scale-024))] xl:left-[calc(50vw-var(--spacing-scale-048))]"
data-top="nav"
>
<Menu
size="X Small"
className="md:gap-[var(--spacing-scale-004)] lg:gap-[var(--spacing-scale-012)]"
@@ -69,21 +72,21 @@ function TopView({
src={getAssetPath(ASSETS.UNION_XSM)}
alt=""
role="presentation"
className="absolute -bottom-[3px] -right-[52px] -z-10 h-[24px] w-[61px] sm:hidden sm:h-[31.5px] sm:w-[61px]"
className="pointer-events-none absolute -bottom-[3px] -right-[52px] z-0 h-[24px] w-[61px] sm:hidden sm:h-[31.5px] sm:w-[61px]"
/>
{/* eslint-disable-next-line @next/next/no-img-element -- decorative SVG */}
<img
src={getAssetPath(ASSETS.UNION_SM_MD_LG)}
alt=""
role="presentation"
className="absolute -bottom-[3.7px] -right-[53px] -z-10 hidden h-[24px] w-[61px] sm:block sm:h-[31.5px] sm:w-[61px] xl:hidden"
className="pointer-events-none absolute -bottom-[3.7px] -right-[53px] z-0 hidden h-[24px] w-[61px] sm:block sm:h-[31.5px] sm:w-[61px] xl:hidden"
/>
{/* eslint-disable-next-line @next/next/no-img-element -- decorative SVG */}
<img
src={getAssetPath(ASSETS.UNION_XLG)}
alt=""
role="presentation"
className="absolute -bottom-[6px] -right-[94px] -z-10 hidden h-[53px] w-[105px] xl:block"
className="pointer-events-none absolute -bottom-[6px] -right-[94px] z-0 hidden h-[53px] w-[105px] xl:block"
/>
</div>
@@ -103,8 +106,10 @@ function TopView({
/**
* Standard marketing / app top nav.
* Figma: "Navigation / Top" (Community-Rule-System, node 22078-808559).
* Three columns so the logo cannot paint over the nav cluster (no absolute
* overlay, no reserved 200px hit box below `lg`).
* Below md: auto | 1fr | auto so the logo cannot paint over the cluster.
* From md: 1fr | auto | 1fr so the cluster sits on the same page midline
* as Folder-top (Create rule is wider than the logo, so a leftover-column
* center sits off-center).
*/
return (
<>
@@ -118,11 +123,11 @@ function TopView({
aria-label={t("ariaLabels.mainNavigationHeader")}
>
<nav
className="grid w-full grid-cols-[auto_minmax(0,1fr)_auto] items-center px-[var(--spacing-scale-016)] py-[var(--spacing-scale-008)] sm:px-[var(--spacing-measures-spacing-016)] lg:px-[var(--spacing-measures-spacing-64,64px)] lg:py-[var(--spacing-scale-016)]"
className="grid w-full grid-cols-[auto_minmax(0,1fr)_auto] items-center px-[var(--spacing-scale-016)] py-[var(--spacing-scale-008)] sm:px-[var(--spacing-measures-spacing-016)] md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] lg:px-[var(--spacing-measures-spacing-64,64px)] lg:py-[var(--spacing-scale-016)]"
role="navigation"
aria-label={t("ariaLabels.mainNavigation")}
>
<div className="min-w-0 shrink-0 lg:w-[200px] lg:max-w-[200px]" data-top="logo">
<div className="min-w-0 shrink-0 justify-self-start lg:w-[200px] lg:max-w-[200px]" data-top="logo">
<Logo
size={logoSize}
wordmark
@@ -143,7 +148,7 @@ function TopView({
</div>
<div
className="flex shrink-0 items-center gap-[var(--spacing-scale-004)] md:gap-[var(--spacing-measures-spacing-010)] lg:gap-[var(--spacing-measures-spacing-004)]"
className="flex shrink-0 items-center justify-self-end gap-[var(--spacing-scale-004)] md:gap-[var(--spacing-measures-spacing-010)] lg:gap-[var(--spacing-measures-spacing-004)]"
data-top="auth"
>
{loginControl}
@@ -1,11 +1,11 @@
"use client";
/**
* Figma: "Section / Feature-Grid" (18847:22410)
* Figma: "Section / Feature-Grid" (18632:10668)
*/
import { memo, useMemo } from "react";
import { getAssetPath, featurePanelLayout, featurePanelPath } from "../../../../lib/assetUtils";
import { featureIconPath, getAssetPath } from "../../../../lib/assetUtils";
import { useTranslation } from "../../../contexts/MessagesContext";
import FeatureGridView from "./FeatureGrid.view";
import type { FeatureGridProps, Feature } from "./FeatureGrid.types";
@@ -17,52 +17,40 @@ const FeatureGridContainer = memo<FeatureGridProps>(
const features: Feature[] = useMemo(
() => [
{
backgroundColor: "bg-[var(--color-surface-invert-brand-royal)]",
labelLine1: t(
"pages.home.featureGrid.features.decisionMaking.labelLine1",
backgroundColor: "bg-[var(--color-surface-invert-brand-lavender)]",
title: t("pages.home.featureGrid.features.decisionMaking.title"),
description: t(
"pages.home.featureGrid.features.decisionMaking.description",
),
labelLine2: t(
"pages.home.featureGrid.features.decisionMaking.labelLine2",
),
panelContent: getAssetPath(featurePanelPath("support")),
...featurePanelLayout("support"),
ariaLabel: t("featureGrid.features.decisionMaking.ariaLabel"),
descriptionClassName: "text-[#3a3273]",
iconSrc: getAssetPath(featureIconPath("git-branch")),
},
{
backgroundColor: "bg-[var(--color-surface-invert-brand-lime)]",
labelLine1: t(
"pages.home.featureGrid.features.valuesAlignment.labelLine1",
title: t("pages.home.featureGrid.features.valuesAlignment.title"),
description: t(
"pages.home.featureGrid.features.valuesAlignment.description",
),
labelLine2: t(
"pages.home.featureGrid.features.valuesAlignment.labelLine2",
),
panelContent: getAssetPath(featurePanelPath("exercises")),
...featurePanelLayout("exercises"),
ariaLabel: t("featureGrid.features.valuesAlignment.ariaLabel"),
descriptionClassName: "text-[#424f1a]",
iconSrc: getAssetPath(featureIconPath("arrow-left-right")),
},
{
backgroundColor: "bg-[var(--color-surface-invert-brand-rust)]",
labelLine1: t(
"pages.home.featureGrid.features.membershipGuidance.labelLine1",
title: t("pages.home.featureGrid.features.membershipGuidance.title"),
description: t(
"pages.home.featureGrid.features.membershipGuidance.description",
),
labelLine2: t(
"pages.home.featureGrid.features.membershipGuidance.labelLine2",
),
panelContent: getAssetPath(featurePanelPath("guidance")),
...featurePanelLayout("guidance"),
ariaLabel: t("featureGrid.features.membershipGuidance.ariaLabel"),
descriptionClassName: "text-[#5e2e23]",
iconSrc: getAssetPath(featureIconPath("door-open")),
},
{
backgroundColor: "bg-[var(--color-surface-invert-brand-teal)]",
labelLine1: t(
"pages.home.featureGrid.features.conflictResolution.labelLine1",
title: t("pages.home.featureGrid.features.conflictResolution.title"),
description: t(
"pages.home.featureGrid.features.conflictResolution.description",
),
labelLine2: t(
"pages.home.featureGrid.features.conflictResolution.labelLine2",
),
panelContent: getAssetPath(featurePanelPath("tools")),
...featurePanelLayout("tools"),
ariaLabel: t("featureGrid.features.conflictResolution.ariaLabel"),
descriptionClassName: "text-[#1f4d47]",
iconSrc: getAssetPath(featureIconPath("message-square-share")),
},
],
[t],
@@ -6,13 +6,10 @@ export interface FeatureGridProps {
export interface Feature {
backgroundColor: string;
labelLine1: string;
labelLine2: string;
panelContent: string;
panelWidth: number;
panelHeight: number;
panelImageClassName?: string;
ariaLabel: string;
title: string;
description: string;
descriptionClassName: string;
iconSrc: string;
}
export interface FeatureGridViewProps extends FeatureGridProps {
@@ -2,10 +2,9 @@
import { useTranslation } from "../../../contexts/MessagesContext";
import ContentLockup from "../../type/ContentLockup";
import Mini from "../../cards/Mini";
import type { FeatureGridViewProps } from "./FeatureGrid.types";
/** Figma **Section / Feature-Grid** [18847:22410](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=18847-22410&m=dev). */
/** Figma **Section / Feature-Grid** [18632:10668](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=18632-10668&m=dev). */
function FeatureGridView({
title,
subtitle,
@@ -20,16 +19,16 @@ function FeatureGridView({
return (
<section
className={`p-0 lg:p-[var(--spacing-scale-064)] ${className}`}
className={`p-0 md:px-[var(--spacing-scale-024)] lg:p-[var(--spacing-scale-064)] ${className}`}
aria-labelledby={labelledBy}
aria-label={labelledBy ? undefined : ariaLabel}
>
<div
data-figma-node="18847-22410"
className="rounded-[var(--measures-radius-500,20px)] bg-[var(--color-surface-default-secondary)] px-[var(--spacing-scale-020)] py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-048)] md:pb-[var(--spacing-scale-048)] md:pt-[var(--spacing-scale-076)] lg:pb-[var(--spacing-scale-076)]"
data-figma-node="18632-10668"
className="rounded-none bg-[var(--color-surface-default-secondary)] px-[var(--spacing-scale-016)] py-[var(--spacing-scale-032)] md:rounded-[var(--radius-measures-radius-large)] md:p-[var(--spacing-scale-048)] lg:p-[var(--spacing-scale-064)]"
>
<div className="mx-auto w-full gap-[var(--spacing-scale-048)] [container-type:inline-size] lg:flex lg:items-start lg:gap-[var(--spacing-scale-048)]">
<div className="lg:min-w-0 lg:shrink">
<div className="mx-auto flex w-full flex-col gap-[var(--spacing-scale-040)] md:gap-[var(--spacing-scale-048)] lg:flex-row lg:items-center lg:gap-[var(--spacing-scale-064)]">
<div className="lg:min-w-0 lg:flex-1">
<ContentLockup
title={title}
subtitle={subtitle}
@@ -40,20 +39,34 @@ function FeatureGridView({
/>
</div>
<div className="mt-[var(--spacing-scale-048)] grid grid-cols-2 grid-rows-[repeat(2,minmax(0,1fr))] gap-x-[12px] gap-y-[12px] max-md:min-h-[384px] md:grid-cols-4 md:grid-rows-1 md:min-h-0 lg:mt-0 lg:shrink-0 lg:flex-grow">
{features.map((feature, index) => (
<Mini
key={index}
backgroundColor={feature.backgroundColor}
labelLine1={feature.labelLine1}
labelLine2={feature.labelLine2}
panelContent={feature.panelContent}
panelWidth={feature.panelWidth}
panelHeight={feature.panelHeight}
panelImageClassName={feature.panelImageClassName}
ariaLabel={feature.ariaLabel}
featureGridShell
<div className="grid w-full grid-cols-1 gap-[var(--spacing-scale-008)] md:grid-cols-2 md:gap-[var(--spacing-scale-016)] lg:flex-1">
{features.map((feature) => (
<div
key={feature.title}
className={`flex flex-col gap-[var(--spacing-scale-016)] p-[var(--spacing-scale-024)] ${feature.backgroundColor} rounded-[var(--radius-200)] md:rounded-[var(--radius-400)] lg:rounded-[var(--radius-measures-radius-large)]`}
>
<div className="size-[40px] shrink-0 overflow-clip">
{/* eslint-disable-next-line @next/next/no-img-element -- decorative Figma glyph */}
<img
src={feature.iconSrc}
alt=""
width={40}
height={40}
className="size-full object-contain"
role="presentation"
/>
</div>
<div className="flex w-full flex-col gap-[var(--spacing-scale-008)]">
<p className="font-semibold text-[17px] leading-[22px] text-[var(--color-content-inverse-primary)]">
{feature.title}
</p>
<p
className={`text-small-paragraph ${feature.descriptionClassName}`}
>
{feature.description}
</p>
</div>
</div>
))}
</div>
</div>
@@ -4,6 +4,9 @@ export interface StatItem {
value: string;
label: string;
asOf?: string;
asOfPrefix?: string;
sourceHref?: string;
sourceName?: string;
shapeVariant?: StatShapeVariant;
}
@@ -11,6 +14,7 @@ export interface StatsProps {
titlePrefix?: string;
titleEmphasis?: string;
titleSuffix?: string;
asOfPrefix?: string;
items: StatItem[];
className?: string;
}
+6 -1
View File
@@ -21,6 +21,7 @@ function StatsView({
titlePrefix,
titleEmphasis,
titleSuffix,
asOfPrefix,
items,
headingId,
className = "",
@@ -88,7 +89,11 @@ function StatsView({
return (
<li key={`${item.value}-${index}`} className={staggerClass}>
<Stat {...item} className={heightClass} />
<Stat
{...item}
asOfPrefix={item.asOfPrefix ?? asOfPrefix}
className={heightClass}
/>
</li>
);
})}
@@ -25,16 +25,27 @@ function AboutHeaderView({
variant="about"
alignment="left"
titleId={titleId}
titleContent={segments.map((segment, index) => {
if (segment.type === "word") {
return (
<span key={`${segment.text}-${index}`} className="whitespace-nowrap">
{segment.text}
titleContent={segments.flatMap((segment, index) => {
const spaceBefore =
segment.type === "word" && index > 0 ? (
<span key={`space-${index}`} className="sr-only">
{" "}
</span>
);
) : null;
if (segment.type === "word") {
return [
spaceBefore,
<span
key={`${segment.text}-${index}`}
className="whitespace-nowrap"
>
{segment.text}
</span>,
];
}
return (
return [
<span
key={`${segment.icon}-${index}`}
className={
@@ -50,8 +61,8 @@ function AboutHeaderView({
className="size-full object-contain"
role="presentation"
/>
</span>
);
</span>,
];
})}
/>
</section>
@@ -1,6 +1,7 @@
"use client";
import { memo } from "react";
import NextLink from "next/link";
import Button from "../../buttons/Button";
import { contentLockupShapePath, getAssetPath } from "../../../../lib/assetUtils";
import type { ContentLockupViewProps } from "./ContentLockup.types";
@@ -84,8 +85,13 @@ function ContentLockupView({
)}
</div>
{/* Subtitle */}
{subtitle ? <h2 className={styles.subtitle}>{subtitle}</h2> : null}
{subtitle ? (
variant === "feature" ? (
<p className={styles.subtitle}>{subtitle}</p>
) : (
<h2 className={styles.subtitle}>{subtitle}</h2>
)
) : null}
</div>
{/* Description */}
@@ -93,14 +99,13 @@ function ContentLockupView({
</div>
)}
{/* Link for feature variant */}
{variant === "feature" && linkText && linkHref && (
<a
<NextLink
href={linkHref}
className="text-medium-underline underline text-[var(--color-content-default-primary)] hover:text-[var(--color-content-default-secondary)] transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 focus:ring-offset-[var(--color-surface-default-secondary)] rounded-sm px-1 py-0.5"
className="w-fit self-start cursor-pointer text-small-paragraph md:text-medium-paragraph text-[var(--color-content-default-primary)] underline decoration-solid [text-underline-position:from-font] hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)]"
>
{linkText}
</a>
</NextLink>
)}
{/* CTA Button */}
@@ -110,7 +115,7 @@ function ContentLockupView({
<div className="block md:hidden">
<Button
buttonType="filled"
palette={variant === "hero" ? "default" : "inverse"}
palette="inverse"
size="small"
href={ctaHref}
>
@@ -121,7 +126,7 @@ function ContentLockupView({
<div className="hidden md:block xl:hidden">
<Button
buttonType="filled"
palette={variant === "hero" ? "default" : "inverse"}
palette="inverse"
size="large"
className={buttonClassName}
href={ctaHref}
@@ -133,7 +138,7 @@ function ContentLockupView({
<div className="hidden xl:block">
<Button
buttonType="filled"
palette={variant === "hero" ? "default" : "inverse"}
palette="inverse"
size="xlarge"
href={ctaHref}
>
-23
View File
@@ -58,26 +58,3 @@ body {
background-color: black;
min-height: 100vh;
}
/* Skip link: parked above the viewport until focused. */
.skip-to-content {
position: absolute;
left: var(--spacing-scale-016);
top: var(--spacing-scale-016);
z-index: 100;
padding: var(--spacing-scale-012) var(--spacing-scale-016);
border-radius: var(--radius-measures-radius-full);
background-color: var(--color-surface-inverse-primary);
color: var(--color-content-inverse-primary);
text-decoration: none;
outline: none;
cursor: pointer;
box-shadow:
0 0 0 2px var(--color-border-default-primary),
0 0 0 4px var(--color-border-invert-primary);
transform: translateY(-200%);
}
.skip-to-content:focus,
.skip-to-content:focus-visible {
transform: translateY(0);
}
+27
View File
@@ -45,6 +45,33 @@
background: #292d32;
}
/*
* Must live in this file (same sheet as `@import "tailwindcss"`). Rules in
* `globals.css` after that import never reach the browser.
* Parked above the viewport until focused so it does not add header offset.
*/
.skip-to-content {
position: absolute;
left: var(--spacing-scale-016);
top: var(--spacing-scale-016);
z-index: 100;
padding: var(--spacing-scale-012) var(--spacing-scale-016);
border-radius: var(--radius-measures-radius-full);
background-color: var(--color-surface-inverse-primary);
color: var(--color-content-inverse-primary);
text-decoration: none;
outline: none;
cursor: pointer;
box-shadow:
0 0 0 2px var(--color-border-default-primary),
0 0 0 4px var(--color-border-invert-primary);
transform: translateY(-200%);
}
.skip-to-content:focus,
.skip-to-content:focus-visible {
transform: translateY(0);
}
@theme inline {
/* Custom breakpoints */
--breakpoint-xsm: 429px;
+4 -3
View File
@@ -8,7 +8,7 @@ Convention for files served from `public/`. Path helpers live in
```
public/
assets/
icons/ # UI chrome (alert, close, help, pointer)
icons/ # UI chrome (close, help) + FeatureGrid card glyphs
logos/ # Brand + social lockups
partners/ # Logo wall partner SVGs (kebab org slug)
marketing/ # Hero, feature panels, section numbers, avatars, banners, book cover
@@ -25,7 +25,7 @@ public/
| Location | Used for | Resolution |
| --- | --- | --- |
| `public/assets/icons/` | Static chrome served by URL (`icon-close.svg`, `icon-help.svg`) | `ASSETS.ICON_*` |
| `public/assets/icons/` | Static chrome served by URL (`icon-close.svg`, `icon-help.svg`) and FeatureGrid card glyphs (`icon-git-branch.svg`, …) | `ASSETS.ICON_*`, `featureIconPath()` |
| `app/components/asset/icon/` | Bundled create-flow / nav SVGs imported by `Icon.tsx` | Webpack import, not `public/` |
Do not duplicate the same glyph in both places unless migrating between systems.
@@ -61,7 +61,8 @@ stage. Raster → SVG conversion is tracked in
| Path | Used by | Disposition |
| --- | --- | --- |
| `logos/partners/*.svg` (×6) | LogoWall | **Done** — SVG (kebab org slug, no `logo-` prefix) |
| `marketing/feature-*.svg` (×4) | FeatureGrid | Exported from Figma Section/Feature-Grid (18847:22410) |
| `marketing/feature-*.svg` (×4) | Mini cards | Panel art (`support`, `exercises`, `guidance`, `tools`) |
| `icons/icon-{git-branch,arrow-left-right,door-open,message-square-share}.svg` | FeatureGrid | Card glyphs from Figma Section/Feature-Grid (18632:10668) |
| `marketing/section-number-*.svg` (×3) | SectionNumber | **Done** — SVG |
| `marketing/avatar-*.svg` (×3) | Avatar / ASSETS | **Done** — SVG |
| `marketing/hero-image.png` | HeroBanner | **Design review** — likely keep raster |
+8 -22
View File
@@ -114,29 +114,15 @@ export function featurePanelPath(key: FeaturePanelKey): string {
return `assets/marketing/feature-${key}.svg`;
}
/** Intrinsic icon bounds from Figma Feature-Grid (18632:10911). */
export const FEATURE_PANEL_LAYOUT: Record<
FeaturePanelKey,
{ width: number; height: number; panelImageClassName?: string }
> = {
support: { width: 48, height: 48 },
exercises: { width: 55, height: 48 },
guidance: { width: 56, height: 39 },
tools: {
width: 50,
height: 47,
/** Figma 18632:10947 — raw asset is inverted; frame applies rotate + flip. */
panelImageClassName: "rotate-180 -scale-x-100",
},
};
/** Feature-grid card glyphs in `public/assets/icons/` (Figma **18632:10668**). */
export type FeatureIconKey =
| "git-branch"
| "arrow-left-right"
| "door-open"
| "message-square-share";
export function featurePanelLayout(key: FeaturePanelKey): {
panelWidth: number;
panelHeight: number;
panelImageClassName?: string;
} {
const { width, height, panelImageClassName } = FEATURE_PANEL_LAYOUT[key];
return { panelWidth: width, panelHeight: height, panelImageClassName };
export function featureIconPath(key: FeatureIconKey): string {
return `assets/icons/icon-${key}.svg`;
}
/** Case study card artwork in `public/assets/case-study/`. */
+6 -6
View File
@@ -1,20 +1,20 @@
{
"_comment": "FeatureGrid component defaults (shared across pages). Learn more goes to /learn; feature cards stay non-interactive until destinations are decided.",
"linkText": "Learn more",
"_comment": "FeatureGrid component defaults (shared across pages). Explore the toolkit goes to /learn; feature cards stay non-interactive until destinations are decided.",
"linkText": "Explore the toolkit",
"linkHref": "/learn",
"ariaLabel": "Feature tools and services",
"features": {
"decisionMaking": {
"ariaLabel": "Decision-making support tools"
"ariaLabel": "Choose how you decide"
},
"valuesAlignment": {
"ariaLabel": "Values alignment exercises"
"ariaLabel": "Name your values"
},
"membershipGuidance": {
"ariaLabel": "Membership guidance resources"
"ariaLabel": "Define membership"
},
"conflictResolution": {
"ariaLabel": "Conflict resolution tools"
"ariaLabel": "Plan for conflict"
}
}
}
+19 -7
View File
@@ -23,27 +23,39 @@
"titlePrefix": "From",
"titleEmphasis": "projects",
"titleSuffix": "to communities",
"asOf": "as of June 30, 2024",
"asOfPrefix": "as of",
"items": [
{
"value": "420M+",
"label": "open source projects",
"shapeVariant": "yellow"
"shapeVariant": "yellow",
"asOf": "2025",
"sourceHref": "https://github.blog/news-insights/octoverse/octoverse-a-new-developer-joins-github-every-second-as-ai-leads-typescript-to-1/",
"sourceName": "GitHub Octoverse"
},
{
"value": "27%",
"value": "24%",
"label": "year over year growth in open source",
"shapeVariant": "purple"
"shapeVariant": "purple",
"asOf": "2025",
"sourceHref": "https://github.blog/news-insights/octoverse/octoverse-a-new-developer-joins-github-every-second-as-ai-leads-typescript-to-1/",
"sourceName": "GitHub Octoverse"
},
{
"value": "45%",
"label": "adults in the US participate in community service",
"shapeVariant": "green"
"shapeVariant": "green",
"asOf": "2025",
"sourceHref": "https://www.americorps.gov/about/our-impact/volunteering-civic-life",
"sourceName": "AmeriCorps Volunteering and Civic Life in America"
},
{
"value": "8000+",
"value": "800+",
"label": "mutual aid groups in the U.S.",
"shapeVariant": "orange"
"shapeVariant": "orange",
"asOf": "2026",
"sourceHref": "https://www.nea.org/nea-today/all-news-articles/mutual-aid-act-resistance-and-community-support",
"sourceName": "Mutual Aid Hub via NEA"
}
]
},
+10 -10
View File
@@ -29,24 +29,24 @@
}
},
"featureGrid": {
"title": "We've got your back, every step of the way",
"subtitle": "Use our toolkit to improve, document, and evolve your organization.",
"title": "Tools for the parts that get hard",
"subtitle": "Every group runs into the same knots. CommunityRule gives you a way through, and a written record of what you decided.",
"features": {
"decisionMaking": {
"labelLine1": "Decision-making",
"labelLine2": "support"
"title": "Choose how you decide",
"description": "Compare consensus, do-ocracy, and other patterns, then commit to one."
},
"valuesAlignment": {
"labelLine1": "Values alignment",
"labelLine2": "exercises"
"title": "Name your values",
"description": "Short exercises that surface what your group agrees on before it's tested."
},
"membershipGuidance": {
"labelLine1": "Membership",
"labelLine2": "guidance"
"title": "Define membership",
"description": "Spell out how people join, take on roles, and step away."
},
"conflictResolution": {
"labelLine1": "Conflict resolution",
"labelLine2": "tools"
"title": "Plan for conflict",
"description": "Agree on how you'll handle disagreement while everyone is still calm."
}
}
},
@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="arrow-left-right">
<path id="Vector" d="M13.334 18.3333L6.668 11.6667L13.334 5M6.668 11.6667H33.332M26.666 21.6667L33.332 28.3333L26.666 35M33.332 28.3333H6.668" stroke="#6AA90B" stroke-width="2" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 406 B

+5
View File
@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="door-open">
<path id="Vector" d="M18.3332 33.3351H3.332M31.6676 33.3354L20.4034 36.1505C20.1577 36.2117 19.9013 36.2162 19.6537 36.1636C19.406 36.111 19.1736 36.0027 18.974 35.8469C18.7745 35.691 18.6131 35.4918 18.5021 35.2643C18.391 35.0368 18.3333 34.7869 18.3332 34.5338V7.60489C18.3335 7.09852 18.4492 6.59721 18.6715 6.14222C18.8938 5.68724 19.2168 5.28887 19.6161 4.97737C20.0153 4.66586 20.4803 4.4494 20.9757 4.34442C21.4711 4.23944 21.9839 4.2487 22.4752 4.3715L29.1424 6.0382C29.8636 6.21846 30.5038 6.63458 30.9614 7.22044C31.4189 7.80629 31.6675 8.52826 31.6676 9.27159V33.3354ZM31.6676 33.3354L36.668 33.3351M18.3332 6.66788H13.3328C12.4487 6.66788 11.6008 7.01908 10.9756 7.64421C10.3504 8.26934 9.9992 9.11721 9.9992 10.0013V33.3351M23.3336 20.0015H23.3503" stroke="#D96043" stroke-width="2" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1018 B

+5
View File
@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="git-branch">
<path id="Vector" d="M25 10C21.0218 10 17.2064 11.5804 14.3934 14.3934C11.5804 17.2064 10 21.0218 10 25M25 10C25 12.7614 27.2386 15 30 15C32.7614 15 35 12.7614 35 10C35 7.23858 32.7614 5 30 5C27.2386 5 25 7.23858 25 10ZM10 25V5M10 25C7.23858 25 5 27.2386 5 30C5 32.7614 7.23858 35 10 35C12.7614 35 15 32.7614 15 30C15 27.2386 12.7614 25 10 25Z" stroke="#8D5AC4" stroke-width="2" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 602 B

@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="message-square-share">
<path id="Vector" d="M20 5H6.6656C5.78147 5 4.93356 5.35119 4.30839 5.97632C3.68322 6.60144 3.332 7.44929 3.332 8.33335V35.4768C3.33203 35.7108 3.40145 35.9396 3.53149 36.1342C3.66153 36.3287 3.84635 36.4804 4.06257 36.5699C4.27879 36.6595 4.51672 36.6829 4.74626 36.6373C4.9758 36.5916 5.18666 36.4789 5.35216 36.3135L9.02245 32.6435C9.64748 32.0183 10.4953 31.667 11.3793 31.6668H33.3344C34.2185 31.6668 35.0664 31.3156 35.6916 30.6905C36.3168 30.0654 36.668 29.2175 36.668 28.3334V21.6667M36.668 15V5H26.6672M36.668 5L26.6672 15" stroke="#16A9C4" stroke-width="2" stroke-linecap="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 800 B

+6 -6
View File
@@ -8,12 +8,12 @@ export default {
docs: {
description: {
component: `
Feature grid for the home marketing section (Figma 18847:22410).
Feature grid for the home marketing section (Figma 18632:10668).
- **Layout**: 2×2 on mobile, 1×4 on tablet, horizontal lockup + grid on desktop
- **Layout**: stacked cards on mobile, 2×2 from tablet, horizontal lockup + grid on desktop
- **Shell**: \`surface/default/secondary\` content block with responsive spacing tokens
- **ContentLockup**: Feature variant — 18/22 title & 14/20 subtitle (mobile), 24/32 title & 16/24 subtitle (640px+), 16/24 link
- **Mini tiles**: Invert-brand surfaces (royal, lime, rust, teal), 138px panel height, 48px icons
- **Cards**: Invert-brand surfaces (lavender, lime, rust, teal) with title, description, and 40px icons
`,
},
},
@@ -36,15 +36,15 @@ Feature grid for the home marketing section (Figma 18847:22410).
export const Default = {
args: {
title: "We've got your back, every step of the way",
title: "Tools for the parts that get hard",
subtitle:
"Use our toolkit to improve, document, and evolve your organization.",
"Every group runs into the same knots. CommunityRule gives you a way through, and a written record of what you decided.",
},
parameters: {
docs: {
description: {
story: `
Default FeatureGrid — responsive breakpoint layout with Figma styling (invert-brand tiles, secondary surface, updated lockup typography).
Default FeatureGrid — responsive breakpoint layout with Figma styling (invert-brand cards, secondary surface, updated lockup copy).
`,
},
},
+3 -3
View File
@@ -48,11 +48,11 @@ export const Feature = {
export const FeatureWithLink = {
args: {
title: "We've got your back, every step of the way",
title: "Tools for the parts that get hard",
subtitle:
"Use our toolkit to improve, document, and evolve your organization.",
"Every group runs into the same knots. CommunityRule gives you a way through, and a written record of what you decided.",
variant: "feature",
linkText: "Learn more",
linkText: "Explore the toolkit",
linkHref: "/learn",
},
};
+24 -39
View File
@@ -41,33 +41,33 @@ describe("FeatureGrid (behavioral tests)", () => {
expect(
screen.getByRole("heading", { name: "Test Title" }),
).toBeInTheDocument();
expect(screen.getByText("Test Subtitle")).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Test Subtitle" }),
).toBeInTheDocument();
screen.queryByRole("heading", { name: "Test Subtitle" }),
).not.toBeInTheDocument();
});
it("renders all four feature cards as static tiles", () => {
render(<FeatureGrid title="Test" subtitle="Test" />);
expect(screen.getByText("Decision-making")).toBeInTheDocument();
expect(screen.getByText("Values alignment")).toBeInTheDocument();
expect(screen.getByText("Membership")).toBeInTheDocument();
expect(screen.getByText("Conflict resolution")).toBeInTheDocument();
expect(screen.getByText("Choose how you decide")).toBeInTheDocument();
expect(screen.getByText("Name your values")).toBeInTheDocument();
expect(screen.getByText("Define membership")).toBeInTheDocument();
expect(screen.getByText("Plan for conflict")).toBeInTheDocument();
expect(
screen.queryByRole("link", { name: "Decision-making support tools" }),
screen.queryByRole("link", { name: "Choose how you decide" }),
).not.toBeInTheDocument();
});
it("sends Learn more to the Learn page", () => {
it("sends Explore the toolkit to the Learn page", () => {
render(<FeatureGrid title="Test" subtitle="Test" />);
expect(screen.getByRole("link", { name: "Learn more" })).toHaveAttribute(
"href",
"/learn",
);
expect(
screen.getByRole("link", { name: "Explore the toolkit" }),
).toHaveAttribute("href", "/learn");
});
it("does not apply a focus ring to the entire grid shell", () => {
render(<FeatureGrid title="Test" subtitle="Test" />);
const shell = document.querySelector('[data-figma-node="18847-22410"]');
const shell = document.querySelector('[data-figma-node="18632-10668"]');
expect(shell?.className).not.toContain("focus-within:ring-2");
});
@@ -83,11 +83,11 @@ describe("FeatureGrid (behavioral tests)", () => {
expect(section).toBeInTheDocument();
});
it("uses Figma invert surface colors on mini tiles", () => {
it("uses Figma invert surface colors on feature cards", () => {
render(<FeatureGrid title="Test" subtitle="Test" />);
expect(
document.querySelector(
'[class*="bg-[var(--color-surface-invert-brand-royal)]"]',
'[class*="bg-[var(--color-surface-invert-brand-lavender)]"]',
),
).toBeInTheDocument();
expect(
@@ -110,7 +110,7 @@ describe("FeatureGrid (behavioral tests)", () => {
it("marks the content block with the Figma node id", () => {
render(<FeatureGrid title="Test" subtitle="Test" />);
expect(
document.querySelector('[data-figma-node="18847-22410"]'),
document.querySelector('[data-figma-node="18632-10668"]'),
).toBeInTheDocument();
});
@@ -119,35 +119,20 @@ describe("FeatureGrid (behavioral tests)", () => {
const title = screen.getByRole("heading", { name: "Test Title" });
expect(title.className).toMatch(/text-\[18px\]/);
expect(title.className).toMatch(/md:text-\[length:var\(--sizing-600,24px\)\]/);
const subtitle = screen.getByRole("heading", { name: "Test Subtitle" });
const subtitle = screen.getByText("Test Subtitle");
expect(subtitle.tagName).toBe("P");
expect(subtitle).toHaveClass("text-small-paragraph", "md:text-medium-paragraph");
});
it("applies grain texture to feature grid icons", () => {
it("renders 40px decorative icons from Figma", () => {
render(<FeatureGrid title="Test" subtitle="Test" />);
const icons = document.querySelectorAll("section img");
expect(icons.length).toBeGreaterThanOrEqual(4);
expect(icons.length).toBe(4);
icons.forEach((icon) => {
expect(icon.getAttribute("style")).toContain("#grain");
expect(icon).toHaveAttribute("width", "40");
expect(icon).toHaveAttribute("height", "40");
expect(icon).toHaveAttribute("alt", "");
expect(icon.getAttribute("src")).toMatch(/^\/assets\/icons\/icon-/);
});
});
it("preserves per-icon aspect ratios from Figma layout", () => {
render(<FeatureGrid title="Test" subtitle="Test" />);
const icons = Array.from(document.querySelectorAll("section img"));
expect(icons.map((icon) => icon.getAttribute("width"))).toEqual([
"48",
"55",
"56",
"50",
]);
expect(icons.map((icon) => icon.getAttribute("height"))).toEqual([
"48",
"48",
"39",
"47",
]);
expect(icons[3]?.className).toContain("rotate-180");
expect(icons[3]?.className).toContain("-scale-x-100");
});
});
+5 -4
View File
@@ -57,10 +57,11 @@ describe("HeroBanner (behavioral tests)", () => {
).toBeInTheDocument();
});
it("renders CTA link when provided", () => {
it("renders CTA with the inverse (black) filled palette", () => {
render(<HeroBanner title="Test" ctaText="Get Started" ctaHref="/start" />);
expect(
screen.getAllByRole("link", { name: "Get Started" }).length,
).toBeGreaterThan(0);
const cta = screen.getAllByRole("link", { name: "Get Started" })[0];
expect(cta?.className).toContain(
"bg-[var(--color-surface-default-primary)]",
);
});
});
+6
View File
@@ -79,6 +79,12 @@ describe("Logo (behavioral tests)", () => {
expect(row?.className).toContain("h-[var(--spacing-scale-040)]");
});
it("shows the folder-top wordmark without a breakpoint hide", () => {
render(<Logo size="topNavFolderTop" wordmark />);
const wordmark = screen.getByText("CommunityRule");
expect(wordmark.className).not.toMatch(/\bhidden\b/);
});
it("renders with different size variants", () => {
const { rerender } = render(<Logo size="default" />);
expect(screen.getByRole("link")).toBeInTheDocument();
+17
View File
@@ -78,4 +78,21 @@ describe("SectionHeader rule stack title max width", () => {
}),
).toHaveClass("md:max-w-[571px]");
});
it("keeps spaces between stacked desktop title lines", () => {
render(
<SectionHeader
title="How CommunityRule helps"
subtitle="Start from a proven pattern."
variant="multi-line"
stackedDesktopLines={["How", "CommunityRule", "helps"]}
/>,
);
const heading = screen.getByRole("heading", {
name: /How CommunityRule helps/,
});
expect(heading.textContent).toMatch(/How CommunityRule helps/);
expect(heading.textContent).not.toMatch(/HowCommunityRulehelps/);
});
});
+33
View File
@@ -172,7 +172,40 @@ describe("Top header chrome", () => {
renderWithProviders(<Top folderTop={false} loggedIn />);
const logo = document.querySelector("[data-top='logo']");
const nav = document.querySelector("[data-top='nav']");
const headerNav = document.querySelector("header nav");
expect(logo?.className).not.toMatch(/\bz-20\b/);
expect(nav?.className).not.toMatch(/\babsolute\b/);
expect(headerNav?.className).toContain(
"md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]",
);
});
it("hugs the home folder tab below md and page-centers nav from md", () => {
renderWithProviders(<Top folderTop />);
const tab = document.querySelector(".HeaderTab");
const nav = document.querySelector("[data-top='nav']");
expect(tab?.className).toContain("w-fit");
expect(tab?.className).toContain("sm:w-auto");
expect(tab?.className).toContain("sm:flex-1");
expect(nav?.className).toContain("md:absolute");
expect(nav?.className).toContain(
"md:left-[calc(50vw-var(--spacing-scale-016))]",
);
expect(nav?.className).not.toContain("ml-auto");
});
it("keeps a single home nav cluster and shows the folder wordmark", () => {
renderWithProviders(<Top folderTop />);
expect(
screen.getAllByRole("menuitem", {
name: /navigate to use cases page/i,
hidden: true,
}),
).toHaveLength(1);
const wordmark = screen.getByText("CommunityRule");
expect(wordmark.className).not.toMatch(/\bhidden\b/);
expect(
screen.getByRole("button", { name: /create a new rule/i }).className,
).toContain("md:text-x-small-label");
});
});
+20
View File
@@ -16,4 +16,24 @@ describe("Stat", () => {
expect(screen.getByText("open source projects")).toBeInTheDocument();
expect(screen.getByText("as of June 30, 2024")).toBeInTheDocument();
});
it("links the as-of year when a source is provided", () => {
render(
<Stat
value="420M+"
label="open source projects"
asOfPrefix="as of"
asOf="2025"
sourceHref="https://github.blog/news-insights/octoverse/octoverse-a-new-developer-joins-github-every-second-as-ai-leads-typescript-to-1/"
sourceName="GitHub Octoverse"
/>,
);
expect(
screen.getByRole("link", { name: /2025/ }),
).toHaveAttribute(
"href",
"https://github.blog/news-insights/octoverse/octoverse-a-new-developer-joins-github-every-second-as-ai-leads-typescript-to-1/",
);
});
});
@@ -16,6 +16,27 @@ describe("AboutHeader", () => {
expect(
screen.getByRole("heading", { name: /CommunityRule helps/i }),
).toBeInTheDocument();
expect(screen.getByRole("heading").textContent).toMatch(
/CommunityRule helps/,
);
expect(screen.getByRole("heading").textContent).not.toMatch(
/CommunityRulehelps/,
);
});
it("keeps spaces when icons sit between words", () => {
render(
<AboutHeader
segments={[
{ type: "word", text: "CommunityRule" },
{ type: "icon", icon: "arrow" },
{ type: "word", text: "is" },
{ type: "word", text: "a" },
]}
/>,
);
expect(screen.getByRole("heading").textContent).toMatch(/CommunityRule is a/);
});
it("sizes inline marks to the 320 / 640 / 1440 About Header variants", () => {
@@ -25,6 +25,24 @@ describe("ContentLockup", () => {
).toBeInTheDocument();
});
it("uses the inverse filled palette on the hero CTA", () => {
renderWithProviders(
<ContentLockup
variant="hero"
title="Collaborate"
ctaText="Learn how CommunityRule works"
ctaHref="/how-it-works"
/>,
);
const cta = screen.getAllByRole("link", {
name: "Learn how CommunityRule works",
})[0];
expect(cta?.className).toContain(
"bg-[var(--color-surface-default-primary)]",
);
});
it("uses Display line-height and Small/Paragraph on the hero lockup", () => {
renderWithProviders(
<ContentLockup
@@ -45,4 +63,24 @@ describe("ContentLockup", () => {
expect(description.className).toContain("text-small-paragraph");
expect(description.className).toContain("sm:text-large-paragraph");
});
it("renders the feature lockup CTA as an inline text link", () => {
renderWithProviders(
<ContentLockup
variant="feature"
title="Tools for the parts that get hard"
subtitle="Every group runs into the same knots."
linkText="Explore the toolkit"
linkHref="/learn"
/>,
);
const link = screen.getByRole("link", { name: "Explore the toolkit" });
expect(link).toHaveAttribute("href", "/learn");
expect(link.className).toMatch(/underline/);
expect(link.className).toMatch(/w-fit/);
expect(link.className).not.toMatch(/px-1/);
expect(link.className).not.toMatch(/focus:ring-2/);
expect(link.className).toMatch(/focus-visible:outline/);
});
});
+16 -17
View File
@@ -52,10 +52,10 @@ test.describe("Critical User Journeys", () => {
// 7. User checks out features
const features = [
"Decision-making support",
"Values alignment exercises",
"Membership guidance",
"Conflict resolution tools",
"Choose how you decide",
"Name your values",
"Define membership",
"Plan for conflict",
];
for (const feature of features) {
@@ -96,7 +96,7 @@ test.describe("Critical User Journeys", () => {
await expect(howItWorksHeading).toBeVisible();
await expect(
page.locator("h1").filter({ hasText: "We've got your back" }),
page.locator("h1").filter({ hasText: "Tools for the parts that get hard" }),
).toBeVisible();
// Check key components are rendered
@@ -108,33 +108,32 @@ test.describe("Critical User Journeys", () => {
test("feature grid section functionality", async ({ page }) => {
// Check section header
await expect(
page.locator('h1:has-text("We\'ve got your back")'),
page.locator('h1:has-text("Tools for the parts that get hard")'),
).toBeVisible();
await expect(
page.locator(
"text=Use our toolkit to improve, document, and evolve your organization",
"text=Every group runs into the same knots. CommunityRule gives you a way through",
),
).toBeVisible();
// Check all four feature cards - FeatureGrid uses section with grid layout
const featureSection = page.locator(
'section[aria-label="Feature tools and services"], section:has-text("We\'ve got your back")',
'section[aria-label="Feature tools and services"], section:has-text("Tools for the parts that get hard")',
);
await expect(featureSection.locator("text=Decision-making")).toBeVisible();
await expect(featureSection.locator("text=Values alignment")).toBeVisible();
await expect(featureSection.locator("text=Membership")).toBeVisible();
await expect(featureSection.locator("text=Choose how you decide")).toBeVisible();
await expect(featureSection.locator("text=Name your values")).toBeVisible();
await expect(featureSection.locator("text=Define membership")).toBeVisible();
await expect(
featureSection.locator("text=Conflict resolution"),
featureSection.locator("text=Plan for conflict"),
).toBeVisible();
// Feature tiles are presentational — only the lockup "Learn more" is a link
// Feature tiles are presentational — only the lockup "Explore the toolkit" is a link
await expect(
featureSection.locator('a[href="#decision-making"]'),
).toHaveCount(0);
await expect(featureSection.getByRole("link", { name: "Learn more" })).toHaveAttribute(
"href",
"/learn",
);
await expect(
featureSection.getByRole("link", { name: "Explore the toolkit" }),
).toHaveAttribute("href", "/learn");
});
test("header navigation functionality", async ({ page }) => {
+23
View File
@@ -63,4 +63,27 @@ describe("AboutPage", () => {
}),
).toHaveAttribute("href", getAssetPath(governanceBookletPath()));
});
test("shows sourced About statistics with as-of years", () => {
render(<AboutPage />);
const page = messages.pages.about;
expect(screen.getByText("420M+")).toBeInTheDocument();
expect(screen.getByText("24%")).toBeInTheDocument();
expect(screen.getByText("45%")).toBeInTheDocument();
expect(screen.getByText("800+")).toBeInTheDocument();
expect(screen.queryByText("8000+")).not.toBeInTheDocument();
expect(screen.queryByText("27%")).not.toBeInTheDocument();
for (const item of page.stats.items) {
expect(screen.getByText(item.label)).toBeInTheDocument();
if (item.sourceHref) {
expect(
screen.getAllByRole("link").some(
(link) => link.getAttribute("href") === item.sourceHref,
),
).toBe(true);
}
}
});
});
+11 -9
View File
@@ -35,13 +35,13 @@ describe("Page", () => {
// FeatureGrid is next/dynamic wait like other code-split sections
await waitFor(() => {
expect(
screen.getAllByText("We've got your back, every step of the way")
screen.getAllByText("Tools for the parts that get hard")
.length,
).toBeGreaterThan(0);
});
expect(
screen.getAllByText(
"Use our toolkit to improve, document, and evolve your organization.",
"Every group runs into the same knots. CommunityRule gives you a way through, and a written record of what you decided.",
).length,
).toBeGreaterThan(0);
@@ -111,16 +111,18 @@ describe("Page", () => {
// Wait for dynamically imported FeatureGrid component to load
await waitFor(() => {
expect(
screen.getAllByText("We've got your back, every step of the way")
screen.getAllByText("Tools for the parts that get hard")
.length,
).toBeGreaterThan(0);
});
expect(
screen.getAllByText(
"Use our toolkit to improve, document, and evolve your organization.",
"Every group runs into the same knots. CommunityRule gives you a way through, and a written record of what you decided.",
).length,
).toBeGreaterThan(0);
const learnMore = screen.getAllByRole("link", { name: "Learn more" });
const learnMore = screen.getAllByRole("link", {
name: "Explore the toolkit",
});
expect(learnMore[0]).toHaveAttribute("href", "/learn");
});
@@ -157,7 +159,7 @@ describe("Page", () => {
// FeatureGrid
await waitFor(() => {
expect(
screen.getAllByText("We've got your back, every step of the way")
screen.getAllByText("Tools for the parts that get hard")
.length,
).toBeGreaterThan(0);
});
@@ -211,7 +213,7 @@ describe("Page", () => {
await waitFor(() => {
expect(
screen.getAllByText(
"Use our toolkit to improve, document, and evolve your organization.",
"Every group runs into the same knots. CommunityRule gives you a way through, and a written record of what you decided.",
).length,
).toBeGreaterThan(0);
});
@@ -236,7 +238,7 @@ describe("Page", () => {
});
await waitFor(() => {
expect(
screen.getAllByText("We've got your back, every step of the way")
screen.getAllByText("Tools for the parts that get hard")
.length,
).toBeGreaterThan(0);
});
@@ -285,7 +287,7 @@ describe("Page", () => {
await waitFor(() => {
expect(
screen.getAllByText(
"Use our toolkit to improve, document, and evolve your organization.",
"Every group runs into the same knots. CommunityRule gives you a way through, and a written record of what you decided.",
).length,
).toBeGreaterThan(0);
});
+3 -3
View File
@@ -90,13 +90,13 @@ describe("Page Flow Integration", () => {
await waitFor(() => {
expect(
screen.getByRole("heading", {
name: "We've got your back, every step of the way",
name: "Tools for the parts that get hard",
}),
).toBeInTheDocument();
});
expect(
screen.getByText(
"Use our toolkit to improve, document, and evolve your organization.",
"Every group runs into the same knots. CommunityRule gives you a way through, and a written record of what you decided.",
),
).toBeInTheDocument();
@@ -233,7 +233,7 @@ describe("Page Flow Integration", () => {
await waitFor(() => {
expect(
screen.getByText("We've got your back, every step of the way"),
screen.getByText("Tools for the parts that get hard"),
).toBeInTheDocument();
});
+3 -3
View File
@@ -151,12 +151,12 @@ describe("User Journey Integration", () => {
await waitFor(() => {
expect(
screen.getByText("We've got your back, every step of the way"),
screen.getByText("Tools for the parts that get hard"),
).toBeInTheDocument();
});
expect(
screen.getByText(
"Use our toolkit to improve, document, and evolve your organization.",
"Every group runs into the same knots. CommunityRule gives you a way through, and a written record of what you decided.",
),
).toBeInTheDocument();
@@ -206,7 +206,7 @@ describe("User Journey Integration", () => {
await waitFor(() => {
expect(
screen.getByText("We've got your back, every step of the way"),
screen.getByText("Tools for the parts that get hard"),
).toBeInTheDocument();
});