Update template cards

This commit is contained in:
adilallo
2026-05-22 13:36:23 -06:00
parent 753220f97b
commit 3dbb6b61d2
9 changed files with 141 additions and 129 deletions
+1 -1
View File
@@ -771,7 +771,7 @@
--color-surface-inverse-brand-accent: var(--color-yellow-yellow100); --color-surface-inverse-brand-accent: var(--color-yellow-yellow100);
--color-surface-inverse-brand-primary: var(--color-yellow-yellow50); --color-surface-inverse-brand-primary: var(--color-yellow-yellow50);
--color-surface-inverse-brand-secondary: var(--color-rust-rust300); --color-surface-inverse-brand-secondary: var(--color-yellow-yellow200);
--color-surface-inverse-primary: var(--color-gray-000); --color-surface-inverse-primary: var(--color-gray-000);
--color-surface-inverse-secondary: var(--color-gray-100); --color-surface-inverse-secondary: var(--color-gray-100);
--color-surface-inverse-tertiary: var(--color-gray-200); --color-surface-inverse-tertiary: var(--color-gray-200);
+5 -4
View File
@@ -140,13 +140,14 @@ const bySlug = new Map(
); );
/** /**
* Order for the home “Popular templates” row (four cards). Must match catalog slugs. * Order for the home “Popular templates” row (four cards).
* Figma Section/RuleStack [22083:855584](https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=22083-855584).
*/ */
export const GOVERNANCE_TEMPLATE_HOME_SLUGS: readonly string[] = [ export const GOVERNANCE_TEMPLATE_HOME_SLUGS: readonly string[] = [
"consensus-clusters",
"consensus", "consensus",
"elected-board", "do-ocracy",
"petition", "devolution",
"quadratic-governance",
]; ];
export function getGovernanceTemplatesForHome(): GovernanceTemplateCatalogEntry[] { export function getGovernanceTemplatesForHome(): GovernanceTemplateCatalogEntry[] {
+6 -6
View File
@@ -234,7 +234,7 @@ const TEMPLATES: {
description: description:
"Units called Circles have the ability to decide and act on matters in their domains, which their members agree on through a Council.", "Units called Circles have the ability to decide and act on matters in their domains, which their members agree on through a Council.",
sortOrder: 0, sortOrder: 0,
featured: true, featured: false,
body: bodyFromSlugComposition("consensus-clusters"), body: bodyFromSlugComposition("consensus-clusters"),
}, },
{ {
@@ -254,7 +254,7 @@ const TEMPLATES: {
description: description:
"An elected board determines policies and organizes their implementation.", "An elected board determines policies and organizes their implementation.",
sortOrder: 2, sortOrder: 2,
featured: true, featured: false,
body: bodyFromSlugComposition("elected-board"), body: bodyFromSlugComposition("elected-board"),
}, },
{ {
@@ -264,7 +264,7 @@ const TEMPLATES: {
description: description:
"Any participant can propose a rule change. If enough sign it, it goes to a general vote.", "Any participant can propose a rule change. If enough sign it, it goes to a general vote.",
sortOrder: 3, sortOrder: 3,
featured: true, featured: false,
body: bodyFromSlugComposition("petition"), body: bodyFromSlugComposition("petition"),
}, },
{ {
@@ -304,7 +304,7 @@ const TEMPLATES: {
description: description:
"Authority is granted to those doing the work. If you do the task, you decide how it gets done.", "Authority is granted to those doing the work. If you do the task, you decide how it gets done.",
sortOrder: 7, sortOrder: 7,
featured: false, featured: true,
body: bodyFromSlugComposition("do-ocracy"), body: bodyFromSlugComposition("do-ocracy"),
}, },
{ {
@@ -314,7 +314,7 @@ const TEMPLATES: {
description: description:
"Voting cost is squared (V²), preventing a majority from steamrolling a passionate minority.", "Voting cost is squared (V²), preventing a majority from steamrolling a passionate minority.",
sortOrder: 8, sortOrder: 8,
featured: false, featured: true,
body: bodyFromSlugComposition("quadratic-governance"), body: bodyFromSlugComposition("quadratic-governance"),
}, },
{ {
@@ -334,7 +334,7 @@ const TEMPLATES: {
description: description:
"Starts as a Dictatorship for speed, moving to a Board, and finally to full community ownership.", "Starts as a Dictatorship for speed, moving to a Board, and finally to full community ownership.",
sortOrder: 10, sortOrder: 10,
featured: false, featured: true,
body: bodyFromSlugComposition("devolution"), body: bodyFromSlugComposition("devolution"),
}, },
{ {
+12 -51
View File
@@ -1,5 +1,7 @@
import Rule from "../../app/components/cards/Rule"; import Rule from "../../app/components/cards/Rule";
import Image from "next/image"; import Image from "next/image";
import { getAssetPath } from "../../lib/assetUtils";
import { getGovernanceTemplatesForHome } from "../../lib/templates/governanceTemplateCatalog";
export default { export default {
title: "Components/Cards/Rule", title: "Components/Cards/Rule",
@@ -323,66 +325,25 @@ export const AllVariants = {
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
render: (_args) => ( render: (_args) => (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl">
{getGovernanceTemplatesForHome().map((entry) => (
<Rule <Rule
title="Consensus clusters" key={entry.slug}
description="Units called Circles have the ability to decide and act on matters in their domains, which their members agree on through a Council." title={entry.title}
backgroundColor="bg-[var(--color-surface-default-brand-lime)]" description={entry.description}
backgroundColor={entry.backgroundColor}
templateGridFigmaShell
icon={ icon={
<Image <Image
src="assets/template-mark/consensus-clusters.svg" src={getAssetPath(entry.iconPath)}
alt="Sociocracy" alt={entry.title}
width={40} width={40}
height={40} height={40}
className="md:w-[56px] md:h-[56px] lg:w-[90px] lg:h-[90px]" className="md:w-[56px] md:h-[56px] lg:w-[90px] lg:h-[90px]"
/> />
} }
onClick={() => console.log("Consensus clusters selected")} onClick={() => console.log(`${entry.slug} template selected`)}
/>
<Rule
title="Consensus"
description="Decisions that affect the group collectively should involve participation of all participants."
backgroundColor="bg-[var(--color-surface-default-brand-rust)]"
icon={
<Image
src="assets/template-mark/consensus.svg"
alt="Consensus"
width={40}
height={40}
className="md:w-[56px] md:h-[56px] lg:w-[90px] lg:h-[90px]"
/>
}
onClick={() => console.log("Consensus selected")}
/>
<Rule
title="Elected Board"
description="An elected board determines policies and organizes their implementation."
backgroundColor="bg-[var(--color-surface-default-brand-red)]"
icon={
<Image
src="assets/template-mark/elected-board.svg"
alt="Elected Board"
width={40}
height={40}
className="md:w-[56px] md:h-[56px] lg:w-[90px] lg:h-[90px]"
/>
}
onClick={() => console.log("Elected Board selected")}
/>
<Rule
title="Petition"
description="All participants can propose and vote on proposals for the group."
backgroundColor="bg-[var(--color-surface-default-brand-teal)]"
icon={
<Image
src="assets/template-mark/petition.svg"
alt="Petition"
width={40}
height={40}
className="md:w-[56px] md:h-[56px] lg:w-[90px] lg:h-[90px]"
/>
}
onClick={() => console.log("Petition selected")}
/> />
))}
</div> </div>
), ),
parameters: { parameters: {
+4 -4
View File
@@ -45,10 +45,10 @@ test.describe("Critical User Journeys", () => {
).toBeVisible(); ).toBeVisible();
// 6. User explores rule templates // 6. User explores rule templates
await page.locator("text=Circles").first().click(); await page.locator("text=Consensus").first().click();
await page.locator("text=Consensus").nth(1).click(); await page.locator("text=Do-ocracy").first().click();
await page.locator("text=Elected Board").first().click(); await page.locator("text=Devolution").first().click();
await page.locator("text=Petition").first().click(); await page.locator("text=Quadratic Governance").first().click();
// 7. User checks out features // 7. User checks out features
const features = [ const features = [
+9 -9
View File
@@ -75,16 +75,16 @@ describe("Page Flow Integration", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole("heading", { name: "Circles" })).toBeInTheDocument(); expect(screen.getByRole("heading", { name: "Consensus" })).toBeInTheDocument();
}); });
expect( expect(
screen.getByRole("heading", { name: "Elected Board" }), screen.getByRole("heading", { name: "Do-ocracy" }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.getByRole("heading", { name: "Consensus" }), screen.getByRole("heading", { name: "Devolution" }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.getByRole("heading", { name: "Petition" }), screen.getByRole("heading", { name: "Quadratic Governance" }),
).toBeInTheDocument(); ).toBeInTheDocument();
await waitFor(() => { await waitFor(() => {
@@ -153,12 +153,12 @@ describe("Page Flow Integration", () => {
renderPage(); renderPage();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Circles")).toBeInTheDocument(); expect(screen.getByText("Consensus")).toBeInTheDocument();
}); });
expect(screen.queryByText("Solidarity Network")).not.toBeInTheDocument(); expect(screen.queryByText("Solidarity Network")).not.toBeInTheDocument();
expect(screen.getByText("Elected Board")).toBeInTheDocument(); expect(screen.getByText("Do-ocracy")).toBeInTheDocument();
expect(screen.getByText("Consensus")).toBeInTheDocument(); expect(screen.getByText("Devolution")).toBeInTheDocument();
expect(screen.getByText("Petition")).toBeInTheDocument(); expect(screen.getByText("Quadratic Governance")).toBeInTheDocument();
const seeAll = screen.getByRole("link", { name: "See all templates" }); const seeAll = screen.getByRole("link", { name: "See all templates" });
expect(seeAll).toHaveAttribute("href", "/templates"); expect(seeAll).toHaveAttribute("href", "/templates");
@@ -228,7 +228,7 @@ describe("Page Flow Integration", () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Circles")).toBeInTheDocument(); expect(screen.getByText("Consensus")).toBeInTheDocument();
}); });
await waitFor(() => { await waitFor(() => {
+5 -5
View File
@@ -56,11 +56,11 @@ describe("User Journey Integration", () => {
renderPage(); renderPage();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Circles")).toBeInTheDocument();
});
expect(screen.getByText("Elected Board")).toBeInTheDocument();
expect(screen.getByText("Consensus")).toBeInTheDocument(); expect(screen.getByText("Consensus")).toBeInTheDocument();
expect(screen.getByText("Petition")).toBeInTheDocument(); });
expect(screen.getByText("Do-ocracy")).toBeInTheDocument();
expect(screen.getByText("Devolution")).toBeInTheDocument();
expect(screen.getByText("Quadratic Governance")).toBeInTheDocument();
const seeHowLinks = screen.getAllByRole("link", { const seeHowLinks = screen.getAllByRole("link", {
name: "See how it works", name: "See how it works",
@@ -195,7 +195,7 @@ describe("User Journey Integration", () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(screen.getAllByText(/Circles/i).length).toBeGreaterThan(0); expect(screen.getAllByText(/Consensus/i).length).toBeGreaterThan(0);
}); });
await waitFor(() => { await waitFor(() => {
+39 -40
View File
@@ -61,7 +61,7 @@ afterEach(() => {
async function waitForRuleStackCards() { async function waitForRuleStackCards() {
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Circles")).toBeInTheDocument(); expect(screen.getByText("Consensus")).toBeInTheDocument();
}); });
} }
@@ -70,7 +70,7 @@ describe("RuleStack Component", () => {
const fetchMock = vi.mocked(global.fetch); const fetchMock = vi.mocked(global.fetch);
const callsBefore = fetchMock.mock.calls.length; const callsBefore = fetchMock.mock.calls.length;
render(<RuleStack initialGridEntries={homeFeatured} />); render(<RuleStack initialGridEntries={homeFeatured} />);
expect(screen.getByText("Circles")).toBeInTheDocument(); expect(screen.getByText("Consensus")).toBeInTheDocument();
expect(fetchMock.mock.calls.length).toBe(callsBefore); expect(fetchMock.mock.calls.length).toBe(callsBefore);
}); });
@@ -120,20 +120,24 @@ describe("RuleStack Component", () => {
render(<RuleStack />); render(<RuleStack />);
await waitForRuleStackCards(); await waitForRuleStackCards();
expect(
screen.getByText(/Units called Circles have the ability to decide/),
).toBeInTheDocument();
expect( expect(
screen.getByText( screen.getByText(
/Important decisions require unanimous agreement\. Proposals pass only if no serious objections remain\./, /Important decisions require unanimous agreement\. Proposals pass only if no serious objections remain\./,
), ),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.getByText(/An elected board determines policies/), screen.getByText(
/Authority is granted to those doing the work\. If you do the task, you decide how it gets done\./,
),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.getByText( screen.getByText(
/Any participant can propose a rule change\. If enough sign it/, /Starts as a Dictatorship for speed, moving to a Board, and finally to full community ownership\./,
),
).toBeInTheDocument();
expect(
screen.getByText(
/Voting cost is squared \(V²\), preventing a majority from steamrolling a passionate minority\./,
), ),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@@ -143,24 +147,19 @@ describe("RuleStack Component", () => {
await waitForRuleStackCards(); await waitForRuleStackCards();
const imgs = container.querySelectorAll("img"); const imgs = container.querySelectorAll("img");
const circles = [...imgs].find((el) => {
const s = el.getAttribute("src") ?? "";
return (
s.includes("template-mark/consensus-clusters") ||
s.includes("template-mark%2Fconsensus-clusters")
);
});
const consensus = [...imgs].find((el) => { const consensus = [...imgs].find((el) => {
const s = el.getAttribute("src") ?? ""; const s = el.getAttribute("src") ?? "";
return ( return (
s.includes("consensus") && s.includes("template-mark/consensus") &&
!s.includes("consensus-clusters") && !s.includes("consensus-clusters")
!s.includes("elected") &&
!s.includes("petition")
); );
}); });
expect(circles).toBeTruthy(); const doOcracy = [...imgs].find((el) => {
const s = el.getAttribute("src") ?? "";
return s.includes("template-mark/do-ocracy");
});
expect(consensus).toBeTruthy(); expect(consensus).toBeTruthy();
expect(doOcracy).toBeTruthy();
}); });
test("renders see-all-templates link to full templates page", async () => { test("renders see-all-templates link to full templates page", async () => {
@@ -203,15 +202,15 @@ describe("RuleStack Component", () => {
render(<RuleStack />); render(<RuleStack />);
await waitForRuleStackCards(); await waitForRuleStackCards();
const circlesCard = screen
.getByText("Circles")
.closest('[class*="bg-[var(--color-surface-invert-brand-teal)]"]');
expect(circlesCard).toBeInTheDocument();
const consensusCard = screen const consensusCard = screen
.getByText("Consensus") .getByText("Consensus")
.closest('[class*="bg-[var(--color-surface-invert-positive-secondary)]"]'); .closest('[class*="bg-[var(--color-surface-invert-positive-secondary)]"]');
expect(consensusCard).toBeInTheDocument(); expect(consensusCard).toBeInTheDocument();
const doOcracyCard = screen
.getByText("Do-ocracy")
.closest('[class*="bg-[var(--color-surface-invert-brand-royal)]"]');
expect(doOcracyCard).toBeInTheDocument();
}); });
test("handles template click events for featured templates", async () => { test("handles template click events for featured templates", async () => {
@@ -284,31 +283,31 @@ describe("RuleStack Component", () => {
await waitForRuleStackCards(); await waitForRuleStackCards();
const imgs = container.querySelectorAll("img"); const imgs = container.querySelectorAll("img");
const circlesIcon = [...imgs].find((el) => { const doOcracyIcon = [...imgs].find((el) => {
const s = el.getAttribute("src") ?? ""; const s = el.getAttribute("src") ?? "";
return ( return (
s.includes("template-mark/consensus-clusters") || s.includes("template-mark/do-ocracy") ||
s.includes("template-mark%2Fconsensus-clusters") s.includes("template-mark%2Fdo-ocracy")
); );
}); });
expect(circlesIcon).toBeTruthy(); expect(doOcracyIcon).toBeTruthy();
expect(circlesIcon?.getAttribute("src")).toMatch( expect(doOcracyIcon?.getAttribute("src")).toMatch(
/template-mark(?:%2F|\/)consensus-clusters/, /template-mark(?:%2F|\/)do-ocracy/,
); );
expect(circlesIcon?.className).toMatch( expect(doOcracyIcon?.className).toMatch(
/min-\[640px\]:max-\[1023px\]:w-\[56px\]/, /min-\[640px\]:max-\[1023px\]:w-\[56px\]/,
); );
expect(circlesIcon?.className).toMatch( expect(doOcracyIcon?.className).toMatch(
/min-\[640px\]:max-\[1023px\]:h-\[56px\]/, /min-\[640px\]:max-\[1023px\]:h-\[56px\]/,
); );
expect(circlesIcon?.className).toMatch( expect(doOcracyIcon?.className).toMatch(
/min-\[1024px\]:max-\[1439px\]:w-\[90px\]/, /min-\[1024px\]:max-\[1439px\]:w-\[90px\]/,
); );
expect(circlesIcon?.className).toMatch( expect(doOcracyIcon?.className).toMatch(
/min-\[1024px\]:max-\[1439px\]:h-\[90px\]/, /min-\[1024px\]:max-\[1439px\]:h-\[90px\]/,
); );
expect(circlesIcon?.className).toMatch(/min-\[1440px\]:w-\[90px\]/); expect(doOcracyIcon?.className).toMatch(/min-\[1440px\]:w-\[90px\]/);
expect(circlesIcon?.className).toMatch(/min-\[1440px\]:h-\[90px\]/); expect(doOcracyIcon?.className).toMatch(/min-\[1440px\]:h-\[90px\]/);
}); });
test("applies different background colors to featured cards", async () => { test("applies different background colors to featured cards", async () => {
@@ -370,14 +369,14 @@ describe("RuleStack Component", () => {
render(<RuleStack />); render(<RuleStack />);
await waitForRuleStackCards(); await waitForRuleStackCards();
const electedBoardCard = screen.getByText("Elected Board").closest("div"); const doOcracyCard = screen.getByText("Do-ocracy").closest("div");
await user.click(electedBoardCard); await user.click(doOcracyCard);
expect(gtagSpy).toHaveBeenCalledWith("event", "template_click", { expect(gtagSpy).toHaveBeenCalledWith("event", "template_click", {
template_slug: "elected-board", template_slug: "do-ocracy",
}); });
expect(analyticsSpy).toHaveBeenCalledWith("Template Clicked", { expect(analyticsSpy).toHaveBeenCalledWith("Template Clicked", {
templateSlug: "elected-board", templateSlug: "do-ocracy",
}); });
}); });
}); });
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import {
GOVERNANCE_TEMPLATE_CATALOG,
GOVERNANCE_TEMPLATE_HOME_SLUGS,
getGovernanceTemplatesForHome,
} from "../../lib/templates/governanceTemplateCatalog";
/**
* Figma Community-Rule-System node 21764-16435 — Card / Rule template surfaces.
* Token names and hex fallbacks from Dev Mode (May 2026).
*/
const FIGMA_TEMPLATE_SURFACE_BY_SLUG: Record<string, string> = {
consensus: "--color-surface-invert-positive-secondary",
"consensus-clusters": "--color-surface-invert-brand-teal",
"solidarity-network": "--color-surface-invert-positive-primary",
"sortition-jury": "--color-surface-invert-brand-lavender",
"liquid-democracy": "--color-surface-invert-brand-kiwi",
"do-ocracy": "--color-surface-invert-brand-royal",
"quadratic-governance": "--color-surface-invert-brand-secondary",
"federated-clusters": "--color-surface-invert-brand-primary",
devolution: "--color-surface-invert-negative-secondary",
"benevolent-dictator": "--color-surface-invert-negative-primary",
petition: "--color-surface-invert-brand-teal",
"self-appointed-board": "--color-surface-invert-brand-rust",
"elected-board": "--color-surface-invert-warning-secondary",
};
describe("governanceTemplateCatalog (Figma 21764-16435)", () => {
it("maps every catalog slug to the Figma invert surface token", () => {
for (const entry of GOVERNANCE_TEMPLATE_CATALOG) {
const expected = FIGMA_TEMPLATE_SURFACE_BY_SLUG[entry.slug];
expect(expected, `missing Figma mapping for ${entry.slug}`).toBeTruthy();
expect(entry.backgroundColor).toBe(`bg-[var(${expected})]`);
}
});
it("covers all thirteen Figma template variants", () => {
expect(GOVERNANCE_TEMPLATE_CATALOG).toHaveLength(13);
expect(Object.keys(FIGMA_TEMPLATE_SURFACE_BY_SLUG)).toHaveLength(13);
});
it("orders the home RuleStack row per Figma 22083-855584", () => {
expect([...GOVERNANCE_TEMPLATE_HOME_SLUGS]).toEqual([
"consensus",
"do-ocracy",
"devolution",
"quadratic-governance",
]);
expect(getGovernanceTemplatesForHome()).toHaveLength(4);
});
});