Rebuild the About page from the current marketing frames #89

Merged
an.di merged 2 commits from adilallo/feature/CR-228-about-page into main 2026-11-04 02:30:32 +00:00
56 changed files with 1515 additions and 238 deletions
Showing only changes of commit 2101676429 - Show all commits

No files matched your search

+27 -63
View File
@@ -1,86 +1,50 @@
import type { Metadata } from "next";
import messages from "../../../messages/en/index";
import {
ASSETS,
getAssetPath,
governanceBookletPath,
structureBeforeCrisisPath,
} from "../../../lib/assetUtils";
import { getTranslation } from "../../../lib/i18n/getTranslation";
import { routeMetadata } from "../../../lib/siteMetadata";
import AboutHeader from "../../components/type/AboutHeader";
import type { AboutHeaderSegment } from "../../components/type/AboutHeader";
import Stats from "../../components/sections/Stats";
import type { StatItem } from "../../components/sections/Stats";
import TripleTextBlock from "../../components/type/TripleTextBlock";
import type { TripleTextBlockColumn } from "../../components/type/TripleTextBlock";
import Book from "../../components/sections/Book";
import FaqAccordion from "../../components/sections/Accordion";
import type { FaqAccordionItem } from "../../components/sections/Accordion";
import CenteredHeader from "../../components/sections/CenteredHeader";
import History from "../../components/sections/History";
import ParticipantSelection from "../../components/sections/ParticipantSelection";
import FourQuestions from "../../components/sections/FourQuestions";
import RebuildProcess from "../../components/sections/RebuildProcess";
import NarrativeBlock from "../../components/sections/NarrativeBlock";
import YourTurn from "../../components/sections/YourTurn";
import Credits from "../../components/sections/Credits";
import QuoteBlock from "../../components/sections/QuoteBlock";
import AskOrganizer from "../../components/sections/AskOrganizer";
export const metadata: Metadata = routeMetadata("/about", {
title: messages.metadata.about.title,
description: messages.metadata.about.description,
});
function asArray<T>(value: unknown): T[] {
return Array.isArray(value) ? value : [];
}
export default function AboutPage() {
const t = (key: string) => getTranslation(messages, key);
const page = messages.pages.about;
const headerSegments = asArray<AboutHeaderSegment>(page.aboutHeader.segments);
const statsItems = asArray<StatItem>(page.stats.items);
const faqItems = asArray<FaqAccordionItem>(page.faq.items);
const tripleColumns = asArray<TripleTextBlockColumn>(page.tripleTextBlock.columns);
const askOrganizerData = {
title: t("pages.home.askOrganizer.title"),
subtitle: t("pages.home.askOrganizer.subtitle"),
buttonText: t("pages.home.askOrganizer.buttonText"),
};
return (
<div className="min-h-screen bg-black">
<AboutHeader segments={headerSegments} />
<Stats
titlePrefix={page.stats.titlePrefix}
titleEmphasis={page.stats.titleEmphasis}
titleSuffix={page.stats.titleSuffix}
asOfPrefix={page.stats.asOfPrefix}
items={statsItems}
/>
<TripleTextBlock columns={tripleColumns} />
<Book
title={page.structureBeforeCrisis.title}
description={page.structureBeforeCrisis.description}
buttonText={page.structureBeforeCrisis.buttonText}
buttonHref={getAssetPath(structureBeforeCrisisPath())}
imageSrc={getAssetPath(ASSETS.STRUCTURE_BEFORE_CRISIS_COVER)}
imageAlt={page.structureBeforeCrisis.imageAlt}
/>
<Book
title={page.communityRules.title}
description={page.communityRules.description}
buttonText={page.communityRules.buttonText}
buttonHref={getAssetPath(governanceBookletPath())}
imageSrc={getAssetPath(ASSETS.COMMUNITY_RULES_COVER)}
imageAlt={page.communityRules.imageAlt}
/>
<FaqAccordion title={page.faq.title} items={faqItems} />
<CenteredHeader />
<QuoteBlock
variant="statement"
id="about-statement-quote"
quote={page.quote.paragraph1}
quoteSecondary={page.quote.paragraph2}
id="about-opening-statement"
statementStacked
quote={page.openingStatement.paragraph1}
quoteSecondary={page.openingStatement.paragraph2}
/>
<section className="bg-[var(--color-surface-default-primary)]">
<AskOrganizer {...askOrganizerData} />
<History />
<ParticipantSelection />
<FourQuestions />
<RebuildProcess />
<NarrativeBlock />
<YourTurn />
<Credits />
<section className="bg-black">
<AskOrganizer
title={t("pages.home.askOrganizer.title")}
subtitle={t("pages.home.askOrganizer.subtitle")}
buttonText={t("pages.home.askOrganizer.buttonText")}
/>
</section>
</div>
);
@@ -0,0 +1,26 @@
"use client";
/**
* Figma: "Section / Centered Header" (24214:25892)
*/
import { memo } from "react";
import { useMessages } from "../../../contexts/MessagesContext";
import CenteredHeaderView from "./CenteredHeader.view";
import type { CenteredHeaderSegment } from "./CenteredHeader.types";
const CenteredHeaderContainer = memo(() => {
const copy = useMessages().pages.about.centeredHeader;
return (
<CenteredHeaderView
accessibleName={copy.accessibleName}
subhead={copy.subhead}
credit={copy.credit}
segments={copy.segments as CenteredHeaderSegment[]}
/>
);
});
CenteredHeaderContainer.displayName = "CenteredHeader";
export default CenteredHeaderContainer;
@@ -0,0 +1,12 @@
export type CenteredHeaderIcon = "arrow" | "ring";
export type CenteredHeaderSegment =
| { type: "word"; text: string }
| { type: "icon"; icon: CenteredHeaderIcon };
export interface CenteredHeaderViewProps {
accessibleName: string;
segments: CenteredHeaderSegment[];
subhead: string;
credit: string;
}
@@ -0,0 +1,103 @@
"use client";
import { memo, type ReactNode } from "react";
import { getAssetPath } from "../../../../lib/assetUtils";
import type {
CenteredHeaderIcon,
CenteredHeaderSegment,
CenteredHeaderViewProps,
} from "./CenteredHeader.types";
const ICON_SRC: Record<CenteredHeaderIcon, string> = {
arrow: "assets/shapes/about-header-arrow.svg",
ring: "assets/shapes/about-header-ring.svg",
};
const ICON_CLASS: Record<CenteredHeaderIcon, string> = {
arrow: "size-10 md:size-[54px] lg:size-20",
ring: "size-8 md:size-[54px] lg:size-20",
};
/** Poster lines at 1024+, matching the desktop frame. Narrower widths wrap on their own. */
const DESKTOP_LINES = [[0], [1], [2, 3], [4, 5], [6, 7, 8]] as const;
function SegmentView({ segment }: { segment: CenteredHeaderSegment }) {
if (segment.type === "word") {
return (
<span className="font-bricolage-grotesque text-display-m whitespace-nowrap text-[var(--color-content-default-primary)] md:text-display-xl lg:text-display-xxl">
{segment.text}
</span>
);
}
return (
<span
className={`inline-flex shrink-0 items-center justify-center ${ICON_CLASS[segment.icon]}`}
>
{/* eslint-disable-next-line @next/next/no-img-element -- decorative Figma mark */}
<img
src={getAssetPath(ICON_SRC[segment.icon])}
alt=""
width={80}
height={80}
className="size-full"
/>
</span>
);
}
function CenteredHeaderView({
accessibleName,
segments,
subhead,
credit,
}: CenteredHeaderViewProps) {
const desktopLines: ReactNode = DESKTOP_LINES.map((line) => (
<div key={line.join("-")} className="flex items-center justify-center gap-4">
{line.map((index) => {
const segment = segments[index];
return segment ? <SegmentView key={index} segment={segment} /> : null;
})}
</div>
));
return (
<section
data-figma-node="24214:25892"
className="flex flex-col items-center gap-[var(--space-600)] overflow-hidden bg-black px-[var(--space-600)] py-[var(--space-3000)] md:px-[var(--space-1800)]"
aria-labelledby="about-centered-header"
>
<h1 id="about-centered-header" className="sr-only">
{accessibleName}
</h1>
<div
aria-hidden
className="flex w-full flex-wrap items-center justify-center gap-x-3 gap-y-2 lg:hidden"
>
{segments.map((segment, index) => (
<SegmentView key={index} segment={segment} />
))}
</div>
<div
aria-hidden
className="hidden w-full max-w-[32rem] flex-col items-center gap-4 lg:flex"
>
{desktopLines}
</div>
<p className="max-w-[720px] text-center font-inter text-paragraph-m text-[var(--color-content-default-secondary)] md:text-paragraph-xl lg:font-bricolage-grotesque lg:text-display-s">
{subhead}
</p>
<div
className="h-px w-[295px] bg-[var(--color-border-default-secondary)]"
aria-hidden
/>
<p className="max-w-[720px] text-center font-inter text-label-xs text-[var(--color-content-default-tertiary)] md:text-label-s">
{credit}
</p>
</section>
);
}
CenteredHeaderView.displayName = "CenteredHeaderView";
export default memo(CenteredHeaderView);
@@ -0,0 +1,6 @@
export { default } from "./CenteredHeader.container";
export type {
CenteredHeaderIcon,
CenteredHeaderSegment,
CenteredHeaderViewProps,
} from "./CenteredHeader.types";
@@ -0,0 +1,18 @@
"use client";
/**
* Figma: "Section / Credits" (24240:1547)
*/
import { memo } from "react";
import { useMessages } from "../../../contexts/MessagesContext";
import CreditsView from "./Credits.view";
const CreditsContainer = memo(() => {
const copy = useMessages().pages.about.credits;
return <CreditsView heading={copy.heading} rows={copy.rows} />;
});
CreditsContainer.displayName = "Credits";
export default CreditsContainer;
@@ -0,0 +1,9 @@
export type CreditRow = {
role: string;
name: string;
};
export interface CreditsViewProps {
heading: string;
rows: CreditRow[];
}
@@ -0,0 +1,42 @@
"use client";
import { memo } from "react";
import type { CreditsViewProps } from "./Credits.types";
function CreditsView({ heading, rows }: CreditsViewProps) {
return (
<section
data-figma-node="24240:1547"
className="flex flex-col items-center bg-black px-[var(--space-400)] py-16 md:px-16 lg:px-20 lg:py-[var(--space-3000)]"
aria-labelledby="about-credits-heading"
>
<div className="flex w-full max-w-[800px] flex-col items-center gap-12">
<h2
id="about-credits-heading"
className="text-center font-bricolage-grotesque text-heading-l text-[var(--color-content-default-primary)]"
>
{heading}
</h2>
<dl className="m-0 flex w-full flex-col gap-5">
{rows.map((row) => (
<div
key={row.role}
className="flex flex-col gap-1 border-b border-[var(--color-border-default-tertiary)] pb-4"
>
<dt className="font-inter text-label-caps-xxs text-[var(--color-content-default-tertiary)] uppercase">
{row.role}
</dt>
<dd className="m-0 font-inter text-paragraph-s text-[var(--color-content-default-primary)]">
{row.name}
</dd>
</div>
))}
</dl>
</div>
</section>
);
}
CreditsView.displayName = "CreditsView";
export default memo(CreditsView);
@@ -0,0 +1,2 @@
export { default } from "./Credits.container";
export type { CreditRow, CreditsViewProps } from "./Credits.types";
@@ -0,0 +1,29 @@
"use client";
/**
* Figma: "Section / Four Questions" (24230:852)
*/
import { memo } from "react";
import { useMessages } from "../../../contexts/MessagesContext";
import FourQuestionsView from "./FourQuestions.view";
import type { FourQuestionCard } from "./FourQuestions.types";
const FourQuestionsContainer = memo(() => {
const copy = useMessages().pages.about.fourQuestions;
return (
<FourQuestionsView
heading={copy.heading}
cards={copy.cards as FourQuestionCard[]}
wishHeading={copy.wishHeading}
wishParagraphs={copy.wishParagraphs}
statsHeading={copy.statsHeading}
statsSource={copy.statsSource}
stats={copy.stats}
/>
);
});
FourQuestionsContainer.displayName = "FourQuestions";
export default FourQuestionsContainer;
@@ -0,0 +1,29 @@
export const FOUR_QUESTION_TONE_OPTIONS = [
"yellow",
"teal",
"lavender",
"red",
] as const;
export type FourQuestionTone = (typeof FOUR_QUESTION_TONE_OPTIONS)[number];
export type FourQuestionCard = {
tone: FourQuestionTone;
question: string;
line: string;
};
export type FourQuestionStat = {
value: string;
line: string;
};
export interface FourQuestionsViewProps {
heading: string;
cards: FourQuestionCard[];
wishHeading: string;
wishParagraphs: string[];
statsHeading: string;
statsSource: string;
stats: FourQuestionStat[];
}
@@ -0,0 +1,98 @@
"use client";
import { memo } from "react";
import type {
FourQuestionTone,
FourQuestionsViewProps,
} from "./FourQuestions.types";
const TONE_CLASS: Record<FourQuestionTone, string> = {
yellow: "bg-[var(--color-surface-invert-brand-secondary)]",
teal: "bg-[var(--color-surface-invert-brand-teal)]",
lavender: "bg-[var(--color-surface-invert-brand-lavender)]",
red: "bg-[var(--color-surface-invert-brand-red)]",
};
function FourQuestionsView({
heading,
cards,
wishHeading,
wishParagraphs,
statsHeading,
statsSource,
stats,
}: FourQuestionsViewProps) {
return (
<section
data-figma-node="24230:852"
className="flex flex-col gap-11 border-b border-[var(--color-border-default-secondary)] bg-black px-[var(--space-400)] py-16 lg:gap-14 lg:px-[var(--space-2400)] lg:py-20"
aria-labelledby="about-four-questions-heading"
>
<h2
id="about-four-questions-heading"
className="max-w-[768px] font-bricolage-grotesque text-heading-l text-[var(--color-content-default-primary)] lg:text-heading-xxl"
>
{heading}
</h2>
<ul className="m-0 grid list-none grid-cols-1 gap-4 p-0 md:grid-cols-2 md:gap-6">
{cards.map((card) => (
<li
key={card.question}
className={`flex flex-col gap-3 rounded-[var(--radius-300)] p-6 lg:gap-4 lg:rounded-[var(--radius-500)] lg:p-8 ${TONE_CLASS[card.tone]}`}
>
<h3 className="font-bricolage-grotesque text-display-xs text-[var(--color-content-invert-primary)]">
{card.question}
</h3>
<p className="font-inter text-paragraph-s text-[var(--color-content-invert-tertiary)] lg:text-paragraph-m">
{card.line}
</p>
</li>
))}
</ul>
<div className="flex max-w-[736px] flex-col gap-6">
<h2 className="font-bricolage-grotesque text-heading-l text-[var(--color-content-default-primary)] lg:text-heading-xxl">
{wishHeading}
</h2>
<div className="flex flex-col gap-[1lh]">
{wishParagraphs.map((paragraph) => (
<p
key={paragraph}
className="font-inter text-paragraph-m text-[var(--color-content-default-secondary)] lg:text-paragraph-l"
>
{paragraph}
</p>
))}
</div>
</div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2 lg:gap-3">
<h3 className="font-bricolage-grotesque text-display-s text-[var(--color-content-default-primary)]">
{statsHeading}
</h3>
<p className="font-inter text-paragraph-s text-[var(--color-content-default-tertiary)] lg:text-paragraph-m">
{statsSource}
</p>
</div>
<dl className="m-0 flex flex-col pt-0 lg:flex-row lg:gap-16 lg:pt-10">
{stats.map((stat) => (
<div
key={stat.value}
className="flex flex-col gap-2.5 border-t border-[var(--color-border-default-secondary)] py-6 lg:flex-1 lg:gap-4 lg:border-t-0 lg:py-0"
>
<dt className="font-bricolage-grotesque text-display-s text-[var(--color-content-default-primary)] lg:text-display-xl">
{stat.value}
</dt>
<dd className="m-0 font-inter text-paragraph-s text-[var(--color-content-default-secondary)] lg:text-paragraph-m">
{stat.line}
</dd>
</div>
))}
</dl>
</div>
</section>
);
}
FourQuestionsView.displayName = "FourQuestionsView";
export default memo(FourQuestionsView);
@@ -0,0 +1,8 @@
export { default } from "./FourQuestions.container";
export type {
FourQuestionCard,
FourQuestionStat,
FourQuestionTone,
FourQuestionsViewProps,
} from "./FourQuestions.types";
export { FOUR_QUESTION_TONE_OPTIONS } from "./FourQuestions.types";
@@ -0,0 +1,21 @@
"use client";
/**
* Figma: "Section / History" (24220:30635)
*/
import { memo } from "react";
import { useMessages } from "../../../contexts/MessagesContext";
import HistoryView from "./History.view";
import type { HistoryItem } from "./History.types";
const HistoryContainer = memo(() => {
const copy = useMessages().pages.about.history;
return (
<HistoryView heading={copy.heading} items={copy.items as HistoryItem[]} />
);
});
HistoryContainer.displayName = "History";
export default HistoryContainer;
@@ -0,0 +1,24 @@
export type HistoryAccent = "brand" | "positive";
export type HistoryRun = {
text: string;
emphasis?: boolean;
};
export type HistoryItem =
| {
kind: "entry";
date: string;
accent: HistoryAccent;
body: HistoryRun[];
}
| {
kind: "callout";
title: string;
body: string;
};
export interface HistoryViewProps {
heading: string;
items: HistoryItem[];
}
@@ -0,0 +1,82 @@
"use client";
import { memo } from "react";
import type { HistoryAccent, HistoryItem, HistoryViewProps } from "./History.types";
const ACCENT_CLASS: Record<HistoryAccent, string> = {
brand: "bg-[var(--color-content-default-brand-primary)]",
positive: "bg-[var(--color-surface-default-positive-primary)]",
};
const DATE_CLASS =
"font-bricolage-grotesque text-display-xxs text-[var(--color-content-default-primary)]";
const BODY_CLASS =
"font-inter text-paragraph-m text-[var(--color-content-default-secondary)]";
function DateMark({ date, accent }: { date: string; accent: HistoryAccent }) {
return (
<div className="flex w-full flex-col gap-1 md:w-[140px] md:shrink-0">
<p className={`${DATE_CLASS} whitespace-nowrap`}>{date}</p>
<span className={`h-0.5 w-8 ${ACCENT_CLASS[accent]}`} aria-hidden />
</div>
);
}
function HistoryItemView({ item }: { item: HistoryItem }) {
if (item.kind === "callout") {
return (
<aside className="flex flex-col gap-5 border-l-[3px] border-[var(--color-content-default-brand-primary)] pl-6">
<p className={DATE_CLASS}>{item.title}</p>
<p className={BODY_CLASS}>{item.body}</p>
</aside>
);
}
return (
<div
className={`flex flex-col gap-4 md:flex-row md:gap-8 ${
item.accent === "positive" ? "pt-6" : ""
}`}
>
<DateMark date={item.date} accent={item.accent} />
<p className={`min-w-0 flex-1 ${BODY_CLASS}`}>
{item.body.map((run, index) =>
run.emphasis ? (
<em key={index} className="italic">
{run.text}
</em>
) : (
<span key={index}>{run.text}</span>
),
)}
</p>
</div>
);
}
function HistoryView({ heading, items }: HistoryViewProps) {
return (
<section
data-figma-node="24220:30635"
className="flex flex-col items-center gap-10 bg-black px-[var(--space-400)] py-12 md:gap-16 md:px-[var(--space-1800)] md:py-[88px]"
aria-labelledby="about-history-heading"
>
<h2
id="about-history-heading"
className="w-full max-w-[800px] text-center font-bricolage-grotesque text-heading-l text-[var(--color-content-default-primary)]"
>
{heading}
</h2>
<div className="flex w-full max-w-[800px] flex-col gap-8 md:gap-10">
{items.map((item, index) => (
<HistoryItemView key={item.kind === "entry" ? item.date : index} item={item} />
))}
</div>
</section>
);
}
HistoryView.displayName = "HistoryView";
export default memo(HistoryView);
@@ -0,0 +1,2 @@
export { default } from "./History.container";
export type { HistoryItem, HistoryViewProps } from "./History.types";
@@ -0,0 +1,37 @@
"use client";
/**
* Figma: "Section / Narrative Block" (24237:1241)
*/
import { memo, useCallback, useState } from "react";
import { useMessages } from "../../../contexts/MessagesContext";
import AskOrganizerInquiryModal from "../../modals/AskOrganizerInquiry";
import NarrativeBlockView from "./NarrativeBlock.view";
const NarrativeBlockContainer = memo(() => {
const copy = useMessages().pages.about.narrativeBlock;
const [inquiryOpen, setInquiryOpen] = useState(false);
const openInquiry = useCallback(() => setInquiryOpen(true), []);
const closeInquiry = useCallback(() => setInquiryOpen(false), []);
return (
<>
<NarrativeBlockView
heading={copy.heading}
paragraphs={copy.paragraphs}
addHeading={copy.addHeading}
addLine={copy.addLine}
shareExperiences={copy.shareExperiences}
helpBuild={copy.helpBuild}
helpBuildHref={copy.helpBuildHref}
onShareExperiences={openInquiry}
/>
<AskOrganizerInquiryModal isOpen={inquiryOpen} onClose={closeInquiry} />
</>
);
});
NarrativeBlockContainer.displayName = "NarrativeBlock";
export default NarrativeBlockContainer;
@@ -0,0 +1,10 @@
export interface NarrativeBlockViewProps {
heading: string;
paragraphs: string[];
addHeading: string;
addLine: string;
shareExperiences: string;
helpBuild: string;
helpBuildHref: string;
onShareExperiences: () => void;
}
@@ -0,0 +1,77 @@
"use client";
import { memo } from "react";
import Button from "../../buttons/Button";
import type { NarrativeBlockViewProps } from "./NarrativeBlock.types";
function NarrativeBlockView({
heading,
paragraphs,
addHeading,
addLine,
shareExperiences,
helpBuild,
helpBuildHref,
onShareExperiences,
}: NarrativeBlockViewProps) {
return (
<section
data-figma-node="24237:1241"
className="flex flex-col items-center gap-10 border-b border-[var(--color-border-default-secondary)] bg-black px-[var(--space-400)] py-16 lg:gap-14 lg:px-[var(--space-2400)] lg:py-[var(--space-3000)]"
aria-labelledby="about-narrative-heading"
>
<div className="flex w-full max-w-[800px] flex-col gap-8 lg:gap-10">
<h2
id="about-narrative-heading"
className="font-bricolage-grotesque text-heading-l text-[var(--color-content-default-primary)] lg:text-heading-xxl"
>
{heading}
</h2>
<div className="flex flex-col gap-8">
{paragraphs.map((paragraph) => (
<p
key={paragraph}
className="font-inter text-paragraph-l text-[var(--color-content-default-secondary)] lg:text-paragraph-xl"
>
{paragraph}
</p>
))}
</div>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<h3 className="font-bricolage-grotesque text-display-xs text-[var(--color-content-default-primary)]">
{addHeading}
</h3>
<p className="font-inter text-paragraph-s text-[var(--color-content-default-secondary)]">
{addLine}
</p>
</div>
<div className="flex flex-wrap gap-4">
<Button
buttonType="filled"
palette="default"
size="medium"
onClick={onShareExperiences}
>
{shareExperiences}
</Button>
<Button
buttonType="filled"
palette="default"
size="medium"
href={helpBuildHref}
target="_blank"
rel="noopener noreferrer"
>
{helpBuild}
</Button>
</div>
</div>
</div>
</section>
);
}
NarrativeBlockView.displayName = "NarrativeBlockView";
export default memo(NarrativeBlockView);
@@ -0,0 +1,2 @@
export { default } from "./NarrativeBlock.container";
export type { NarrativeBlockViewProps } from "./NarrativeBlock.types";
@@ -0,0 +1,18 @@
"use client";
/**
* Figma: "Section / Participant Selection" (24227:30762)
*/
import { memo } from "react";
import { useMessages } from "../../../contexts/MessagesContext";
import ParticipantSelectionView from "./ParticipantSelection.view";
const ParticipantSelectionContainer = memo(() => {
const copy = useMessages().pages.about.participantSelection;
return <ParticipantSelectionView {...copy} />;
});
ParticipantSelectionContainer.displayName = "ParticipantSelection";
export default ParticipantSelectionContainer;
@@ -0,0 +1,6 @@
export interface ParticipantSelectionViewProps {
heading: string;
body: string;
groups: string[];
footnote: string;
}
@@ -0,0 +1,89 @@
"use client";
import { memo } from "react";
import { getAssetPath } from "../../../../lib/assetUtils";
import type { ParticipantSelectionViewProps } from "./ParticipantSelection.types";
function ParticipantArt() {
return (
<div
className="relative mx-auto aspect-[540/555] w-full max-w-[420px] pr-12 lg:mx-0 lg:w-[540px] lg:max-w-none lg:shrink-0 lg:pr-0"
aria-hidden
>
{/* eslint-disable @next/next/no-img-element -- decorative Figma shapes */}
<img
src={getAssetPath("assets/shapes/about-participant-circles.svg")}
alt=""
width={282}
height={282}
className="absolute top-0 left-0 h-auto w-[52.2%]"
/>
<img
src={getAssetPath("assets/shapes/about-participant-rings.svg")}
alt=""
width={334}
height={334}
className="absolute top-[6.3%] left-[38.15%] h-auto w-[61.85%]"
/>
<img
src={getAssetPath("assets/shapes/about-participant-clover.svg")}
alt=""
width={200}
height={200}
className="absolute top-[57.4%] left-[25.7%] h-auto w-[37%] rotate-[60deg]"
/>
{/* eslint-enable @next/next/no-img-element */}
</div>
);
}
function ParticipantSelectionView({
heading,
body,
groups,
footnote,
}: ParticipantSelectionViewProps) {
return (
<section
data-figma-node="24227:30762"
className="flex flex-col gap-4 border-b border-[var(--color-border-default-secondary)] bg-black px-[var(--space-400)] py-20 lg:flex-row lg:items-center lg:justify-center lg:gap-8 lg:px-[var(--space-2400)]"
aria-labelledby="about-participant-heading"
>
<ParticipantArt />
<div className="flex w-full min-w-0 flex-col gap-12 lg:max-w-[928px] lg:flex-1">
<div className="flex flex-col gap-4">
<h2
id="about-participant-heading"
className="font-bricolage-grotesque text-display-s text-[var(--color-content-default-primary)] lg:text-heading-xxl"
>
{heading}
</h2>
<p className="font-inter text-paragraph-s text-[var(--color-content-default-tertiary)] lg:text-paragraph-l">
{body}
</p>
</div>
<div>
<ul className="m-0 list-none p-0">
{groups.map((group, index) => (
<li
key={group}
className={`border-b border-[var(--color-border-default-secondary)] py-4 font-inter text-paragraph-xs text-[var(--color-content-default-primary)] lg:text-paragraph-m ${
index === 0 ? "border-t" : ""
}`}
>
{group}
</li>
))}
</ul>
<p className="pt-6 font-inter text-paragraph-xxs text-[var(--color-content-default-tertiary)] lg:text-paragraph-s">
{footnote}
</p>
</div>
</div>
</section>
);
}
ParticipantSelectionView.displayName = "ParticipantSelectionView";
export default memo(ParticipantSelectionView);
@@ -0,0 +1,2 @@
export { default } from "./ParticipantSelection.container";
export type { ParticipantSelectionViewProps } from "./ParticipantSelection.types";
@@ -13,6 +13,7 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
className = "",
quote = "The rules of decision-making must be open and available to everyone, and this can happen only if they are formalized.",
quoteSecondary,
statementStacked = false,
author = "Jo Freeman",
source = "The Tyranny of Structurelessness",
avatarSrc = getAssetPath(ASSETS.QUOTE_AVATAR),
@@ -151,6 +152,7 @@ const QuoteBlockContainer = memo<QuoteBlockProps>(
className={className}
quote={quote}
quoteSecondary={quoteSecondary}
statementStacked={statementStacked}
author={author}
source={source}
quoteId={quoteId}
@@ -11,6 +11,11 @@ export interface QuoteBlockProps {
quote?: string;
/** Second paragraph for **`statement`** (Section/Quote); merged into one `<p>` from **`lg`**. */
quoteSecondary?: string;
/**
* Keep the two statement paragraphs stacked at every breakpoint.
* Default joins them into one paragraph from `lg`.
*/
statementStacked?: boolean;
author?: string;
source?: string;
avatarSrc?: string;
@@ -46,6 +51,7 @@ export interface QuoteBlockViewProps {
className: string;
quote: string;
quoteSecondary?: string;
statementStacked: boolean;
author: string;
source?: string;
quoteId: string;
@@ -11,6 +11,7 @@ function QuoteBlockView({
className,
quote,
quoteSecondary,
statementStacked,
author,
source,
quoteId,
@@ -41,13 +42,25 @@ function QuoteBlockView({
<div className="relative z-10 flex w-full min-w-0 shrink-0 flex-col items-center text-center">
<p
id={quoteId}
className={`${statementTextClass} mb-0 flex w-full min-w-0 flex-col gap-9 text-center md:gap-[length:var(--text-xx-large-heading--line-height)] lg:block lg:gap-0`}
className={
statementStacked
? `${statementTextClass} mb-0 flex w-full min-w-0 flex-col gap-[1lh] text-center`
: `${statementTextClass} mb-0 flex w-full min-w-0 flex-col gap-9 text-center md:gap-[length:var(--text-xx-large-heading--line-height)] lg:block lg:gap-0`
}
>
<span className="block lg:inline">{quote}</span>
<span className={statementStacked ? "block" : "block lg:inline"}>
{quote}
</span>
{quoteSecondary ? (
<>
<span className="hidden lg:inline">{" "}</span>
<span className="block lg:inline">{quoteSecondary}</span>
{statementStacked ? (
" "
) : (
<span className="hidden lg:inline">{" "}</span>
)}
<span className={statementStacked ? "block" : "block lg:inline"}>
{quoteSecondary}
</span>
</>
) : null}
</p>
@@ -0,0 +1,18 @@
"use client";
/**
* Figma: "Section / Rebuild Process" (24237:1085)
*/
import { memo } from "react";
import { useMessages } from "../../../contexts/MessagesContext";
import RebuildProcessView from "./RebuildProcess.view";
const RebuildProcessContainer = memo(() => {
const copy = useMessages().pages.about.rebuildProcess;
return <RebuildProcessView {...copy} />;
});
RebuildProcessContainer.displayName = "RebuildProcess";
export default RebuildProcessContainer;
@@ -0,0 +1,4 @@
export interface RebuildProcessViewProps {
heading: string;
body: string;
}
@@ -0,0 +1,28 @@
"use client";
import { memo } from "react";
import type { RebuildProcessViewProps } from "./RebuildProcess.types";
function RebuildProcessView({ heading, body }: RebuildProcessViewProps) {
return (
<section
data-figma-node="24237:1085"
className="flex flex-col gap-6 border-b border-white/15 bg-black px-[var(--space-400)] py-16 lg:flex-row lg:items-start lg:justify-center lg:gap-12 lg:px-[var(--space-2400)] lg:py-[var(--space-3000)]"
aria-labelledby="about-rebuild-heading"
>
<h2
id="about-rebuild-heading"
className="font-bricolage-grotesque text-heading-l text-[var(--color-content-default-primary)] lg:w-[287px] lg:shrink-0"
>
{heading}
</h2>
<p className="max-w-[800px] font-inter text-paragraph-l text-[var(--color-content-default-tertiary)] lg:flex-1 lg:text-paragraph-xl">
{body}
</p>
</section>
);
}
RebuildProcessView.displayName = "RebuildProcessView";
export default memo(RebuildProcessView);
@@ -0,0 +1,2 @@
export { default } from "./RebuildProcess.container";
export type { RebuildProcessViewProps } from "./RebuildProcess.types";
@@ -0,0 +1,67 @@
"use client";
/**
* Figma: "Section / Your Turn" (24239:1397)
*/
import { memo, useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useMessages } from "../../../contexts/MessagesContext";
import { fetchAuthSession } from "../../../../lib/create/api";
import { prepareFreshCreateFlowEntrySync } from "../../../(app)/create/utils/prepareFreshCreateFlowEntry";
import AskOrganizerInquiryModal from "../../modals/AskOrganizerInquiry";
import YourTurnView from "./YourTurn.view";
const YourTurnContainer = memo(() => {
const router = useRouter();
const copy = useMessages().pages.about.yourTurn;
const [signedIn, setSignedIn] = useState(false);
const [inquiryOpen, setInquiryOpen] = useState(false);
useEffect(() => {
let cancelled = false;
void fetchAuthSession()
.then(({ user }) => {
if (!cancelled) setSignedIn(Boolean(user));
})
.catch(() => {
/* guest fallback */
});
return () => {
cancelled = true;
};
}, []);
const onCreateRule = useCallback(() => {
prepareFreshCreateFlowEntrySync({ signedIn });
router.push("/create/informational");
}, [router, signedIn]);
return (
<>
<YourTurnView
heading={copy.heading}
body={copy.body}
createRule={copy.createRule}
facilitator={copy.facilitator}
facilitatorHref={copy.facilitatorHref}
questionsLead={copy.questionsLead}
faq={copy.faq}
faqHref={copy.faqHref}
or={copy.or}
askOrganizer={copy.askOrganizer}
illustrationCaption={copy.illustrationCaption}
onCreateRule={onCreateRule}
onAskOrganizer={() => setInquiryOpen(true)}
/>
<AskOrganizerInquiryModal
isOpen={inquiryOpen}
onClose={() => setInquiryOpen(false)}
/>
</>
);
});
YourTurnContainer.displayName = "YourTurn";
export default YourTurnContainer;
@@ -0,0 +1,15 @@
export interface YourTurnViewProps {
heading: string;
body: string;
createRule: string;
facilitator: string;
facilitatorHref: string;
questionsLead: string;
faq: string;
faqHref: string;
or: string;
askOrganizer: string;
illustrationCaption: string;
onCreateRule: () => void;
onAskOrganizer: () => void;
}
@@ -0,0 +1,102 @@
"use client";
import { memo } from "react";
import Button from "../../buttons/Button";
import Link from "../../navigation/Link";
import type { YourTurnViewProps } from "./YourTurn.types";
const COLOR_BARS = [
"bg-[var(--color-lavender-lavender200)]",
"bg-[var(--color-surface-invert-brand-teal)]",
"bg-[var(--color-surface-invert-brand-secondary)]",
"bg-[var(--color-surface-invert-brand-red)]",
] as const;
function YourTurnView({
heading,
body,
createRule,
facilitator,
facilitatorHref,
questionsLead,
faq,
faqHref,
or,
askOrganizer,
illustrationCaption,
onCreateRule,
onAskOrganizer,
}: YourTurnViewProps) {
return (
<section
data-figma-node="24239:1397"
className="border-b border-[var(--color-border-default-secondary)] bg-[var(--color-surface-default-tertiary)] px-[var(--space-600)] py-12 lg:px-[var(--space-2400)] lg:py-[var(--space-3000)]"
aria-labelledby="about-your-turn-heading"
>
<div className="mx-auto flex w-full max-w-[1920px] flex-col gap-10 lg:flex-row lg:items-start lg:gap-12">
<div className="flex w-full flex-col gap-8 lg:max-w-[472px] lg:shrink-0 lg:gap-12">
<div className="flex flex-col gap-5 lg:gap-6">
<div className="flex h-1 w-[184px] gap-1.5 lg:h-3 lg:w-[400px]" aria-hidden>
{COLOR_BARS.map((bar) => (
<span key={bar} className={`h-full flex-1 rounded-sm ${bar}`} />
))}
</div>
<h2
id="about-your-turn-heading"
className="font-bricolage-grotesque text-heading-l text-[var(--color-content-default-primary)]"
>
{heading}
</h2>
<p className="font-inter text-paragraph-m text-[var(--color-content-default-secondary)]">
{body}
</p>
</div>
<div className="flex flex-col gap-3 md:flex-row md:gap-4">
<Button
buttonType="filled"
palette="default"
size="large"
className="w-full justify-center md:w-auto"
onClick={onCreateRule}
>
{createRule}
</Button>
<Button
buttonType="outline"
palette="default"
size="large"
className="w-full justify-center md:w-auto"
href={facilitatorHref}
>
{facilitator}
</Button>
</div>
<p className="font-inter text-paragraph-s text-[var(--color-content-default-secondary)]">
{questionsLead}{" "}
<Link href={faqHref} variant="paragraph" theme="dark" type="primary">
{faq}
</Link>{" "}
{or}{" "}
<Link
variant="paragraph"
theme="dark"
type="primary"
onClick={onAskOrganizer}
>
{askOrganizer}
</Link>
</p>
</div>
<div className="flex h-[246px] w-full flex-col items-start justify-end rounded-[var(--radius-300)] border border-dashed border-[var(--color-border-default-tertiary)] bg-[var(--color-surface-default-secondary)] p-6 lg:h-[340px] lg:flex-1">
<p className="font-inter text-paragraph-xxs text-[var(--color-content-default-tertiary)]">
{illustrationCaption}
</p>
</div>
</div>
</section>
);
}
YourTurnView.displayName = "YourTurnView";
export default memo(YourTurnView);
@@ -0,0 +1,2 @@
export { default } from "./YourTurn.container";
export type { YourTurnViewProps } from "./YourTurn.types";
+13 -5
View File
@@ -145,11 +145,19 @@ Inventory aligns with [**CR-104**](https://linear.app/community-rule/issue/CR-10
| Card steps (SectionCardSteps) | **`CardSteps/`** | Composes **`cards/Step`** (Figma **Card / Step** — not **`progress/Stepper`**). |
| Rule stack | **`RuleStack/`** | |
| Feature grid | **`FeatureGrid/`** | |
| Quote block | **`QuoteBlock/`** | Includes **`statement`** (Section/Quote **22137:890679**), shipped **`/about`** under FAQ; **`standard` / `compact` / `extended`** remain portrait + attribution. |
| Quote block | **`QuoteBlock/`** | Includes **`statement`** (Section/Quote **22137:890679**). About passes **`statementStacked`** for the opening statement. **`standard` / `compact` / `extended`** remain portrait + attribution. |
| Ask organizer | **`AskOrganizer/`** | |
| Stats (about metrics) | **`Stats/`** | Composes **`cards/Stat`** + **`asset/Shapes`**; shipped on **`/about`**. |
| Book promo | **`Book/`** | **`/about`** download band — **`ContentLockup`** + **`Button`**. |
| FAQ accordion | **`Accordion/`** | Section wrapper over **`layout/Accordion`**; **`/about`**. |
| Centered header | **`CenteredHeader/`** | Figma [**24214:25892**](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=24214-25892). About hero. |
| History | **`History/`** | Figma [**24220:30635**](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=24220-30635). About timeline. |
| Participant selection | **`ParticipantSelection/`** | Figma [**24227:30762**](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=24227-30762). About study groups, unnamed. |
| Four questions | **`FourQuestions/`** | Figma [**24230:852**](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=24230-852). About questions and study stats. |
| Rebuild process | **`RebuildProcess/`** | Figma [**24237:1085**](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=24237-1085). |
| Narrative block | **`NarrativeBlock/`** | Figma [**24237:1241**](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=24237-1241). |
| Your turn | **`YourTurn/`** | Figma [**24239:1397**](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=24239-1397). About closing call to action. |
| Credits | **`Credits/`** | Figma [**24240:1547**](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=24240-1547). |
| Stats (about metrics) | **`Stats/`** | Composes **`cards/Stat`** + **`asset/Shapes`**. |
| Book promo | **`Book/`** | Download band — **`ContentLockup`** + **`Button`**. |
| FAQ accordion | **`Accordion/`** | Section wrapper over **`layout/Accordion`**. |
| Related content | **`RelatedArticles/`** | Article list / cards — confirm naming vs Figma “related slider” frames. |
| Template grid (governance) | **`GovernanceTemplateGrid/`** | **`GovernanceTemplateGridSkeleton`** colocated. |
| Use cases org strip | **`UseCasesOrgs/`** | Figma [**22112-873893**](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22112-873893); composes **`cards/CaseStudy`**; **`/use-cases`**. |
@@ -170,7 +178,7 @@ Inventory aligns with [**CR-104**](https://linear.app/community-rule/issue/CR-10
| --- | --- | --- |
| Section header (1 vs 3 lines, responsive sizes) | **`SectionHeader/`** | Figma [**17411:10981**](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=17411-10981). **`SectionHeader.tsx`** + **`index.tsx`**; **`default`** / **`multi-line`**; optional **`stackedDesktopLines`**. Composed by **`sections/CardSteps`**, **`sections/RuleStack`**, etc. |
| Header lockup / content lockup | **`HeaderLockup/`**, **`ContentLockup/`** | **`container` / `view` / `types`** + **`index.tsx`** where split. **`ContentLockup`** **`about`** variant + optional **`titleContent`** for **`AboutHeader`**. |
| About header (inline word + shape lockup) | **`AboutHeader/`** | **`/about`** hero; composes **`ContentLockup`**. |
| About header (inline word + shape lockup) | **`AboutHeader/`** | Earlier About hero; composes **`ContentLockup`**. About now opens with **`sections/CenteredHeader`**. |
| Marketing page header (title + body + optional CTA) | **`PageHeader/`** | Figma [**21004-15902**](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=21004-15902); **`/use-cases`**. |
| Triple text block | **`TripleTextBlock/`** | **`/about`** (columns only; optional title/CTA omitted per page). |
| Three-step explainer + CTA | **`TripleStep/`** | Figma [**22084-859256**](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22084-859256); **`/use-cases`** (illustration column deferred). |
+2 -1
View File
@@ -17,7 +17,8 @@
]
},
"about": {
"title": "About"
"title": "About",
"description": "Why the Media Economies Design Lab at CU Boulder rebuilt CommunityRule, what organizers told us and who builds it."
},
"learn": {
"title": "Learn"
+172 -87
View File
@@ -1,125 +1,210 @@
{
"_comment": "About page content. Book download hrefs are wired in app/(marketing)/about/page.tsx (structureBeforeCrisisPath / governanceBookletPath).",
"aboutHeader": {
"_comment": "About page. Three sentences stay out until they are approved: cards and templates in the public domain, a Rule staying private unless it is shared, and co-op lawyers designing the Rule format.",
"centeredHeader": {
"accessibleName": "Every community has rules written or not",
"segments": [
{ "type": "word", "text": "CommunityRule" },
{ "type": "word", "text": "Every" },
{ "type": "word", "text": "community" },
{ "type": "word", "text": "has" },
{ "type": "word", "text": "rules" },
{ "type": "icon", "icon": "arrow" },
{ "type": "word", "text": "is" },
{ "type": "word", "text": "a" },
{ "type": "word", "text": "tool" },
{ "type": "word", "text": "that" },
{ "type": "word", "text": "helps" },
{ "type": "word", "text": "groups" },
{ "type": "icon", "icon": "about" },
{ "type": "word", "text": "define" },
{ "type": "word", "text": "who" },
{ "type": "word", "text": "they" },
{ "type": "word", "text": "want" },
{ "type": "word", "text": "to" },
{ "type": "word", "text": "be" }
]
{ "type": "word", "text": "written" },
{ "type": "word", "text": "or" },
{ "type": "word", "text": "not" },
{ "type": "icon", "icon": "ring" }
],
"subhead": "CommunityRule helps communities write down, together, how they talk, who belongs, how they decide and what happens when they disagree.",
"credit": "From the Media Economies Design Lab at the University of Colorado Boulder"
},
"stats": {
"titlePrefix": "From",
"titleEmphasis": "projects",
"titleSuffix": "to communities",
"asOfPrefix": "as of",
"openingStatement": {
"paragraph1": "Too many of our communities end up with rules nobody chose, run by whoever set up the group chat. Other people often do all the work so the first real disagreement turns into a painful split.",
"paragraph2": "Our communities deserve better."
},
"history": {
"heading": "The idea is old. Our first tries fell short",
"items": [
{
"value": "420M+",
"label": "open source projects",
"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"
"kind": "entry",
"date": "About 100 BCE",
"accent": "brand",
"body": [
{
"text": "One of the Dead Sea Scrolls sets out how to join a Jewish community, how members should treat one another and what happens when someone breaks the rules. Scholars call it the Community Rule. Medieval monasteries called their own governing documents a "
},
{ "text": "regula", "emphasis": true },
{ "text": ", a rule. Our name is a nod to both." }
]
},
{
"value": "24%",
"label": "year over year growth in open source",
"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"
"kind": "entry",
"date": "1972",
"accent": "brand",
"body": [
{
"text": "Jo Freeman, writing from inside the women's movement, argued that groups without written rules still have rules, just hidden ones. “The rules of decision-making must be open and available to everyone, and this can happen only if they are formalized.”"
}
]
},
{
"value": "45%",
"label": "adults in the US participate in community service",
"shapeVariant": "green",
"asOf": "2025",
"sourceHref": "https://www.americorps.gov/about/our-impact/volunteering-civic-life",
"sourceName": "AmeriCorps Volunteering and Civic Life in America"
"kind": "entry",
"date": "2020",
"accent": "brand",
"body": [
{
"text": "Nathan Schneider started the first CommunityRule at the lab in the mutual aid surge during Covid. It was a palette of templates, from dictatorship to various flavors of democracy, that any community could adapt and adopt. Mutual aid communities were forming in weeks, so the lab offered to help them use it, for free."
}
]
},
{
"value": "800+",
"label": "mutual aid groups in the U.S.",
"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"
"kind": "entry",
"date": "2021",
"accent": "brand",
"body": [
{
"text": "Cassandra Dana, Drew Hornbein, Vincent Russell and Nathan Schneider turned the templates into a small book, Community Rules. Nine structures to choose from, combine or react against."
}
]
},
{
"kind": "entry",
"date": "2022 to 2024",
"accent": "brand",
"body": [
{
"text": "The lab rebuilt it around modules with Deacon Rodda and Drew Hornbein, so a community could mix practices instead of picking one fixed model. Later, communities could log in, save a Rule and change it as they grew."
}
]
},
{
"kind": "callout",
"title": "We found that people came to read, and far fewer stayed to write. Many people tried the editor, often alone or in a workshop, and few communities are known to have adopted a Rule and kept it.",
"body": "Reading about governance was not the hard part. Something happened between the template and the room, and we did not know what."
},
{
"kind": "entry",
"date": "2026",
"accent": "positive",
"body": [
{
"text": "Two community fellows at the lab set out to learn what was holding people back and how to fix it. Dani Shipley designed a study, interviewed organizers and built prototypes of a new experience. In parallel, Andy DiLallo started rebuilding the tool around what organizers said."
}
]
}
]
},
"tripleTextBlock": {
"columns": [
"participantSelection": {
"heading": "We went looking for communities that had figured it out",
"body": "We talked with the people who hold communities together about how they actually do it: organizers from worker and housing co-ops, mutual aid networks, street medic crews and open source projects, in different countries and at very different sizes. We are still talking with them.",
"groups": [
"A mutual aid network that formed during a flood and never stopped",
"A decades-old worker co-op of co-ops started by staff",
"A community fridge organizer that serves a large metro area",
"An open source project with maintainers on three continents",
"A housing co-op on its second generation of members",
"A food share that has served every Saturday for at least nine years"
],
"footnote": "Names appear only for pilot communities"
},
"fourQuestions": {
"heading": "Across these conversations, the communities that lasted had settled four things early",
"cards": [
{
"title": "Share Leadership and Prevent Burnout",
"description": "Rotating roles and setting clear expectations for contributions can prevent a few people from taking on too much. A living document outlining responsibilities and group values helps distribute labor fairly and avoids misunderstandings.\n\nA transportation assistance group avoided burnout by introducing a weekly shift system, making participation more sustainable.",
"lgTitle": "Frameworks for Community Governance",
"lgDescription": "CommunityRule provides customizable templates that help communities define decision-making structures, leadership roles, and processes for collective action."
"tone": "yellow",
"question": "How do you talk?",
"line": "Where do discussions happen, how is information logged and what channels stay open to everyone?"
},
{
"title": "Establish Transparent, Inclusive Decision-Making",
"description": "Deciding early on how your group makes choices—whether consensus, majority vote, or delegation—can prevent confusion. Transparency and regular check-ins help avoid unspoken hierarchies and keep everyone engaged.\n\nA disaster relief group sped up decisions by adopting a “consensus-minus-one” model, preventing gridlock while maintaining group alignment.",
"lgTitle": "Empowering Self-Governance",
"lgDescription": "The platform enables communities, mutual aid groups, and open-source projects to foster transparency and accountability without relying on top-down authority."
"tone": "teal",
"question": "Who belongs?",
"line": "How does someone join the community, how do they take on roles and what are the boundaries of membership?"
},
{
"title": "Address Conflicts Before They Grow",
"description": "A basic conflict resolution process can prevent disputes from escalating. Clear guidelines for resource distribution reduce misunderstandings and maintain trust. Assume good faith and use structured discussions to resolve issues.\n\nMembers noticed frustration when some folks felt unheard in decisions about new projects. By anonymous feedback box, they created a space to address concerns early and make adjustments before conflicts escalated.",
"lgTitle": "Flexible & Open-Source Approach",
"lgDescription": "Users can modify existing governance templates or create their own, making it a versatile tool for diverse community needs, from co-ops to digital collectives."
"tone": "lavender",
"question": "How do you decide?",
"line": "Who has the authority to make decisions, what processes are used and how are agreements formalized?"
},
{
"tone": "red",
"question": "What happens when you disagree?",
"line": "What are the steps for peer mediation, conflict management and handling core value collisions?"
}
],
"wishHeading": "What did communities wish they had settled sooner?",
"wishParagraphs": [
"Through interviews, workshops and digital ethnography we built empathy with people that we hoped would use the tool. This research produced many insights.",
"We found that written rules can harden too, and they can favor whoever writes well and has the most time. So a good Rule stays short, names who holds the keys, from the bank account to the group chat, and says how it changes and how someone can leave."
],
"statsHeading": "What unwritten rules cost",
"statsSource": "From interviews with members of forty communities from April 2025 to February 2026.",
"stats": [
{
"value": "9 in 10",
"line": "had lost an organizer to burnout, usually with little to no notice."
},
{
"value": "2 in 3",
"line": "had never written down how they make hard or contentious decisions."
},
{
"value": "1 in 2",
"line": "had split, or nearly split, over a decision nobody knew how to make."
}
]
},
"structureBeforeCrisis": {
"title": "Get Structure Before Crisis",
"description": "Structure Before Crisis is a collection of organizing patterns that help mutual aid groups lay the groundwork to build new worlds.",
"buttonText": "Download Book",
"imageAlt": "Structure Before Crisis book cover"
"rebuildProcess": {
"heading": "We rebuilt the tool around what organizers told us",
"body": "Organizers told us the old tool was confusing. They could not see their options, and writing a whole Rule at once felt like too much. So we sketched each idea, put it in front of organizers, founders and co-op developers, ran a pilot with local mutual aid communities and co-ops and fixed whatever tripped people up. Now your options are laid out as cards other communities use. You pick the ones that fit and write your Rule one part at a time."
},
"communityRules": {
"title": "Get the Community Rules Book",
"description": "Community Rules is a simple tool to help make great communities even better and healthier. It includes nine templates for organizational structures that communities can choose from, combine, or react against.",
"buttonText": "Download Book",
"imageAlt": "Community Rules book cover"
"narrativeBlock": {
"heading": "What organizers gave, we give back",
"paragraphs": [
"The study exists because organizers gave their time and their stories. We commit to ensuring they receive the direct value of our collective knowledge first, with access to the findings before anyone else, alongside a free, dedicated governance workshop for their community.",
"What it produced is shared. The tool is free for every community, and its code is open source under the GNU General Public License version 3. The guides, the zines and the findings are published under CC BY-SA 4.0, so you can copy them, adapt them and republish them. A tool that asks your community to be transparent should not be a black box itself.",
"Some things are not shared. The notes from each conversation stay private, and nothing a community tells us is quoted or used without its permission. Send your members a link, or export it and publish it yourself. With a community's consent, and only then, what we learn in workshops goes back into the templates and guides for the next community.",
"Our templates output plain text markdown that you can commit right into your git repository next to your code."
],
"addHeading": "Add to it",
"addLine": "The patterns come from communities. Add yours.",
"shareExperiences": "Share your experiences",
"helpBuild": "Help build",
"helpBuildHref": "https://git.medlab.host"
},
"faq": {
"title": "Get answers to your questions",
"items": [
"yourTurn": {
"heading": "Every community has rules. Write yours down, together",
"body": "It is also practice. Each time a community settles something together, it gets better at governing itself. The tool is free for every community, and the kit is free for volunteer-run communities. Try it together, then tell us how your community really works, so the next version fits it better.",
"createRule": "Create rule",
"facilitator": "Talk to a facilitator",
"facilitatorHref": "/workshops",
"questionsLead": "Questions first?",
"faq": "Read the FAQ",
"faqHref": "/learn#faq",
"or": "or",
"askOrganizer": "ask an organizer",
"illustrationCaption": "[People sitting around a circular table discussing drafts]"
},
"credits": {
"heading": "Who made this",
"rows": [
{
"title": "What is CommunityRule, and who is it for?",
"answer": "CommunityRule helps groups write operating manuals that reflect their values and decision-making needs."
"role": "Started CommunityRule, advisor and guide",
"name": "Nathan Schneider"
},
{
"title": "How does CommunityRule help with governance?",
"answer": "Templates and guided prompts help teams document roles, decision processes, and conflict resolution before issues escalate."
"role": "Mixed-method research, experience design and product management",
"name": "Dani Shipley"
},
{
"title": "Do we need to have formal governance to use CommunityRule?",
"answer": "No. Many groups start with informal practices and use CommunityRule to clarify expectations as they grow."
"role": "Product engineering, design system workflow and infrastructure",
"name": "Andy DiLallo"
},
{
"title": "What kinds of templates does CommunityRule offer?",
"answer": "The library includes structures such as consensus clusters, elected boards, do-ocracy, and other community-tested models."
"role": "Earlier contributors",
"name": "Cassandra Dana, Drew Hornbein, Deacon Rodda, Vincent Russell, Nathan Schneider"
},
{
"title": "Is CommunityRule free to use?",
"answer": "Yes. You can explore templates and draft rules without a subscription."
"role": "Made at",
"name": "Media Economies Design Lab, University of Colorado Boulder"
}
]
},
"quote": {
"paragraph1": "Too many of our communities adopt default governance practices that rely on unchecked authority without even basic democratic features.",
"paragraph2": "Community Rule helps communities establish better norms for decision-making, stewardship, and culture."
}
}
@@ -0,0 +1,3 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector" d="M80 40L0 0L20.02 40L0 80L80 40ZM40.04 31.69C41.6735 31.6999 43.2674 32.1923 44.6213 33.1052C45.9752 34.0182 47.0285 35.3108 47.6485 36.8205C48.2686 38.3302 48.4277 39.9894 48.1059 41.5892C47.7841 43.189 46.9957 44.658 45.84 45.8111C44.6843 46.9643 43.2129 47.7501 41.6111 48.0697C40.0093 48.3893 38.3486 48.2283 36.8382 47.607C35.3278 46.9858 34.0351 45.932 33.1229 44.5784C32.2106 43.2248 31.7196 41.6318 31.7117 40C31.7064 38.9064 31.9183 37.8226 32.335 36.8113C32.7518 35.8 33.3652 34.8814 34.1398 34.1086C34.9143 33.3357 35.8346 32.724 36.8474 32.3089C37.8602 31.8937 38.9453 31.6834 40.04 31.69Z" fill="#FFFDD2"/>
</svg>

After

Width:  |  Height:  |  Size: 813 B

@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Group">
<path id="Vector" d="M40 80C62.0912 80 80 62.0912 80 40C80 17.9086 62.0912 0 40 0C17.9086 0 0 17.9086 0 40C0 62.0912 17.9086 80 40 80ZM40 57.5C49.6648 57.5 57.5 49.6648 57.5 40C57.5 30.335 49.6648 22.5 40 22.5C30.335 22.5 22.5 30.335 22.5 40C22.5 49.6648 30.335 57.5 40 57.5Z" fill-rule="evenodd" clip-rule="evenodd" fill="#F7D0DB"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 527 B

@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="282" height="282" viewBox="0 0 282 282" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Group">
<path id="Vector" d="M70.5 141C109.436 141 141 109.436 141 70.5C141 109.436 172.564 141 211.5 141C172.564 141 141 172.564 141 211.5C141 172.564 109.436 141 70.5 141ZM70.5 141C31.564 141 0 172.564 0 211.5C0 250.436 31.564 282 70.5 282C109.436 282 141 250.436 141 211.5C141 250.436 172.564 282 211.5 282C250.436 282 282 250.436 282 211.5C282 172.564 250.436 141 211.5 141C250.436 141 282 109.436 282 70.5C282 31.564 250.436 0 211.5 0C172.564 0 141 31.564 141 70.5C141 31.564 109.436 0 70.5 0C31.564 0 0 31.564 0 70.5C0 109.436 31.564 141 70.5 141Z" fill-rule="evenodd" clip-rule="evenodd" fill="#FFB6C2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 801 B

@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Group">
<path id="Vector" d="M139.997 139.994C220.001 220.002 -20.0011 220.002 60.0033 139.994C-20.0011 220.002 -20.0011 -19.9992 60.0033 59.9975C-20.0011 -19.9992 220.001 -19.9992 139.997 59.9975C220.001 -19.9992 220.001 220.002 139.997 139.994Z" fill="#C9CDFE"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 454 B

@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="334" height="334" viewBox="0 0 334 334" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Group">
<path id="Vector" d="M167 334C259.231 334 334 259.231 334 167C334 74.7684 259.231 0 167 0C74.7684 0 0 74.7684 0 167C0 259.231 74.7684 334 167 334ZM167 313.96C248.164 313.96 313.96 248.164 313.96 167C313.96 85.8362 248.164 20.04 167 20.04C85.8362 20.04 20.04 85.8362 20.04 167C20.04 248.164 85.8362 313.96 167 313.96ZM167 293.92C237.097 293.92 293.92 237.097 293.92 167C293.92 96.9041 237.097 40.08 167 40.08C96.9041 40.08 40.08 96.9041 40.08 167C40.08 237.097 96.9041 293.92 167 293.92ZM167 273.88C226.028 273.88 273.88 226.028 273.88 167C273.88 107.972 226.028 60.12 167 60.12C107.972 60.12 60.12 107.972 60.12 167C60.12 226.028 107.972 273.88 167 273.88ZM253.84 167C253.84 214.961 214.961 253.84 167 253.84C119.04 253.84 80.16 214.961 80.16 167C80.16 119.04 119.04 80.16 167 80.16C214.961 80.16 253.84 119.04 253.84 167ZM233.8 167C233.8 203.892 203.892 233.8 167 233.8C130.107 233.8 100.2 203.892 100.2 167C100.2 130.107 130.107 100.2 167 100.2C203.892 100.2 233.8 130.107 233.8 167Z" fill-rule="evenodd" clip-rule="evenodd" fill="#F6F06F"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+12 -3
View File
@@ -1,5 +1,4 @@
import FaqAccordion from "../../app/components/sections/Accordion";
import about from "../../messages/en/pages/about.json";
export default {
title: "Components/Sections/Accordion",
@@ -32,7 +31,17 @@ export default {
export const Default = {
args: {
title: about.faq.title,
items: about.faq.items,
title: "Get answers to your questions",
items: [
{
title: "What is CommunityRule, and who is it for?",
answer:
"CommunityRule helps groups write operating manuals that reflect their values and decision-making needs.",
},
{
title: "Is CommunityRule free to use?",
answer: "Yes. You can explore templates and draft rules without a subscription.",
},
],
},
};
+10 -13
View File
@@ -5,8 +5,6 @@ import {
governanceBookletPath,
structureBeforeCrisisPath,
} from "../../lib/assetUtils";
import messages from "../../messages/en/pages/about.json";
export default {
title: "Components/Sections/Book",
component: Book,
@@ -16,27 +14,26 @@ export default {
},
};
const structureBeforeCrisis = messages.structureBeforeCrisis;
const communityRules = messages.communityRules;
export const Default = {
args: {
title: structureBeforeCrisis.title,
description: structureBeforeCrisis.description,
buttonText: structureBeforeCrisis.buttonText,
title: "Get Structure Before Crisis",
description:
"Structure Before Crisis is a collection of organizing patterns that help mutual aid groups lay the groundwork to build new worlds.",
buttonText: "Download Book",
buttonHref: getAssetPath(structureBeforeCrisisPath()),
imageSrc: getAssetPath(ASSETS.STRUCTURE_BEFORE_CRISIS_COVER),
imageAlt: structureBeforeCrisis.imageAlt,
imageAlt: "Structure Before Crisis book cover",
},
};
export const CommunityRules = {
args: {
title: communityRules.title,
description: communityRules.description,
buttonText: communityRules.buttonText,
title: "Get the Community Rules Book",
description:
"Community Rules is a simple tool to help make great communities even better and healthier.",
buttonText: "Download Book",
buttonHref: getAssetPath(governanceBookletPath()),
imageSrc: getAssetPath(ASSETS.COMMUNITY_RULES_COVER),
imageAlt: communityRules.imageAlt,
imageAlt: "Community Rules book cover",
},
};
@@ -0,0 +1,12 @@
import CenteredHeader from "../../app/components/sections/CenteredHeader";
export default {
title: "Components/Sections/CenteredHeader",
component: CenteredHeader,
parameters: {
layout: "fullscreen",
backgrounds: { default: "dark" },
},
};
export const Default = {};
+12
View File
@@ -0,0 +1,12 @@
import Credits from "../../app/components/sections/Credits";
export default {
title: "Components/Sections/Credits",
component: Credits,
parameters: {
layout: "fullscreen",
backgrounds: { default: "dark" },
},
};
export const Default = {};
+12
View File
@@ -0,0 +1,12 @@
import FourQuestions from "../../app/components/sections/FourQuestions";
export default {
title: "Components/Sections/FourQuestions",
component: FourQuestions,
parameters: {
layout: "fullscreen",
backgrounds: { default: "dark" },
},
};
export const Default = {};
+12
View File
@@ -0,0 +1,12 @@
import History from "../../app/components/sections/History";
export default {
title: "Components/Sections/History",
component: History,
parameters: {
layout: "fullscreen",
backgrounds: { default: "dark" },
},
};
export const Default = {};
@@ -0,0 +1,12 @@
import NarrativeBlock from "../../app/components/sections/NarrativeBlock";
export default {
title: "Components/Sections/NarrativeBlock",
component: NarrativeBlock,
parameters: {
layout: "fullscreen",
backgrounds: { default: "dark" },
},
};
export const Default = {};
@@ -0,0 +1,12 @@
import ParticipantSelection from "../../app/components/sections/ParticipantSelection";
export default {
title: "Components/Sections/ParticipantSelection",
component: ParticipantSelection,
parameters: {
layout: "fullscreen",
backgrounds: { default: "dark" },
},
};
export const Default = {};
@@ -0,0 +1,12 @@
import RebuildProcess from "../../app/components/sections/RebuildProcess";
export default {
title: "Components/Sections/RebuildProcess",
component: RebuildProcess,
parameters: {
layout: "fullscreen",
backgrounds: { default: "dark" },
},
};
export const Default = {};
+12
View File
@@ -0,0 +1,12 @@
import YourTurn from "../../app/components/sections/YourTurn";
export default {
title: "Components/Sections/YourTurn",
component: YourTurn,
parameters: {
layout: "fullscreen",
backgrounds: { default: "dark" },
},
};
export const Default = {};
+26 -2
View File
@@ -1,5 +1,4 @@
import TripleTextBlock from "../../app/components/type/TripleTextBlock";
import messages from "../../messages/en/pages/about.json";
export default {
title: "Components/Type/TripleTextBlock",
@@ -9,6 +8,31 @@ export default {
export const Default = {
args: {
columns: messages.tripleTextBlock.columns,
columns: [
{
title: "Share Leadership and Prevent Burnout",
description:
"Rotating roles and setting clear expectations for contributions can prevent a few people from taking on too much.",
lgTitle: "Frameworks for Community Governance",
lgDescription:
"CommunityRule provides customizable templates that help communities define decision-making structures.",
},
{
title: "Establish Transparent, Inclusive Decision-Making",
description:
"Deciding early on how your group makes choices can prevent confusion.",
lgTitle: "Empowering Self-Governance",
lgDescription:
"The platform enables communities to foster transparency and accountability.",
},
{
title: "Address Conflicts Before They Grow",
description:
"A basic conflict resolution process can prevent disputes from escalating.",
lgTitle: "Flexible & Open-Source Approach",
lgDescription:
"Users can modify existing governance templates or create their own.",
},
],
},
};
+119 -60
View File
@@ -2,88 +2,147 @@ import { afterEach, describe, expect, test } from "vitest";
import { within } from "@testing-library/react";
import {
cleanup,
fireEvent,
renderWithProviders as render,
screen,
} from "../utils/test-utils";
import AboutPage from "../../app/(marketing)/about/page";
import {
ASSETS,
getAssetPath,
governanceBookletPath,
structureBeforeCrisisPath,
} from "../../lib/assetUtils";
import messages from "../../messages/en/index";
import { testRouter } from "../mocks/navigation";
afterEach(() => {
cleanup();
testRouter.push.mockClear();
});
function bookSection(title: string) {
const heading = screen.getByRole("heading", { name: title });
const section = heading.closest("section");
expect(section).not.toBeNull();
return within(section as HTMLElement);
}
describe("AboutPage", () => {
test("pairs each book cover, heading, alt, and download with the same title", () => {
test("opens with the centered header and the new opening statement", () => {
render(<AboutPage />);
const page = messages.pages.about;
const structureBeforeCrisis = bookSection(page.structureBeforeCrisis.title);
expect(
structureBeforeCrisis.getByText(page.structureBeforeCrisis.description),
screen.getByRole("heading", {
level: 1,
name: "Every community has rules written or not",
}),
).toBeInTheDocument();
expect(
structureBeforeCrisis.getByRole("img", {
name: page.structureBeforeCrisis.imageAlt,
screen.getByText(
"From the Media Economies Design Lab at the University of Colorado Boulder",
),
).toBeInTheDocument();
expect(
screen.getByText("Our communities deserve better."),
).toBeInTheDocument();
expect(
screen.queryByRole("heading", {
name: /CommunityRule is a tool that helps groups/i,
}),
).toHaveAttribute(
"src",
getAssetPath(ASSETS.STRUCTURE_BEFORE_CRISIS_COVER),
).not.toBeInTheDocument();
});
test("tells the study story without the old stats, books, or FAQ", () => {
render(<AboutPage />);
expect(
screen.getByRole("heading", {
name: "The idea is old. Our first tries fell short",
}),
).toBeInTheDocument();
expect(screen.getByText("regula").tagName).toBe("EM");
expect(
screen.getByText(/far fewer stayed to write/),
).toBeInTheDocument();
expect(
screen.getByRole("heading", {
name: "We went looking for communities that had figured it out",
}),
).toBeInTheDocument();
const participant = screen
.getByRole("heading", {
name: "We went looking for communities that had figured it out",
})
.closest("section");
expect(participant).not.toBeNull();
expect(
within(participant as HTMLElement).getByText(
"Names appear only for pilot communities",
),
).toBeInTheDocument();
expect(
within(participant as HTMLElement).queryByText(/Colorado/),
).not.toBeInTheDocument();
expect(screen.getByText("How do you talk?")).toBeInTheDocument();
expect(screen.getByText("What happens when you disagree?")).toBeInTheDocument();
expect(screen.getByText("9 in 10")).toBeInTheDocument();
expect(screen.getByText("2 in 3")).toBeInTheDocument();
expect(screen.getByText("1 in 2")).toBeInTheDocument();
expect(
screen.getByText(
"From interviews with members of forty communities from April 2025 to February 2026.",
),
).toBeInTheDocument();
expect(
screen.getByRole("heading", {
name: "We rebuilt the tool around what organizers told us",
}),
).toBeInTheDocument();
expect(screen.queryByText("420M+")).not.toBeInTheDocument();
expect(
screen.queryByRole("heading", { name: "Get Structure Before Crisis" }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("heading", { name: "Get the Community Rules Book" }),
).not.toBeInTheDocument();
expect(
screen.queryByRole("heading", { name: "Get answers to your questions" }),
).not.toBeInTheDocument();
});
test("keeps unapproved narrative sentences off the page and wires the approved actions", () => {
render(<AboutPage />);
expect(
screen.getByRole("heading", { name: "What organizers gave, we give back" }),
).toBeInTheDocument();
expect(screen.getByText(/GNU General Public License version 3/)).toBeInTheDocument();
expect(screen.getByText(/CC BY-SA 4.0/)).toBeInTheDocument();
expect(screen.queryByText(/CC0/)).not.toBeInTheDocument();
expect(screen.queryByText(/public domain/i)).not.toBeInTheDocument();
expect(screen.queryByText(/stays private unless you share/i)).not.toBeInTheDocument();
expect(screen.queryByText(/co-op lawyers/i)).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "Help build" })).toHaveAttribute(
"href",
"https://git.medlab.host",
);
expect(
structureBeforeCrisis.getByRole("link", {
name: page.structureBeforeCrisis.buttonText,
}),
).toHaveAttribute("href", getAssetPath(structureBeforeCrisisPath()));
screen.getByRole("link", { name: "Talk to a facilitator" }),
).toHaveAttribute("href", "/workshops");
expect(screen.getByRole("link", { name: "Read the FAQ" })).toHaveAttribute(
"href",
"/learn#faq",
);
const communityRules = bookSection(page.communityRules.title);
expect(
communityRules.getByText(page.communityRules.description),
).toBeInTheDocument();
expect(
communityRules.getByRole("img", {
name: page.communityRules.imageAlt,
}),
).toHaveAttribute("src", getAssetPath(ASSETS.COMMUNITY_RULES_COVER));
expect(
communityRules.getByRole("link", {
name: page.communityRules.buttonText,
}),
).toHaveAttribute("href", getAssetPath(governanceBookletPath()));
fireEvent.click(screen.getByRole("button", { name: "Create rule" }));
expect(testRouter.push).toHaveBeenCalledWith("/create/informational");
fireEvent.click(screen.getByRole("button", { name: "Share your experiences" }));
expect(screen.getByRole("dialog", { name: "Ask an organizer" })).toBeInTheDocument();
});
test("shows sourced About statistics with as-of years", () => {
test("names the people who made it", () => {
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);
}
}
expect(
screen.getByRole("heading", { name: "Who made this" }),
).toBeInTheDocument();
expect(screen.getByText("Nathan Schneider")).toBeInTheDocument();
expect(screen.getByText("Dani Shipley")).toBeInTheDocument();
expect(screen.getByText("Andy DiLallo")).toBeInTheDocument();
expect(
screen.getByText(
"Media Economies Design Lab, University of Colorado Boulder",
),
).toBeInTheDocument();
});
});