(
type: "missing_props",
message:
"QuoteBlock statement variant requires quote and quoteSecondary",
- quote: !!quote?.trim() && !!quoteSecondary?.trim(),
+ quote: !!(quote?.trim() && quoteSecondary?.trim()),
});
}
return null;
diff --git a/app/components/sections/QuoteBlock/QuoteBlock.types.ts b/app/components/sections/QuoteBlock/QuoteBlock.types.ts
index f778bc4..5ec3462 100644
--- a/app/components/sections/QuoteBlock/QuoteBlock.types.ts
+++ b/app/components/sections/QuoteBlock/QuoteBlock.types.ts
@@ -5,13 +5,11 @@ export type QuoteBlockVariantValue =
| "statement";
export interface QuoteBlockProps {
- /** Default `standard` (home portrait quote). `statement` is About-only dual-paragraph layout; isolated branch in QuoteBlock.view. */
+ /** Default `standard` (home portrait quote). **`statement`** = yellow Section / Quote (**About** + **`/use-cases`** — two paragraphs below **`lg`**, one paragraph from **`lg`**; [21967-24638](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=21967-24638&m=dev)). */
variant?: QuoteBlockVariantValue;
className?: string;
quote?: string;
- /**
- * Second paragraph for **`statement`** variant (Figma Section/Quote 22137:890679).
- */
+ /** Second paragraph for **`statement`** (Section/Quote); merged into one `` from **`lg`**. */
quoteSecondary?: string;
author?: string;
source?: string;
@@ -39,7 +37,7 @@ export interface VariantConfig {
source: string;
showDecor: boolean;
/**
- * When true, render Figma **Section/Quote** layout (yellow surface, dual paragraphs, no attribution).
+ * When true, render Figma **Section/Quote** layout (yellow surface; stacked copy below **`lg`**, single paragraph from **`lg`**; no attribution).
*/
statementLayout?: boolean;
}
diff --git a/app/components/sections/QuoteBlock/QuoteBlock.view.tsx b/app/components/sections/QuoteBlock/QuoteBlock.view.tsx
index d37b3c5..15b55ed 100644
--- a/app/components/sections/QuoteBlock/QuoteBlock.view.tsx
+++ b/app/components/sections/QuoteBlock/QuoteBlock.view.tsx
@@ -26,15 +26,12 @@ function QuoteBlockView({
const avatarAlt = t("avatarAlt").replace("{author}", author);
if (config.statementLayout) {
- if (!quoteSecondary?.trim()) {
- return null;
- }
-
const statementTextClass =
"font-bricolage-grotesque text-[28px] font-bold leading-9 tracking-[var(--text-xx-large-heading--letter-spacing)] text-[var(--color-surface-default-tertiary)] md:text-[length:var(--text-xx-large-heading)] md:leading-[length:var(--text-xx-large-heading--line-height)]";
return (
-
-
- {quote}
-
-
- {quoteSecondary}
+
+
+ {quote}
+ {quoteSecondary ? (
+ <>
+ {" "}
+ {quoteSecondary}
+ >
+ ) : null}
diff --git a/app/components/sections/QuoteBlock/QuoteStatementDecor.tsx b/app/components/sections/QuoteBlock/QuoteStatementDecor.tsx
index b08e84a..ef7f535 100644
--- a/app/components/sections/QuoteBlock/QuoteStatementDecor.tsx
+++ b/app/components/sections/QuoteBlock/QuoteStatementDecor.tsx
@@ -3,7 +3,7 @@
import { memo } from "react";
import { getAssetPath, quoteStatementShapePath } from "../../../../lib/assetUtils";
-/** Figma: Section / Quote — Shapes (22137:890679). Radial asset + horizontal gradient mask (side lobes only); grain matches QuoteBlock/HeroDecor. Background `cover` so wide banners still fill lateral mask stripes (square sized by panel height misses them when centered). */
+/** Figma: Section / Quote — **`shape-qoute.svg`** (22137:890679). */
const EDGE_MASK =
"linear-gradient(to right, #fff 0%, #fff 14%, rgba(255,255,255,0) 30%, rgba(255,255,255,0) 70%, #fff 86%, #fff 100%)";
diff --git a/app/components/sections/RelatedArticles/RelatedArticles.container.tsx b/app/components/sections/RelatedArticles/RelatedArticles.container.tsx
index 67feb64..3dc68bc 100644
--- a/app/components/sections/RelatedArticles/RelatedArticles.container.tsx
+++ b/app/components/sections/RelatedArticles/RelatedArticles.container.tsx
@@ -2,11 +2,18 @@
import { useState, useEffect, memo, useMemo, useCallback } from "react";
import { useIsMobile } from "../../../hooks";
+import { useMessages } from "../../../contexts/MessagesContext";
import { RelatedArticlesView } from "./RelatedArticles.view";
import type { RelatedArticlesProps } from "./RelatedArticles.types";
const RelatedArticlesContainer = memo(
- ({ relatedPosts, currentPostSlug, slugOrder = [] }) => {
+ ({
+ relatedPosts,
+ currentPostSlug,
+ slugOrder = [],
+ variant = "default",
+ }) => {
+ const messages = useMessages();
// Memoize filtered posts to prevent unnecessary re-computations
const filteredPosts = useMemo(
() => relatedPosts.filter((post) => post.slug !== currentPostSlug),
@@ -95,6 +102,11 @@ const RelatedArticlesContainer = memo(
return () => clearInterval(progressInterval);
}, [currentIndex, filteredPosts.length, isMobile]);
+ const useCasesHeadingLines =
+ variant === "useCases"
+ ? messages.pages.useCases.relatedArticles.title
+ : undefined;
+
return (
(
transformStyle={transformStyle}
getProgressStyle={getProgressStyle}
onMouseDown={handleMouseDown}
+ variant={variant}
+ useCasesHeadingLines={useCasesHeadingLines}
/>
);
},
diff --git a/app/components/sections/RelatedArticles/RelatedArticles.types.ts b/app/components/sections/RelatedArticles/RelatedArticles.types.ts
index de78279..61dd01a 100644
--- a/app/components/sections/RelatedArticles/RelatedArticles.types.ts
+++ b/app/components/sections/RelatedArticles/RelatedArticles.types.ts
@@ -1,9 +1,17 @@
import type { BlogPost } from "../../../../lib/content";
+export type RelatedArticlesVariant = "default" | "useCases";
+
export interface RelatedArticlesProps {
relatedPosts: BlogPost[];
currentPostSlug: string;
slugOrder?: string[];
+ /**
+ * **`useCases`**: Figma related section — baseline [**22112-872308**](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22112-872308&m=dev),
+ * **`md`** [**22085-863216**](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22085-863216&m=dev),
+ * **`lg`** [**20711-14231**](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=20711-14231&m=dev) (shell + card row gutter / padding).
+ */
+ variant?: RelatedArticlesVariant;
}
export interface RelatedArticlesViewProps {
@@ -13,4 +21,7 @@ export interface RelatedArticlesViewProps {
transformStyle: React.CSSProperties;
getProgressStyle: (_index: number) => React.CSSProperties;
onMouseDown?: (_e: React.MouseEvent) => void;
+ variant?: RelatedArticlesVariant;
+ /** Stacked title lines (`pages.useCases.relatedArticles.title`) when `variant="useCases"`. */
+ useCasesHeadingLines?: readonly string[];
}
diff --git a/app/components/sections/RelatedArticles/RelatedArticles.view.tsx b/app/components/sections/RelatedArticles/RelatedArticles.view.tsx
index 3e9d53e..d79a2da 100644
--- a/app/components/sections/RelatedArticles/RelatedArticles.view.tsx
+++ b/app/components/sections/RelatedArticles/RelatedArticles.view.tsx
@@ -8,25 +8,66 @@ export function RelatedArticlesView({
transformStyle,
getProgressStyle,
onMouseDown,
+ variant = "default",
+ useCasesHeadingLines,
}: RelatedArticlesViewProps) {
if (filteredPosts.length === 0) {
return null;
}
+ const isUseCases = variant === "useCases";
+
return (
-
-
- Related Articles
-
+
+ {isUseCases && useCasesHeadingLines?.length ? (
+
+ {/* Baseline 22112-872308: stacked lines; md+ single line; lg 20711-14231: 40/52, max 693px */}
+
+ {useCasesHeadingLines.map((line, index) => (
+
+ {line}
+
+ ))}
+
+
+ {useCasesHeadingLines.join(" ")}
+
+
+ ) : (
+
+ Related Articles
+
+ )}
{/* Horizontal Articles Row - Carousel on mobile, Scrollable slider on desktop */}
-
+
(
- ({ className = "", initialGridEntries }) => {
+ ({ className = "", initialGridEntries, translationNamespace, twoColumnsFromMd }) => {
const router = useRouter();
const [gridEntries, setGridEntries] = useState
(
() => initialGridEntries ?? null,
@@ -103,6 +103,8 @@ const RuleStackContainer = memo(
className={className}
onTemplateClick={handleTemplateClick}
gridEntries={gridEntries}
+ translationNamespace={translationNamespace ?? "pages.home.ruleStack"}
+ twoColumnsFromMd={twoColumnsFromMd}
/>
);
},
diff --git a/app/components/sections/RuleStack/RuleStack.types.ts b/app/components/sections/RuleStack/RuleStack.types.ts
index 6e0248d..f714fd8 100644
--- a/app/components/sections/RuleStack/RuleStack.types.ts
+++ b/app/components/sections/RuleStack/RuleStack.types.ts
@@ -7,6 +7,15 @@ export interface RuleStackProps {
* the client skips the `/api/templates` request.
*/
initialGridEntries?: TemplateGridCardEntry[];
+ /**
+ * Prefix for `title`, `subtitle`, `button.seeAllTemplates` keys (default
+ * matches home: `pages.home.ruleStack`).
+ */
+ translationNamespace?: string;
+ /**
+ * Use **`md`** (640px) for two template columns — `/use-cases` Rule Stack.
+ */
+ twoColumnsFromMd?: boolean;
}
export interface RuleStackViewProps {
@@ -14,4 +23,6 @@ export interface RuleStackViewProps {
onTemplateClick: (_slug: string) => void;
/** `null` while loading curated templates from the API. */
gridEntries: TemplateGridCardEntry[] | null;
+ translationNamespace: string;
+ twoColumnsFromMd?: boolean;
}
diff --git a/app/components/sections/RuleStack/RuleStack.view.tsx b/app/components/sections/RuleStack/RuleStack.view.tsx
index d6b7842..cdb50b4 100644
--- a/app/components/sections/RuleStack/RuleStack.view.tsx
+++ b/app/components/sections/RuleStack/RuleStack.view.tsx
@@ -7,16 +7,20 @@ import { GovernanceTemplateGrid } from "../GovernanceTemplateGrid";
import { GovernanceTemplateGridSkeleton } from "../GovernanceTemplateGrid/GovernanceTemplateGridSkeleton";
import type { RuleStackViewProps } from "./RuleStack.types";
+/** Figma **Section / RuleStack** [22085:860413](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22085-860413&m=dev). */
export function RuleStackView({
className,
onTemplateClick,
gridEntries,
+ translationNamespace,
+ twoColumnsFromMd = false,
}: RuleStackViewProps) {
- const t = useTranslation("pages.home.ruleStack");
+ const t = useTranslation(translationNamespace);
const buttonText = t("button.seeAllTemplates");
return (
{gridEntries === null ? (
-
+
) : (
)}
diff --git a/app/components/sections/UseCasesOrgs/UseCasesOrgs.container.tsx b/app/components/sections/UseCasesOrgs/UseCasesOrgs.container.tsx
new file mode 100644
index 0000000..004d9be
--- /dev/null
+++ b/app/components/sections/UseCasesOrgs/UseCasesOrgs.container.tsx
@@ -0,0 +1,17 @@
+"use client";
+
+import { memo } from "react";
+import UseCasesOrgsView from "./UseCasesOrgs.view";
+import type { UseCasesOrgsProps } from "./UseCasesOrgs.types";
+
+/**
+ * Figma: **Orgs** instance ([21993-33687](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=21993-33687&m=dev)) —
+ * **305×305** `CaseStudy` tiles, **8px** gap, **24px** horizontal / **48px** bottom inset.
+ */
+const UseCasesOrgsContainer = memo((props) => {
+ return ;
+});
+
+UseCasesOrgsContainer.displayName = "UseCasesOrgs";
+
+export default UseCasesOrgsContainer;
diff --git a/app/components/sections/UseCasesOrgs/UseCasesOrgs.types.ts b/app/components/sections/UseCasesOrgs/UseCasesOrgs.types.ts
new file mode 100644
index 0000000..08695ce
--- /dev/null
+++ b/app/components/sections/UseCasesOrgs/UseCasesOrgs.types.ts
@@ -0,0 +1,8 @@
+import type { ReactNode } from "react";
+
+export interface UseCasesOrgsProps {
+ children: ReactNode;
+ className?: string;
+}
+
+export interface UseCasesOrgsViewProps extends UseCasesOrgsProps {}
diff --git a/app/components/sections/UseCasesOrgs/UseCasesOrgs.view.tsx b/app/components/sections/UseCasesOrgs/UseCasesOrgs.view.tsx
new file mode 100644
index 0000000..08cad48
--- /dev/null
+++ b/app/components/sections/UseCasesOrgs/UseCasesOrgs.view.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import { memo } from "react";
+import type { UseCasesOrgsViewProps } from "./UseCasesOrgs.types";
+
+function UseCasesOrgsView({ children, className = "" }: UseCasesOrgsViewProps) {
+ return (
+
+ );
+}
+
+UseCasesOrgsView.displayName = "UseCasesOrgsView";
+
+export default memo(UseCasesOrgsView);
diff --git a/app/components/sections/UseCasesOrgs/index.tsx b/app/components/sections/UseCasesOrgs/index.tsx
new file mode 100644
index 0000000..11fbc17
--- /dev/null
+++ b/app/components/sections/UseCasesOrgs/index.tsx
@@ -0,0 +1,2 @@
+export { default } from "./UseCasesOrgs.container";
+export type { UseCasesOrgsProps } from "./UseCasesOrgs.types";
diff --git a/app/components/type/ContentLockup/ContentLockup.container.tsx b/app/components/type/ContentLockup/ContentLockup.container.tsx
index c519ff6..9fbe198 100644
--- a/app/components/type/ContentLockup/ContentLockup.container.tsx
+++ b/app/components/type/ContentLockup/ContentLockup.container.tsx
@@ -110,7 +110,7 @@ const ContentLockupContainer = memo(
titleGroup: "flex flex-col gap-[var(--spacing-scale-008)]",
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
title:
- "font-bricolage-grotesque font-medium text-[36px] leading-[110%] tracking-[0] md:text-[52px] md:leading-[110%] text-[var(--color-content-default-brand-primary)]",
+ "font-bricolage-grotesque font-medium text-[36px] leading-[110%] tracking-[0] lg:text-[44px] lg:leading-[1.1] text-[var(--color-content-default-brand-primary)]",
subtitle:
"font-inter font-normal text-[18px] leading-[130%] tracking-[0] md:text-[24px] md:leading-[32px] text-[var(--color-content-default-primary)]",
shape:
@@ -122,7 +122,7 @@ const ContentLockupContainer = memo(
titleGroup: "flex flex-col gap-[var(--spacing-scale-008)]",
titleContainer: "flex gap-[var(--spacing-scale-008)] items-center",
title:
- "font-bricolage-grotesque font-medium text-[36px] leading-[110%] tracking-[0] md:text-[52px] md:leading-[110%] text-[var(--color-content-inverse-primary)]",
+ "font-bricolage-grotesque font-medium text-[36px] leading-[110%] tracking-[0] lg:text-[44px] lg:leading-[1.1] text-[var(--color-content-inverse-primary)]",
subtitle:
"font-inter font-normal text-[18px] leading-[130%] tracking-[0] md:text-[24px] md:leading-[32px] text-[var(--color-content-inverse-primary)]",
shape:
diff --git a/app/components/type/PageHeader/PageHeader.container.tsx b/app/components/type/PageHeader/PageHeader.container.tsx
new file mode 100644
index 0000000..c48420d
--- /dev/null
+++ b/app/components/type/PageHeader/PageHeader.container.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { memo, useId } from "react";
+import PageHeaderView from "./PageHeader.view";
+import type { PageHeaderProps } from "./PageHeader.types";
+
+/**
+ * Figma: "Type / PageHeader" (21004-15902).
+ * Minimal headline-only: Section/PageHeader (22112-871523); md density **22085-862431** when `sectionMinimal` is set;
+ * Use cases **`lg`** single-line title **21004-24825** when `singleLineTitleFromLg` is set;
+ * **`xl`** headline scale **22085-860408** when `sectionMinimal` (X Large/Display / `--sizing-1600`).
+ */
+const PageHeaderContainer = memo(
+ ({
+ title,
+ description,
+ ctaText,
+ ctaHref,
+ headingAlign = "start",
+ sectionMinimal = false,
+ singleLineTitleFromLg = false,
+ titleId: titleIdProp,
+ className = "",
+ }) => {
+ const reactId = useId();
+ const titleId = titleIdProp ?? `${reactId}-page-header-title`;
+
+ return (
+
+ );
+ },
+);
+
+PageHeaderContainer.displayName = "PageHeader";
+
+export default PageHeaderContainer;
diff --git a/app/components/type/PageHeader/PageHeader.types.ts b/app/components/type/PageHeader/PageHeader.types.ts
new file mode 100644
index 0000000..25eb8ce
--- /dev/null
+++ b/app/components/type/PageHeader/PageHeader.types.ts
@@ -0,0 +1,24 @@
+export interface PageHeaderProps {
+ /** Single line or stacked lines inside one `` (matches Figma line breaks when centered). */
+ title: string | readonly string[];
+ description?: string;
+ ctaText?: string;
+ ctaHref?: string;
+ /** `center` stacks and centers the headline (Section/PageHeader minimal / use cases). */
+ headingAlign?: "start" | "center";
+ /**
+ * Section/PageHeader minimal density ([22085-862431](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22085-862431&m=dev)):
+ * md+ **52px** display type and **56px** vertical padding (with **64px** horizontal).
+ */
+ sectionMinimal?: boolean;
+ /**
+ * When `title` is multiple lines, use one centered line from **`lg`** ([21004-24825](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=21004-24825&m=dev)).
+ */
+ singleLineTitleFromLg?: boolean;
+ titleId?: string;
+ className?: string;
+}
+
+export type PageHeaderViewProps = Omit & {
+ titleId: string;
+};
diff --git a/app/components/type/PageHeader/PageHeader.view.tsx b/app/components/type/PageHeader/PageHeader.view.tsx
new file mode 100644
index 0000000..d146363
--- /dev/null
+++ b/app/components/type/PageHeader/PageHeader.view.tsx
@@ -0,0 +1,106 @@
+"use client";
+
+import { Fragment, memo } from "react";
+import Button from "../../buttons/Button";
+import type { PageHeaderViewProps } from "./PageHeader.types";
+
+function PageHeaderView({
+ title,
+ description,
+ ctaText,
+ ctaHref,
+ headingAlign = "start",
+ sectionMinimal = false,
+ singleLineTitleFromLg = false,
+ titleId,
+ className = "",
+}: PageHeaderViewProps) {
+ const hasCta = Boolean(ctaText?.trim() && ctaHref?.trim());
+ const hasDescription = Boolean(description?.trim());
+ const isCenter = headingAlign === "center";
+ const titleLines = typeof title === "string" ? [title] : title;
+ const collapseTitleAtLg =
+ singleLineTitleFromLg && titleLines.length > 1;
+
+ const lockupAlign = isCenter
+ ? "items-center text-center"
+ : "items-start text-[var(--color-content-default-primary)]";
+ const h1Align = isCenter ? "text-center" : "";
+
+ const sectionPadding = sectionMinimal
+ ? "py-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-056)]"
+ : "py-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-032)]";
+
+ const titleTypeClasses = sectionMinimal
+ ? "font-bricolage-grotesque text-[32px] font-medium leading-[1.1] text-[var(--color-content-default-primary)] md:text-[52px] md:leading-[1.1] lg:text-[52px] lg:leading-[1.1] xl:text-[length:var(--sizing-1600)] xl:leading-[1.1]"
+ : "font-bricolage-grotesque text-[32px] font-medium leading-[1.1] text-[var(--color-content-default-primary)] md:text-[44px] md:leading-[110%] lg:text-[52px]";
+
+ const sectionFigmaNode =
+ sectionMinimal && collapseTitleAtLg
+ ? "21004-24825"
+ : sectionMinimal
+ ? "22085-862431"
+ : "21004-22394";
+
+ return (
+
+
+
+
+ {titleLines.length === 1 ? (
+ titleLines[0]
+ ) : collapseTitleAtLg ? (
+ titleLines.map((line, index) => (
+
+ {index > 0 ? (
+ {" "}
+ ) : null}
+ {line}
+
+ ))
+ ) : (
+ titleLines.map((line, index) => (
+
+ {line}
+
+ ))
+ )}
+
+ {hasDescription ? (
+
+ {description}
+
+ ) : null}
+
+ {hasCta ? (
+
+
+
+ ) : null}
+
+
+ );
+}
+
+PageHeaderView.displayName = "PageHeaderView";
+
+export default memo(PageHeaderView);
diff --git a/app/components/type/PageHeader/index.tsx b/app/components/type/PageHeader/index.tsx
new file mode 100644
index 0000000..43a318c
--- /dev/null
+++ b/app/components/type/PageHeader/index.tsx
@@ -0,0 +1,2 @@
+export { default } from "./PageHeader.container";
+export type { PageHeaderProps } from "./PageHeader.types";
diff --git a/app/components/type/SectionHeader/SectionHeader.tsx b/app/components/type/SectionHeader/SectionHeader.tsx
index 948e63e..abd1eab 100644
--- a/app/components/type/SectionHeader/SectionHeader.tsx
+++ b/app/components/type/SectionHeader/SectionHeader.tsx
@@ -10,6 +10,11 @@ interface SectionHeaderProps {
variant?: SectionHeaderVariantValue;
/** When set with `variant="multi-line"`, large screens show three title lines (Figma SectionCardSteps). */
stackedDesktopLines?: readonly [string, string, string];
+ /**
+ * With `variant="multi-line"`, keep **Rule stack** desktop type: title **32/40** at `lg`, **40/52** at `xl`;
+ * subtitle **18 / 1.3** at `lg`, **24/32** at `xl`, **left-aligned** in its column from `lg` (Figma **22085:860413**).
+ */
+ ruleStackDesktopTypeScale?: boolean;
}
/**
@@ -23,6 +28,7 @@ const SectionHeader = memo(
titleLg,
variant: variantProp = "default",
stackedDesktopLines,
+ ruleStackDesktopTypeScale = false,
}) => {
const variant = variantProp;
const useStackedDesktop =
@@ -47,7 +53,9 @@ const SectionHeader = memo(
@@ -68,14 +76,18 @@ const SectionHeader = memo(
diff --git a/app/components/type/TripleStep/TripleStep.container.tsx b/app/components/type/TripleStep/TripleStep.container.tsx
new file mode 100644
index 0000000..4a958dc
--- /dev/null
+++ b/app/components/type/TripleStep/TripleStep.container.tsx
@@ -0,0 +1,19 @@
+"use client";
+
+import { memo, useId } from "react";
+import TripleStepView from "./TripleStep.view";
+import type { TripleStepProps } from "./TripleStep.types";
+
+/**
+ * Figma: **Section / Triple Step** ([22084-859405](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22084-859405&m=dev)); type baseline ([22112-871527](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22112-871527&m=dev)); **md+** two-column + **`triple-step.svg`**.
+ */
+const TripleStepContainer = memo((props) => {
+ const reactId = useId();
+ const headingId = `${reactId}-triple-step-heading`;
+
+ return ;
+});
+
+TripleStepContainer.displayName = "TripleStep";
+
+export default TripleStepContainer;
diff --git a/app/components/type/TripleStep/TripleStep.types.ts b/app/components/type/TripleStep/TripleStep.types.ts
new file mode 100644
index 0000000..4488875
--- /dev/null
+++ b/app/components/type/TripleStep/TripleStep.types.ts
@@ -0,0 +1,16 @@
+export interface TripleStepStep {
+ title: string;
+ body: string;
+}
+
+export interface TripleStepProps {
+ heading: string;
+ steps: TripleStepStep[];
+ ctaText: string;
+ ctaHref: string;
+ className?: string;
+}
+
+export interface TripleStepViewProps extends TripleStepProps {
+ headingId: string;
+}
diff --git a/app/components/type/TripleStep/TripleStep.view.tsx b/app/components/type/TripleStep/TripleStep.view.tsx
new file mode 100644
index 0000000..ecd9842
--- /dev/null
+++ b/app/components/type/TripleStep/TripleStep.view.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+import Image from "next/image";
+import { memo } from "react";
+import { getAssetPath } from "../../../../lib/assetUtils";
+import AssetIcon from "../../asset/icon";
+import Button from "../../buttons/Button";
+import type { TripleStepViewProps } from "./TripleStep.types";
+
+const TRIPLE_STEP_NUMERIC_ICONS = [
+ "numeric_1_circle",
+ "numeric_2_circle",
+ "numeric_3_circle",
+] as const;
+
+function TripleStepView({
+ heading,
+ steps,
+ ctaText,
+ ctaHref,
+ headingId,
+ className = "",
+}: TripleStepViewProps) {
+ /** Decorative column art — `public/assets/shapes/triple-step.svg` (288×576 viewBox). */
+ const shapeSrc = getAssetPath("assets/shapes/triple-step.svg");
+
+ return (
+
+
+
+
+ {heading}
+
+ {steps.map((step, index) => (
+
+
+
+
+ {step.title}
+
+
+ {step.body}
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+TripleStepView.displayName = "TripleStepView";
+
+export default memo(TripleStepView);
diff --git a/app/components/type/TripleStep/index.tsx b/app/components/type/TripleStep/index.tsx
new file mode 100644
index 0000000..83e6b12
--- /dev/null
+++ b/app/components/type/TripleStep/index.tsx
@@ -0,0 +1,2 @@
+export { default } from "./TripleStep.container";
+export type { TripleStepProps, TripleStepStep } from "./TripleStep.types";
diff --git a/app/components/type/TripleTextBlock/TripleTextBlock.container.tsx b/app/components/type/TripleTextBlock/TripleTextBlock.container.tsx
index 17717c2..45c248e 100644
--- a/app/components/type/TripleTextBlock/TripleTextBlock.container.tsx
+++ b/app/components/type/TripleTextBlock/TripleTextBlock.container.tsx
@@ -5,7 +5,8 @@ import TripleTextBlockView from "./TripleTextBlock.view";
import type { TripleTextBlockProps } from "./TripleTextBlock.types";
/**
- * Figma: "Type / TripleTextBlock" stacked 22137:890676; lg 22128:888715; xl 22135:889705.
+ * Figma: "Type / TripleTextBlock" — use cases **`lg` 22037-26994**, **`xl` 22085-860414**;
+ * **`md` 22085-862437**; stacked 22137:890676; lg 22128:888715; xl 22135:889705 (default).
*/
const TripleTextBlockContainer = memo((props) => {
const headingId = useId();
diff --git a/app/components/type/TripleTextBlock/TripleTextBlock.types.ts b/app/components/type/TripleTextBlock/TripleTextBlock.types.ts
index 1f085fb..aa4d279 100644
--- a/app/components/type/TripleTextBlock/TripleTextBlock.types.ts
+++ b/app/components/type/TripleTextBlock/TripleTextBlock.types.ts
@@ -1,6 +1,8 @@
export interface TripleTextBlockColumn {
title: string;
description: string;
+ /** Optional second paragraph under `description` (e.g. use cases baseline multi-paragraph lockup). */
+ descriptionSecondary?: string;
/**
* lg+ three-column layout (Figma 22128:888715). When either `lgTitle` or `lgDescription`
* is set, stacked breakpoints use `title`/`description` and lg uses these (missing side falls back).
@@ -16,6 +18,12 @@ export interface TripleTextBlockProps {
ctaText?: string;
ctaHref?: string;
className?: string;
+ /**
+ * `useCases`: Figma use cases TripleText **`lg`** ([22037-26994](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22037-26994&m=dev));
+ * **`xl`** ([22085-860414](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22085-860414&m=dev));
+ * `md` ([22085-862437](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22085-862437&m=dev)); lg 3-col **22128-888715**.
+ */
+ layoutPreset?: "default" | "useCases";
}
export interface TripleTextBlockViewProps extends TripleTextBlockProps {
diff --git a/app/components/type/TripleTextBlock/TripleTextBlock.view.tsx b/app/components/type/TripleTextBlock/TripleTextBlock.view.tsx
index 011b078..5e11a3b 100644
--- a/app/components/type/TripleTextBlock/TripleTextBlock.view.tsx
+++ b/app/components/type/TripleTextBlock/TripleTextBlock.view.tsx
@@ -12,11 +12,35 @@ function columnUsesLargeBreakpointCopy(column: TripleTextBlockColumn): boolean {
return column.lgTitle !== undefined || column.lgDescription !== undefined;
}
+function TripleTextUseCasesColumn({ column }: { column: TripleTextBlockColumn }) {
+ return (
+
+
+
+ {column.title}
+
+
+
{column.description}
+ {column.descriptionSecondary ? (
+
{column.descriptionSecondary}
+ ) : null}
+
+
+
+ );
+}
+
function TripleTextBlockColumnLockup({
column,
+ layoutPreset,
}: {
column: TripleTextBlockColumn;
+ layoutPreset: "default" | "useCases";
}) {
+ if (layoutPreset === "useCases") {
+ return ;
+ }
+
const dual = columnUsesLargeBreakpointCopy(column);
const lgSubtitle = column.lgTitle ?? column.title;
const lgBody = column.lgDescription ?? column.description;
@@ -55,7 +79,11 @@ function TripleTextBlockColumnLockup({
}
/**
- * Figma: "Type / TripleTextBlock" stacked **22137:890676**; lg 3-col **22128-888715**; xl typography + horizontal inset scale/160 **22135:889705** (Subtitle 32 Small/Display, Body X Large/Paragraph 24 / lh 32; section px scale/160, py scale/064).
+ * Section horizontal padding adds **+ Scale/096** below `xl` (outer frame inset); **use cases `xl`** uses **Scale/160** only ([22085:860414](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22085-860414&m=dev)).
+ *
+ * Figma: use cases **`lg`** [22037:26994](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22037-26994&m=dev);
+ * **`md`** [22085:862437](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22085-862437&m=dev); stacked **22137:890676**;
+ * lg 3-col **22128:888715**; xl **22135:889705** (default preset).
*/
function TripleTextBlockView({
title = "",
@@ -64,39 +92,71 @@ function TripleTextBlockView({
ctaHref,
headingId,
className = "",
+ layoutPreset = "default",
}: TripleTextBlockViewProps) {
const sectionTitle = title.trim();
const hasSectionTitle = sectionTitle.length > 0;
+ const isUseCases = layoutPreset === "useCases";
return (
-
+
{hasSectionTitle ? (
{sectionTitle}
) : null}
-
+
{columns.map((column, index) => (
-
+
))}
{ctaText ? (
-
+
}
+ title="Static Title"
+ description="Static Description"
+ />,
+ );
+ expect(screen.getByRole("article")).toBeInTheDocument();
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ });
});
diff --git a/tests/components/RelatedArticles.test.tsx b/tests/components/RelatedArticles.test.tsx
index a0ecf58..5c0d470 100644
--- a/tests/components/RelatedArticles.test.tsx
+++ b/tests/components/RelatedArticles.test.tsx
@@ -1,8 +1,11 @@
import React from "react";
-import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import RelatedArticles from "../../app/components/sections/RelatedArticles";
import type { BlogPost } from "../../lib/content";
+import {
+ renderWithProviders as render,
+ screen,
+} from "../utils/test-utils";
vi.mock("next/link", () => ({
default: ({
@@ -63,7 +66,7 @@ const mockPosts: BlogPost[] = [
},
];
-// Pure presentational; no provider context needed (mocked thumbnail + useIsMobile).
+// MessagesProvider required — container uses useMessages().
describe("RelatedArticles", () => {
it("renders without crashing", () => {
render(
@@ -86,4 +89,20 @@ describe("RelatedArticles", () => {
expect(screen.queryByTestId("thumbnail-article-1")).not.toBeInTheDocument();
expect(screen.getByTestId("thumbnail-article-2")).toBeInTheDocument();
});
+
+ it("useCases variant shows localized stacked title", () => {
+ render(
+
,
+ );
+ expect(
+ screen.getByRole("heading", {
+ level: 2,
+ name: /Tools to set your group up for success/,
+ }),
+ ).toBeInTheDocument();
+ });
});
diff --git a/tests/components/cards/CaseStudy.test.tsx b/tests/components/cards/CaseStudy.test.tsx
new file mode 100644
index 0000000..522fc12
--- /dev/null
+++ b/tests/components/cards/CaseStudy.test.tsx
@@ -0,0 +1,31 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import CaseStudy from "../../../app/components/cards/CaseStudy";
+
+describe("CaseStudy", () => {
+ it("renders tile container", () => {
+ const { container } = render(
+
,
+ );
+ expect(container.querySelector('[data-figma-node="21993-32352"]')).toBeTruthy();
+ });
+
+ it("renders built-in raster when visual is omitted (neutral)", () => {
+ render(
+
,
+ );
+
+ expect(
+ screen.getByRole("img", { name: "Food Not Bombs logo" }),
+ ).toHaveAttribute("src");
+ });
+
+ it("uses Mutual Aid vector on lavender surface", () => {
+ const { container } = render(
+
,
+ );
+ expect(container.querySelector("img")?.getAttribute("src")).toContain(
+ "case-study-mutual-aid.svg",
+ );
+ });
+});
diff --git a/tests/components/sections/Groups.test.tsx b/tests/components/sections/Groups.test.tsx
new file mode 100644
index 0000000..7aa78f3
--- /dev/null
+++ b/tests/components/sections/Groups.test.tsx
@@ -0,0 +1,44 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import Groups from "../../../app/components/sections/Groups";
+
+describe("Groups", () => {
+ it("renders a static icon tile grid", () => {
+ const { container } = render(
+
a,
+ title: "One",
+ description: "First description text.",
+ },
+ {
+ icon: b,
+ title: "Two",
+ description: "Second description text.",
+ },
+ {
+ icon: c,
+ title: "Three",
+ description: "Third description text.",
+ },
+ {
+ icon: d,
+ title: "Four",
+ description: "Fourth description text.",
+ },
+ ]}
+ />,
+ );
+
+ expect(
+ screen.getByRole("heading", { level: 2, name: "Who is this for?" }),
+ ).toBeInTheDocument();
+ expect(screen.getAllByRole("article")).toHaveLength(4);
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
+ expect(
+ container.querySelector('[data-figma-node="22085-860411"]'),
+ ).toBeTruthy();
+ });
+});
diff --git a/tests/components/sections/UseCasesOrgs.test.tsx b/tests/components/sections/UseCasesOrgs.test.tsx
new file mode 100644
index 0000000..dd7cb40
--- /dev/null
+++ b/tests/components/sections/UseCasesOrgs.test.tsx
@@ -0,0 +1,20 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import UseCasesOrgs from "../../../app/components/sections/UseCasesOrgs";
+
+describe("UseCasesOrgs", () => {
+ it("renders children", () => {
+ const { container } = render(
+
+ Child A
+ Child B
+ ,
+ );
+
+ expect(screen.getByText("Child A")).toBeInTheDocument();
+ expect(screen.getByText("Child B")).toBeInTheDocument();
+ expect(
+ container.querySelector('[data-figma-node="21993-33687"]'),
+ ).toBeTruthy();
+ });
+});
diff --git a/tests/components/type/PageHeader.test.tsx b/tests/components/type/PageHeader.test.tsx
new file mode 100644
index 0000000..4396c1e
--- /dev/null
+++ b/tests/components/type/PageHeader.test.tsx
@@ -0,0 +1,72 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import PageHeader from "../../../app/components/type/PageHeader";
+
+describe("PageHeader", () => {
+ it("renders title and description", () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByRole("heading", { level: 1, name: "Test title" }),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Test description body.")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Go" })).toHaveAttribute(
+ "href",
+ "/create",
+ );
+ });
+
+ it("omits CTA when ctaText is absent", () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
+ });
+
+ it("omits description when omitted and renders stacked centered title lines", () => {
+ render(
+ ,
+ );
+
+ const heading = screen.getByRole("heading", { level: 1 });
+ expect(heading).toHaveTextContent(/See how groups useCommunityRule/);
+ expect(
+ heading.querySelectorAll("span.block"),
+ ).toHaveLength(2);
+ expect(screen.queryByRole("paragraph")).not.toBeInTheDocument();
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
+ });
+
+ it("renders use-cases lg single-line title segments when singleLineTitleFromLg", () => {
+ render(
+ ,
+ );
+
+ const heading = screen.getByRole("heading", { level: 1 });
+ expect(heading).toHaveTextContent(/See how groups use CommunityRule/);
+ expect(
+ heading.querySelectorAll("span.block.lg\\:inline"),
+ ).toHaveLength(2);
+ expect(heading.closest("section")).toHaveAttribute(
+ "data-figma-node",
+ "21004-24825",
+ );
+ });
+});
diff --git a/tests/components/type/TripleStep.test.tsx b/tests/components/type/TripleStep.test.tsx
new file mode 100644
index 0000000..9335b41
--- /dev/null
+++ b/tests/components/type/TripleStep.test.tsx
@@ -0,0 +1,32 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect } from "vitest";
+import TripleStep from "../../../app/components/type/TripleStep";
+
+describe("TripleStep", () => {
+ it("renders heading, steps, and CTA", () => {
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByRole("heading", { level: 2, name: "Get organized" }),
+ ).toBeInTheDocument();
+ expect(
+ document.querySelector('[data-figma-node="22084-859405"]'),
+ ).toBeTruthy();
+ expect(screen.getByText("Step one")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Create Rule" })).toHaveAttribute(
+ "href",
+ "/create",
+ );
+ });
+});
diff --git a/tests/components/type/TripleTextBlock.test.tsx b/tests/components/type/TripleTextBlock.test.tsx
index 0d101f1..5560a48 100644
--- a/tests/components/type/TripleTextBlock.test.tsx
+++ b/tests/components/type/TripleTextBlock.test.tsx
@@ -48,4 +48,45 @@ describe("TripleTextBlock", () => {
);
expect(screen.getByText("Only body.")).toBeInTheDocument();
});
+
+ it("useCases preset renders persistent section heading, column h3 titles, dual paragraphs, outline CTA", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(
+ container.querySelector('[data-figma-node="22085-860414"]'),
+ ).toBeTruthy();
+
+ expect(
+ screen.getByRole("heading", {
+ level: 2,
+ name: "Why Horizontal groups need CommunityRule",
+ }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("heading", {
+ level: 3,
+ name: "Share Leadership",
+ }),
+ ).toBeInTheDocument();
+ expect(screen.getByText("First paragraph.")).toBeInTheDocument();
+ expect(screen.getByText("Second paragraph.")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Setup your community" })).toHaveAttribute(
+ "href",
+ "/create",
+ );
+ });
});
diff --git a/tests/unit/QuoteBlock.test.jsx b/tests/unit/QuoteBlock.test.jsx
index 911eb7a..555687a 100644
--- a/tests/unit/QuoteBlock.test.jsx
+++ b/tests/unit/QuoteBlock.test.jsx
@@ -223,7 +223,7 @@ describe("QuoteBlock Component", () => {
).not.toBeInTheDocument();
});
- test("statement variant renders dual paragraphs without attribution", () => {
+ test("statement variant uses one paragraph with responsive stack (Figma 21967-24638)", () => {
render(
{
name: /first paragraph of the statement/i,
});
expect(region).toBeInTheDocument();
+ expect(region).toHaveAttribute("data-figma-node", "21967-24638");
expect(
screen.getByText("Second paragraph of the statement."),
).toBeInTheDocument();
expect(screen.queryByRole("cite")).not.toBeInTheDocument();
+
+ const heading = region.querySelector("#about-test-quote-content");
+ expect(heading?.querySelectorAll("span.block.lg\\:inline").length).toBe(2);
});
test("statement variant logs when quoteSecondary is missing", () => {
diff --git a/tests/unit/RuleStack.test.jsx b/tests/unit/RuleStack.test.jsx
index 1f8c537..74fa516 100644
--- a/tests/unit/RuleStack.test.jsx
+++ b/tests/unit/RuleStack.test.jsx
@@ -74,6 +74,25 @@ describe("RuleStack Component", () => {
expect(fetchMock.mock.calls.length).toBe(callsBefore);
});
+ test("uses translationNamespace for section heading copy", () => {
+ render(
+ ,
+ );
+ const heading = screen.getByRole("heading", { level: 2 });
+ expect(heading.textContent).toMatch(
+ /Get Templates that help your community run smoothly/,
+ );
+ });
+
+ test("defaults to home rule stack heading copy when namespace omitted", () => {
+ render();
+ const heading = screen.getByRole("heading", { level: 2 });
+ expect(heading.textContent).toMatch(/Popular templates/);
+ });
+
test("renders four featured governance template cards on the home row", async () => {
render();
await waitForRuleStackCards();
@@ -166,6 +185,7 @@ describe("RuleStack Component", () => {
await waitForRuleStackCards();
const section = document.querySelector("section");
+ expect(section).toHaveAttribute("data-figma-node", "22085-860413");
expect(section).toHaveClass("px-[20px]", "py-[32px]");
expect(section?.className).toMatch(/min-\[640px\]:px-\[32px\]/);
expect(section?.className).toMatch(/min-\[640px\]:py-\[48px\]/);
@@ -281,6 +301,12 @@ describe("RuleStack Component", () => {
expect(circlesIcon?.className).toMatch(
/min-\[640px\]:max-\[1023px\]:h-\[56px\]/,
);
+ expect(circlesIcon?.className).toMatch(
+ /min-\[1024px\]:max-\[1439px\]:w-\[90px\]/,
+ );
+ expect(circlesIcon?.className).toMatch(
+ /min-\[1024px\]:max-\[1439px\]:h-\[90px\]/,
+ );
expect(circlesIcon?.className).toMatch(/min-\[1440px\]:w-\[90px\]/);
expect(circlesIcon?.className).toMatch(/min-\[1440px\]:h-\[90px\]/);
});