(({ className = "" }) => {
);
-});
+ },
+);
RuleStackContainer.displayName = "RuleStack";
diff --git a/app/components/sections/RuleStack/RuleStack.types.ts b/app/components/sections/RuleStack/RuleStack.types.ts
index 62e0969..6e0248d 100644
--- a/app/components/sections/RuleStack/RuleStack.types.ts
+++ b/app/components/sections/RuleStack/RuleStack.types.ts
@@ -1,8 +1,17 @@
+import type { TemplateGridCardEntry } from "../../../../lib/templates/templateGridPresentation";
+
export interface RuleStackProps {
className?: string;
+ /**
+ * When set (e.g. from a Server Component), first paint uses this data and
+ * the client skips the `/api/templates` request.
+ */
+ initialGridEntries?: TemplateGridCardEntry[];
}
export interface RuleStackViewProps {
className: string;
onTemplateClick: (_slug: string) => void;
+ /** `null` while loading curated templates from the API. */
+ gridEntries: TemplateGridCardEntry[] | null;
}
diff --git a/app/components/sections/RuleStack/RuleStack.view.tsx b/app/components/sections/RuleStack/RuleStack.view.tsx
index 536d1e9..ce041f9 100644
--- a/app/components/sections/RuleStack/RuleStack.view.tsx
+++ b/app/components/sections/RuleStack/RuleStack.view.tsx
@@ -4,14 +4,13 @@ import { useTranslation } from "../../../contexts/MessagesContext";
import SectionHeader from "../SectionHeader";
import Button from "../../buttons/Button";
import { GovernanceTemplateGrid } from "../GovernanceTemplateGrid";
-import { getGovernanceTemplatesForHome } from "../../../../lib/templates/governanceTemplateCatalog";
+import { GovernanceTemplateGridSkeleton } from "../GovernanceTemplateGrid/GovernanceTemplateGridSkeleton";
import type { RuleStackViewProps } from "./RuleStack.types";
-const homeFeaturedTemplates = getGovernanceTemplatesForHome();
-
export function RuleStackView({
className,
onTemplateClick,
+ gridEntries,
}: RuleStackViewProps) {
const t = useTranslation("pages.home.ruleStack");
const buttonText = t("button.seeAllTemplates");
@@ -37,10 +36,14 @@ export function RuleStackView({
variant="multi-line"
/>
-
+ {gridEntries === null ? (
+
+ ) : (
+
+ )}
{
+ const ac = new AbortController();
let cancelled = false;
void (async () => {
if (!cancelled) {
setLoading(true);
setError(null);
}
- const result = await fetchTemplateBySlug(slug);
- if (cancelled) return;
- if (result === null) {
- setError(messages.create.templateReview.errors.notFound);
+ try {
+ const result = await fetchTemplateBySlug(slug, {
+ signal: ac.signal,
+ });
+ if (cancelled) return;
+ if (result === null) {
+ setError(messages.create.templateReview.errors.notFound);
+ setTemplate(null);
+ } else if ("error" in result) {
+ setError(result.error);
+ setTemplate(null);
+ } else {
+ setTemplate(result);
+ setError(null);
+ }
+ } catch (e) {
+ if (cancelled || isTemplatesFetchAborted(e)) return;
+ setError(messages.create.templateReview.errors.loadFailed);
setTemplate(null);
- } else if ("error" in result) {
- setError(result.error);
- setTemplate(null);
- } else {
- setTemplate(result);
- setError(null);
+ } finally {
+ if (!cancelled) setLoading(false);
}
- setLoading(false);
})();
return () => {
cancelled = true;
+ ac.abort();
};
}, [slug]);
diff --git a/lib/create/fetchTemplates.ts b/lib/create/fetchTemplates.ts
index 5252d09..3d2787e 100644
--- a/lib/create/fetchTemplates.ts
+++ b/lib/create/fetchTemplates.ts
@@ -9,16 +9,36 @@ export type RuleTemplateDto = {
category: string | null;
description: string | null;
body: unknown;
+ sortOrder: number;
featured: boolean;
};
type TemplatesResponse = { templates?: RuleTemplateDto[] };
-export async function fetchTemplates(): Promise<
- RuleTemplateDto[] | { error: string }
-> {
+export type FetchTemplatesOptions = {
+ signal?: AbortSignal;
+};
+
+function isAbortError(e: unknown): boolean {
+ return (
+ (e instanceof DOMException && e.name === "AbortError") ||
+ (e instanceof Error && e.name === "AbortError")
+ );
+}
+
+/** For callers that `catch` around `fetchTemplates` / `fetchTemplateBySlug`. */
+export function isTemplatesFetchAborted(e: unknown): boolean {
+ return isAbortError(e);
+}
+
+export async function fetchTemplates(
+ options?: FetchTemplatesOptions,
+): Promise {
try {
- const res = await fetch("/api/templates", { credentials: "include" });
+ const res = await fetch("/api/templates", {
+ credentials: "include",
+ signal: options?.signal,
+ });
const data = (await res.json()) as TemplatesResponse & { error?: string };
if (!res.ok) {
return {
@@ -29,15 +49,19 @@ export async function fetchTemplates(): Promise<
};
}
return Array.isArray(data.templates) ? data.templates : [];
- } catch {
+ } catch (e) {
+ if (isAbortError(e)) {
+ throw e;
+ }
return { error: "Could not load templates" };
}
}
export async function fetchTemplateBySlug(
slug: string,
+ options?: FetchTemplatesOptions,
): Promise {
- const result = await fetchTemplates();
+ const result = await fetchTemplates(options);
if ("error" in result) {
return result;
}
diff --git a/lib/server/ruleTemplates.ts b/lib/server/ruleTemplates.ts
new file mode 100644
index 0000000..ba2d8f5
--- /dev/null
+++ b/lib/server/ruleTemplates.ts
@@ -0,0 +1,30 @@
+import type { RuleTemplateDto } from "../create/fetchTemplates";
+import { prisma } from "./db";
+import { isDatabaseConfigured } from "./env";
+
+/**
+ * Curated templates for public list UIs (same query as GET /api/templates).
+ * Returns [] when the database is not configured or on query failure.
+ */
+export async function listRuleTemplatesFromDb(): Promise {
+ if (!isDatabaseConfigured()) {
+ return [];
+ }
+ try {
+ return await prisma.ruleTemplate.findMany({
+ orderBy: [{ featured: "desc" }, { sortOrder: "asc" }, { title: "asc" }],
+ select: {
+ id: true,
+ slug: true,
+ title: true,
+ category: true,
+ description: true,
+ body: true,
+ sortOrder: true,
+ featured: true,
+ },
+ });
+ } catch {
+ return [];
+ }
+}
diff --git a/lib/templates/templateGridPresentation.ts b/lib/templates/templateGridPresentation.ts
new file mode 100644
index 0000000..aa85095
--- /dev/null
+++ b/lib/templates/templateGridPresentation.ts
@@ -0,0 +1,103 @@
+import type { RuleTemplateDto } from "../create/fetchTemplates";
+import { templateSummaryFromBody } from "../create/templateReviewMapping";
+import type { GovernanceTemplateCatalogEntry } from "./governanceTemplateCatalog";
+import {
+ GOVERNANCE_TEMPLATE_CATALOG,
+ getGovernanceTemplateCatalogEntry,
+ governanceTemplateIconPath,
+} from "./governanceTemplateCatalog";
+
+/** Matches TemplateReviewCard when slug is absent from the Figma catalog. */
+export const TEMPLATE_GRID_FALLBACK_PRESENTATION = {
+ iconPath: governanceTemplateIconPath("consensus"),
+ backgroundColor: "bg-[var(--color-surface-invert-brand-teal)]",
+} as const;
+
+export type TemplateGridCardEntry = GovernanceTemplateCatalogEntry;
+
+function presentationForSlug(slug: string): Pick<
+ GovernanceTemplateCatalogEntry,
+ "iconPath" | "backgroundColor"
+> {
+ const catalog = getGovernanceTemplateCatalogEntry(slug);
+ return catalog ?? TEMPLATE_GRID_FALLBACK_PRESENTATION;
+}
+
+/**
+ * One grid card: API copy + Figma icon/surface from catalog (or fallback).
+ */
+export function ruleTemplateToGridEntry(template: RuleTemplateDto): TemplateGridCardEntry {
+ const pres = presentationForSlug(template.slug);
+ const description = templateSummaryFromBody(template.description, template.body);
+ return {
+ slug: template.slug,
+ title: template.title,
+ description,
+ iconPath: pres.iconPath,
+ backgroundColor: pres.backgroundColor,
+ };
+}
+
+const bySlug = (templates: RuleTemplateDto[]) =>
+ new Map(templates.map((t) => [t.slug, t] as const));
+
+/**
+ * Ordered subset for home: follow `slugOrder`; skip missing slugs.
+ */
+export function gridEntriesForSlugOrder(
+ templates: RuleTemplateDto[],
+ slugOrder: readonly string[],
+): TemplateGridCardEntry[] {
+ const map = bySlug(templates);
+ const out: TemplateGridCardEntry[] = [];
+ for (const slug of slugOrder) {
+ const t = map.get(slug);
+ if (t) out.push(ruleTemplateToGridEntry(t));
+ }
+ return out;
+}
+
+/**
+ * Home row: prefer API row per slug; if missing, use static Figma catalog entry.
+ */
+export function gridEntriesForSlugOrderWithCatalogFallback(
+ templates: RuleTemplateDto[],
+ slugOrder: readonly string[],
+): TemplateGridCardEntry[] {
+ const map = bySlug(templates);
+ const out: TemplateGridCardEntry[] = [];
+ for (const slug of slugOrder) {
+ const t = map.get(slug);
+ if (t) {
+ out.push(ruleTemplateToGridEntry(t));
+ continue;
+ }
+ const cat = getGovernanceTemplateCatalogEntry(slug);
+ if (cat) out.push(cat);
+ }
+ return out;
+}
+
+/**
+ * Full templates index: `featured` first, then `sortOrder`, then title.
+ */
+export function gridEntriesForFullCatalog(templates: RuleTemplateDto[]): TemplateGridCardEntry[] {
+ const withSort = [...templates].sort((a, b) => {
+ if (a.featured !== b.featured) return a.featured ? -1 : 1;
+ if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder;
+ return a.title.localeCompare(b.title);
+ });
+ return withSort.map(ruleTemplateToGridEntry);
+}
+
+/**
+ * Marketing `/templates`: use API order when rows exist; otherwise static catalog.
+ */
+export function gridEntriesForFullCatalogWithFallback(
+ templates: RuleTemplateDto[],
+): TemplateGridCardEntry[] {
+ if (templates.length === 0) {
+ return [...GOVERNANCE_TEMPLATE_CATALOG];
+ }
+ return gridEntriesForFullCatalog(templates);
+}
diff --git a/prisma/seed.ts b/prisma/seed.ts
index 4e30b8b..1986c42 100644
--- a/prisma/seed.ts
+++ b/prisma/seed.ts
@@ -6,7 +6,10 @@ import { PrismaClient, type Prisma } from "@prisma/client";
* DB titles/descriptions should stay aligned with `governanceTemplateCatalog.ts`.
* `body.sections` use the same category row labels as the final-review RuleCard
* (Values, Communication, Membership, Decision-making, Conflict management) so
- * template review matches that layout; `entries[].title` = chip labels, `body` = long text for documents.
+ * template review matches that layout; `entries[].title` = chip labels, `body` = supporting text.
+ * Chip titles per template are sourced from the product **Template Composition** workbook (xlsx column
+ * layout: Decision-making, Membership Policies, Values, Communication, Conflict Management), mapped in
+ * `COMPOSITION_BY_SLUG` below.
*/
/** Starter `body` for catalog templates — five category rows match template review / final-review layout. */
@@ -57,6 +60,165 @@ function governancePatternBody(coreValues: string): Prisma.InputJsonValue {
};
}
+/** Chip copy from Template Composition.xlsx (Decision-making, Membership, Values, Communication, Conflict). */
+const COMPOSITION_CHIP_BODY =
+ "Suggested focus for this governance area. Replace with your own language in the create flow.";
+
+function entriesFromCompositionCell(cell: string): { title: string; body: string }[] {
+ const trimmed = cell.trim();
+ if (!trimmed) return [];
+ return trimmed
+ .split(/,\s*/)
+ .map((title) => title.trim())
+ .filter(Boolean)
+ .map((title) => ({ title, body: COMPOSITION_CHIP_BODY }));
+}
+
+function bodyFromXlsxComposition(row: {
+ decisionMaking: string;
+ membership: string;
+ values: string;
+ communication: string;
+ conflict: string;
+}): Prisma.InputJsonValue {
+ return {
+ sections: [
+ { categoryName: "Values", entries: entriesFromCompositionCell(row.values) },
+ {
+ categoryName: "Communication",
+ entries: entriesFromCompositionCell(row.communication),
+ },
+ {
+ categoryName: "Membership",
+ entries: entriesFromCompositionCell(row.membership),
+ },
+ {
+ categoryName: "Decision-making",
+ entries: entriesFromCompositionCell(row.decisionMaking),
+ },
+ {
+ categoryName: "Conflict management",
+ entries: entriesFromCompositionCell(row.conflict),
+ },
+ ],
+ };
+}
+
+/**
+ * Curated template chip rows — sourced from product Template Composition.xlsx
+ * (Governance Template × category columns).
+ */
+const COMPOSITION_BY_SLUG: Record<
+ string,
+ {
+ decisionMaking: string;
+ membership: string;
+ values: string;
+ communication: string;
+ conflict: string;
+ }
+> = {
+ consensus: {
+ decisionMaking: "Consensus Decision-Making, Modified Consensus",
+ membership: "Consensus or Vote-Based Approval, Peer Sponsorship",
+ values: "Consensus, Community Care, Horizontalism",
+ communication: "In-Person Meetings, Loomio",
+ conflict: "Consensus Building, Facilitated Negotiation",
+ },
+ "consensus-clusters": {
+ decisionMaking: "Sociocracy, Holacracy",
+ membership: "Contribution Based, Orientation Required",
+ values: "Decentralization, Adaptability, Autonomy",
+ communication: "Slack, Matrix / Element",
+ conflict: "Circle Processes, Restorative Practices",
+ },
+ "solidarity-network": {
+ decisionMaking: "Do-ocracy, Modified Consensus",
+ membership: "Open Access, Peer Sponsorship",
+ values: "Solidarity, Mutual Aid, Anti-oppression",
+ communication: "Signal, Matrix / Element",
+ conflict: "Peer Mediation, Restorative Practices",
+ },
+ "sortition-jury": {
+ decisionMaking: "Lottery/Sortition, Deliberative Polling",
+ membership: "Lottery / Sortition",
+ values: "Fairness, Equity, Transparency",
+ communication: "In-Person Meetings, Video Meetings",
+ conflict: "Lottery/Sortition, Rotational Judging",
+ },
+ "liquid-democracy": {
+ decisionMaking: "Delegated Decision-Making, Continuous Voting",
+ membership: "Identity Verification, Open Access",
+ values: "Agency, Flexibility, Transparency",
+ communication: "Loomio, Discourse (Forum)",
+ conflict: "Ad Hoc Arbitration, Peer Mediation",
+ },
+ "do-ocracy": {
+ decisionMaking: "Do-ocracy, Lazy Consensus",
+ membership: "Contribution Based, Skill-Based Contribution",
+ values: "Agency, Autonomy, Voluntarism",
+ communication: "GitHub / GitLab, Discord",
+ conflict: "Peer Mediation, Facilitated Negotiation",
+ },
+ "quadratic-governance": {
+ decisionMaking: "Quadratic Voting",
+ membership: "Identity Verification, Pay-to-Join",
+ values: "Fairness, Innovation, Independence",
+ communication: "Discourse (Forum), Discord",
+ conflict: "Supermajority Vote, Conflict Resolution Council",
+ },
+ "federated-clusters": {
+ decisionMaking: "Consensus Seeking with Delegates",
+ membership: "Hybrid Approval Process, Membership Agreement or Pledge",
+ values: "Interoperability, Localism, Interdependence",
+ communication: "Matrix / Element, Discourse (Forum)",
+ conflict: "Internal Tribunal, Ad Hoc Arbitration",
+ },
+ devolution: {
+ decisionMaking: "Autocratic Decision-Making, Delegated Decision-Making",
+ membership: "Invitation Only, Open Access",
+ values: "Capacity Building, Education, Maintenance",
+ communication: "Discord, GitHub / GitLab",
+ conflict: "Conflict Workshops, Managerial Decision",
+ },
+ "benevolent-dictator": {
+ decisionMaking: "Autocratic Decision-Making, Hierarchical Decision-Making",
+ membership: "Invitation Only, Mentorship",
+ values: "Reliability, Stewardship, Leadership",
+ communication: "Email Distribution List, GitHub / GitLab",
+ conflict: "Managerial Decision, Binding Contracts",
+ },
+ petition: {
+ decisionMaking: "Ranked Choice Voting, Majority Rule",
+ membership: "Open Access, Identity Verification",
+ values: "Freedom of Information, Accountability, Participation",
+ communication: "Loomio, Discourse (Forum)",
+ conflict: "Supermajority Vote, Binding Arbitration",
+ },
+ "self-appointed-board": {
+ decisionMaking: "Advisory Committees, Executive Committees",
+ membership: "Invitation Only, Application & Review",
+ values: "Stewardship, Resilience, Reliability",
+ communication: "Video Meetings, Email Distribution List",
+ conflict: "Judicial Committees, Internal Tribunal",
+ },
+ "elected-board": {
+ decisionMaking: "Elected Board of Directors, Majority Rule",
+ membership: "Application & Review, Membership Agreement or Pledge",
+ values: "Accountability, Transparency, Trust",
+ communication: "Email Distribution List, Slack",
+ conflict: "Conflict Resolution Council, Mediation",
+ },
+};
+
+function bodyFromSlugComposition(slug: string): Prisma.InputJsonValue {
+ const row = COMPOSITION_BY_SLUG[slug];
+ if (!row) {
+ return governancePatternBody("Template composition pending.");
+ }
+ return bodyFromXlsxComposition(row);
+}
+
const TEMPLATES: {
slug: string;
title: string;
@@ -74,239 +236,27 @@ const TEMPLATES: {
"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,
featured: true,
- body: {
- sections: [
- {
- categoryName: "Values",
- entries: [
- {
- title: "Distributed authority",
- body: "Authority lives in Circles close to the work. Domains are explicit so power is visible and negotiable.",
- },
- {
- title: "Transparency",
- body: "Decisions, roles, and metrics that affect members are easy to find and updated regularly.",
- },
- {
- title: "Equivalence",
- body: "Policy affects people in proportion to their stake; no silent vetoes from outside a domain.",
- },
- ],
- },
- {
- categoryName: "Communication",
- entries: [
- {
- title: "Circle channels",
- body: "Each Circle maintains channels for async updates and synchronous sense-making.",
- },
- {
- title: "Council cadence",
- body: "The Council meets on a fixed rhythm to align strategy, resolve overlaps, and hear escalations.",
- },
- ],
- },
- {
- categoryName: "Membership",
- entries: [
- {
- title: "Circle membership",
- body: "People join Circles by agreement of that Circle and clarity on domain contribution.",
- },
- {
- title: "Link roles",
- body: "Members link Circles as delegates or representatives when decisions span domains.",
- },
- ],
- },
- {
- categoryName: "Decision-making",
- entries: [
- {
- title: "Consent within Circles",
- body: "Circles act when there is no reasoned objection from anyone in the Circle with a stake.",
- },
- {
- title: "Cross-domain consent",
- body: "When work spans Circles, proposals include impacted domains and integrate their concerns.",
- },
- ],
- },
- {
- categoryName: "Conflict management",
- entries: [
- {
- title: "Objection testing",
- body: "Objections must show how a proposal fails the aim or creates harm; the group integrates or adapts.",
- },
- {
- title: "Mediation",
- body: "Facilitators help parties hear each other before escalating to Council or broader processes.",
- },
- ],
- },
- ],
- },
+ body: bodyFromSlugComposition("consensus-clusters"),
},
{
slug: "consensus",
title: "Consensus",
category: "Governance pattern",
description:
- "Important decisions require broad agreement. Proposals move forward when serious objections are resolved and the group can stand behind the outcome.",
+ "Important decisions require unanimous agreement. Proposals pass only if no serious objections remain.",
sortOrder: 1,
featured: true,
- body: {
- sections: [
- {
- categoryName: "Values",
- entries: [
- {
- title: "Consciousness",
- body: "We make decisions with awareness of impact on people, ecosystems, and future generations.",
- },
- {
- title: "Ecology",
- body: "We design governance to reduce harm and regenerate the systems we depend on.",
- },
- {
- title: "Abundance",
- body: "We assume enough for needs when resources are shared fairly and waste is reduced.",
- },
- {
- title: "Art",
- body: "We leave room for creativity, culture, and expression in how we organize.",
- },
- {
- title: "Decisiveness",
- body: "We balance care with forward motion—clear timelines and roles prevent endless loops.",
- },
- ],
- },
- {
- categoryName: "Communication",
- entries: [
- {
- title: "Signal",
- body: "We use Signal (or equivalent) for sensitive coordination and timely member updates.",
- },
- ],
- },
- {
- categoryName: "Membership",
- entries: [
- {
- title: "Open Admission",
- body: "People who share our values and agree to practices can participate without unnecessary gatekeeping.",
- },
- ],
- },
- {
- categoryName: "Decision-making",
- entries: [
- {
- title: "Lazy Consensus",
- body: "Proposals advance if no blocking concern is raised within the discussion window.",
- },
- {
- title: "Modified Consensus",
- body: "For larger decisions we use structured consensus with documented objections and integration steps.",
- },
- ],
- },
- {
- categoryName: "Conflict management",
- entries: [
- {
- title: "Code of Conduct",
- body: "We uphold a code of conduct that sets expectations and pathways for accountability.",
- },
- {
- title: "Restorative Justice",
- body: "When harm occurs we prioritize repair, learning, and changed conditions over punishment.",
- },
- ],
- },
- ],
- },
+ body: bodyFromSlugComposition("consensus"),
},
{
slug: "elected-board",
title: "Elected Board",
category: "Governance pattern",
description:
- "Members elect a board to steward policy and operations. The board coordinates implementation and remains accountable through transparent reporting and elections.",
+ "An elected board determines policies and organizes their implementation.",
sortOrder: 2,
featured: true,
- body: {
- sections: [
- {
- categoryName: "Values",
- entries: [
- {
- title: "Accountability",
- body: "The board answers to the membership through elections, published decisions, and recall where applicable.",
- },
- {
- title: "Service",
- body: "Board service is a temporary duty with term limits and clarity on scope of authority.",
- },
- ],
- },
- {
- categoryName: "Communication",
- entries: [
- {
- title: "Board minutes",
- body: "Minutes summarize decisions, rationales, and next steps; members can access them on a regular cadence.",
- },
- {
- title: "Member forums",
- body: "The board hosts open sessions for questions, priorities, and feedback from the membership.",
- },
- ],
- },
- {
- categoryName: "Membership",
- entries: [
- {
- title: "Eligibility to vote",
- body: "Voting members are defined clearly; associate or advisory roles are distinguished from full votes.",
- },
- {
- title: "Board terms",
- body: "Staggered terms keep continuity while refreshing leadership on a predictable schedule.",
- },
- ],
- },
- {
- categoryName: "Decision-making",
- entries: [
- {
- title: "Board vote",
- body: "The board decides matters in its charter by majority or supermajority as specified.",
- },
- {
- title: "Member ratification",
- body: "Major structural changes require member approval according to your bylaws.",
- },
- ],
- },
- {
- categoryName: "Conflict management",
- entries: [
- {
- title: "Recusal",
- body: "Directors recuse themselves when personal or financial conflicts appear.",
- },
- {
- title: "Appeals",
- body: "Members can appeal board decisions through a defined, fair process.",
- },
- ],
- },
- ],
- },
+ body: bodyFromSlugComposition("elected-board"),
},
{
slug: "petition",
@@ -316,75 +266,7 @@ const TEMPLATES: {
"Any participant can propose a rule change. If enough sign it, it goes to a general vote.",
sortOrder: 3,
featured: true,
- body: {
- sections: [
- {
- categoryName: "Values",
- entries: [
- {
- title: "Open participation",
- body: "Legitimate voices can bring proposals without needing informal gatekeepers.",
- },
- {
- title: "Legitimacy",
- body: "Outcomes are trusted when process, quorum, and notice rules are followed consistently.",
- },
- ],
- },
- {
- categoryName: "Communication",
- entries: [
- {
- title: "Discussion period",
- body: "Every proposal has a visible discussion window before voting closes.",
- },
- {
- title: "Announcements",
- body: "Calls to vote and results are posted where all participants can see them.",
- },
- ],
- },
- {
- categoryName: "Membership",
- entries: [
- {
- title: "Voting pool",
- body: "Who may vote is explicit (e.g. members in good standing for 30 days).",
- },
- {
- title: "Quorum",
- body: "Votes count only when quorum is met so decisions reflect an engaged subset.",
- },
- ],
- },
- {
- categoryName: "Decision-making",
- entries: [
- {
- title: "Petition threshold",
- body: "Sponsors or seconders may be required so proposals show a minimal base of support.",
- },
- {
- title: "Majority rules",
- body: "Adoption thresholds (simple majority, supermajority) are defined per decision type.",
- },
- ],
- },
- {
- categoryName: "Conflict management",
- entries: [
- {
- title: "Good faith",
- body: "Debate focuses on substance; harassment or bad-faith tactics are addressed under conduct policies.",
- },
- {
- title: "Ombuds",
- body: "A neutral contact helps people navigate disputes about process or interpretation.",
- },
- ],
- },
- ],
- },
+ body: bodyFromSlugComposition("petition"),
},
{
slug: "solidarity-network",
@@ -394,9 +276,7 @@ const TEMPLATES: {
'Power is held by autonomous "cells." A central hub acts as a switchboard for resources but cannot dictate cell activities.',
sortOrder: 4,
featured: false,
- body: governancePatternBody(
- 'Power is held by autonomous "cells." A central hub acts as a switchboard for resources but cannot dictate cell activities.',
- ),
+ body: bodyFromSlugComposition("solidarity-network"),
},
{
slug: "sortition-jury",
@@ -406,9 +286,7 @@ const TEMPLATES: {
"A representative sample of the community is chosen by lottery to form a temporary council.",
sortOrder: 5,
featured: false,
- body: governancePatternBody(
- "A representative sample of the community is chosen by lottery to form a temporary council.",
- ),
+ body: bodyFromSlugComposition("sortition-jury"),
},
{
slug: "liquid-democracy",
@@ -418,9 +296,7 @@ const TEMPLATES: {
"Members can vote directly or delegate their vote to a trusted peer on a per-topic basis.",
sortOrder: 6,
featured: false,
- body: governancePatternBody(
- "Members can vote directly or delegate their vote to a trusted peer on a per-topic basis.",
- ),
+ body: bodyFromSlugComposition("liquid-democracy"),
},
{
slug: "do-ocracy",
@@ -430,9 +306,7 @@ const TEMPLATES: {
"Authority is granted to those doing the work. If you do the task, you decide how it gets done.",
sortOrder: 7,
featured: false,
- body: governancePatternBody(
- "Authority is granted to those doing the work. If you do the task, you decide how it gets done.",
- ),
+ body: bodyFromSlugComposition("do-ocracy"),
},
{
slug: "quadratic-governance",
@@ -442,9 +316,7 @@ const TEMPLATES: {
"Voting cost is squared (V²), preventing a majority from steamrolling a passionate minority.",
sortOrder: 8,
featured: false,
- body: governancePatternBody(
- "Voting cost is squared (V²), preventing a majority from steamrolling a passionate minority.",
- ),
+ body: bodyFromSlugComposition("quadratic-governance"),
},
{
slug: "federated-clusters",
@@ -454,9 +326,7 @@ const TEMPLATES: {
"Independent groups share a central brand/charter but have total autonomy over internal rules.",
sortOrder: 9,
featured: false,
- body: governancePatternBody(
- "Independent groups share a central brand/charter but have total autonomy over internal rules.",
- ),
+ body: bodyFromSlugComposition("federated-clusters"),
},
{
slug: "devolution",
@@ -466,9 +336,7 @@ const TEMPLATES: {
"Starts as a Dictatorship for speed, moving to a Board, and finally to full community ownership.",
sortOrder: 10,
featured: false,
- body: governancePatternBody(
- "Starts as a Dictatorship for speed, moving to a Board, and finally to full community ownership.",
- ),
+ body: bodyFromSlugComposition("devolution"),
},
{
slug: "benevolent-dictator",
@@ -478,9 +346,7 @@ const TEMPLATES: {
"A single individual holds ultimate power, usually intended as a temporary state until the project is stable.",
sortOrder: 11,
featured: false,
- body: governancePatternBody(
- "A single individual holds ultimate power, usually intended as a temporary state until the project is stable.",
- ),
+ body: bodyFromSlugComposition("benevolent-dictator"),
},
{
slug: "self-appointed-board",
@@ -490,9 +356,7 @@ const TEMPLATES: {
"An existing board selects its own successors to preserve a specific mission over time.",
sortOrder: 12,
featured: false,
- body: governancePatternBody(
- "An existing board selects its own successors to preserve a specific mission over time.",
- ),
+ body: bodyFromSlugComposition("self-appointed-board"),
},
];
diff --git a/stories/sections/RuleStack.stories.js b/stories/sections/RuleStack.stories.js
index 1da655d..e554a37 100644
--- a/stories/sections/RuleStack.stories.js
+++ b/stories/sections/RuleStack.stories.js
@@ -1,8 +1,44 @@
+import React from "react";
import RuleStack from "../../app/components/sections/RuleStack";
+import { GOVERNANCE_TEMPLATE_CATALOG } from "../../lib/templates/governanceTemplateCatalog";
+
+function buildStoryTemplatesPayload() {
+ return GOVERNANCE_TEMPLATE_CATALOG.map((c, i) => ({
+ id: `story-${c.slug}`,
+ slug: c.slug,
+ title: c.title,
+ category: "Governance pattern",
+ description: c.description,
+ body: { sections: [] },
+ sortOrder: i,
+ featured: i < 4,
+ }));
+}
+
+function withMockTemplatesApi(Story) {
+ React.useLayoutEffect(() => {
+ const prev = global.fetch;
+ global.fetch = async (input, init) => {
+ const url = typeof input === "string" ? input : input.url;
+ if (String(url).includes("/api/templates")) {
+ return new Response(
+ JSON.stringify({ templates: buildStoryTemplatesPayload() }),
+ { status: 200, headers: { "Content-Type": "application/json" } },
+ );
+ }
+ return prev(input, init);
+ };
+ return () => {
+ global.fetch = prev;
+ };
+ }, []);
+ return ;
+}
export default {
title: "Components/Sections/RuleStack",
component: RuleStack,
+ decorators: [withMockTemplatesApi],
parameters: {
layout: "fullscreen",
docs: {
diff --git a/tests/unit/RuleStack.test.jsx b/tests/unit/RuleStack.test.jsx
index b6456b5..70fa471 100644
--- a/tests/unit/RuleStack.test.jsx
+++ b/tests/unit/RuleStack.test.jsx
@@ -2,6 +2,7 @@ import {
renderWithProviders as render,
screen,
cleanup,
+ waitFor,
} from "../utils/test-utils";
import userEvent from "@testing-library/user-event";
import { vi, describe, test, expect, afterEach, beforeEach } from "vitest";
@@ -10,22 +11,70 @@ import RuleStack from "../../app/components/sections/RuleStack";
import { testRouter } from "../mocks/navigation";
import {
GOVERNANCE_TEMPLATE_CATALOG,
+ GOVERNANCE_TEMPLATE_HOME_SLUGS,
getGovernanceTemplatesForHome,
} from "../../lib/templates/governanceTemplateCatalog";
const homeFeatured = getGovernanceTemplatesForHome();
+function mockTemplatesApiSuccess() {
+ const templatesPayload = GOVERNANCE_TEMPLATE_HOME_SLUGS.map((slug, i) => {
+ const cat = GOVERNANCE_TEMPLATE_CATALOG.find((e) => e.slug === slug);
+ if (!cat) throw new Error(`missing catalog slug ${slug}`);
+ return {
+ id: `test-${slug}`,
+ slug,
+ title: cat.title,
+ category: "Governance pattern",
+ description: cat.description,
+ body: { sections: [] },
+ sortOrder: i,
+ featured: true,
+ };
+ });
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input) => {
+ const url = typeof input === "string" ? input : input.url;
+ if (url.endsWith("/api/templates")) {
+ return new Response(JSON.stringify({ templates: templatesPayload }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ return new Response("Not Found", { status: 404 });
+ }),
+ );
+}
+
beforeEach(() => {
testRouter.push.mockClear();
+ mockTemplatesApiSuccess();
});
afterEach(() => {
+ vi.unstubAllGlobals();
cleanup();
});
+async function waitForRuleStackCards() {
+ await waitFor(() => {
+ expect(screen.getByText("Circles")).toBeInTheDocument();
+ });
+}
+
describe("RuleStack Component", () => {
- test("renders four featured governance template cards on the home row", () => {
+ test("skips client fetch when initialGridEntries is provided (SSR path)", () => {
+ const fetchMock = vi.mocked(global.fetch);
+ const callsBefore = fetchMock.mock.calls.length;
+ render();
+ expect(screen.getByText("Circles")).toBeInTheDocument();
+ expect(fetchMock.mock.calls.length).toBe(callsBefore);
+ });
+
+ test("renders four featured governance template cards on the home row", async () => {
render();
+ await waitForRuleStackCards();
for (const entry of homeFeatured) {
expect(screen.getByText(entry.title)).toBeInTheDocument();
@@ -38,15 +87,17 @@ describe("RuleStack Component", () => {
).not.toBeInTheDocument();
});
- test("renders with custom className", () => {
+ test("renders with custom className", async () => {
render();
+ await waitForRuleStackCards();
const section = document.querySelector("section");
expect(section).toHaveClass("custom-class");
});
- test("renders sample rule card descriptions from featured catalog", () => {
+ test("renders sample rule card descriptions from featured catalog", async () => {
render();
+ await waitForRuleStackCards();
expect(
screen.getByText(/Units called Circles have the ability to decide/),
@@ -66,8 +117,9 @@ describe("RuleStack Component", () => {
).toBeInTheDocument();
});
- test("renders rule card icons with image assets", () => {
+ test("renders rule card icons with image assets", async () => {
const { container } = render();
+ await waitForRuleStackCards();
const imgs = container.querySelectorAll("img");
const circles = [...imgs].find((el) => {
@@ -90,23 +142,26 @@ describe("RuleStack Component", () => {
expect(consensus).toBeTruthy();
});
- test("renders see-all-templates link to full templates page", () => {
+ test("renders see-all-templates link to full templates page", async () => {
render();
+ await waitForRuleStackCards();
const link = screen.getByRole("link", { name: "See all templates" });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute("href", "/templates");
});
- test("applies correct CSS classes", () => {
+ test("applies correct CSS classes", async () => {
render();
+ await waitForRuleStackCards();
const section = document.querySelector("section");
expect(section).toHaveClass("w-full", "bg-transparent");
});
- test("renders with design tokens", () => {
+ test("renders with design tokens", async () => {
render();
+ await waitForRuleStackCards();
const section = document.querySelector("section");
expect(section).toHaveClass("px-[20px]", "py-[32px]");
@@ -114,15 +169,17 @@ describe("RuleStack Component", () => {
expect(section?.className).toMatch(/min-\[640px\]:py-\[48px\]/);
});
- test("applies responsive grid layout", () => {
+ test("applies responsive grid layout", async () => {
render();
+ await waitForRuleStackCards();
const grid = document.querySelector('[class*="flex flex-col gap-[18px]"]');
expect(grid).toHaveClass("min-[768px]:grid", "min-[768px]:grid-cols-2");
});
- test("renders RuleCard components with catalog surface colors", () => {
+ test("renders RuleCard components with catalog surface colors", async () => {
render();
+ await waitForRuleStackCards();
const circlesCard = screen
.getByText("Circles")
@@ -142,6 +199,7 @@ describe("RuleStack Component", () => {
.mockImplementation(() => undefined);
render();
+ await waitForRuleStackCards();
const consensusCard = screen.getByText("Consensus").closest("div");
await user.click(consensusCard);
@@ -154,8 +212,9 @@ describe("RuleStack Component", () => {
debugSpy.mockRestore();
});
- test("renders with proper semantic structure", () => {
+ test("renders with proper semantic structure", async () => {
render();
+ await waitForRuleStackCards();
const section = document.querySelector("section");
expect(section).toBeInTheDocument();
@@ -164,16 +223,18 @@ describe("RuleStack Component", () => {
expect(headings).toHaveLength(1 + homeFeatured.length);
});
- test("applies responsive spacing", () => {
+ test("applies responsive spacing", async () => {
render();
+ await waitForRuleStackCards();
const section = document.querySelector("section");
expect(section?.className).toMatch(/min-\[640px\]:py-\[48px\]/);
expect(section?.className).toMatch(/min-\[1024px\]:py-\[64px\]/);
});
- test("renders icons with correct attributes", () => {
+ test("renders icons with correct attributes", async () => {
const { container } = render();
+ await waitForRuleStackCards();
const imgs = container.querySelectorAll("img");
const circlesIcon = [...imgs].find((el) => {
@@ -197,8 +258,9 @@ describe("RuleStack Component", () => {
expect(circlesIcon?.className).toMatch(/min-\[1440px\]:h-\[90px\]/);
});
- test("applies different background colors to featured cards", () => {
+ test("applies different background colors to featured cards", async () => {
render();
+ await waitForRuleStackCards();
const buttons = document.querySelectorAll('[role="button"]');
const templateSurfaces = [...buttons].filter((el) =>
@@ -207,16 +269,18 @@ describe("RuleStack Component", () => {
expect(templateSurfaces.length).toBe(homeFeatured.length);
});
- test("renders with proper see-all link styling", () => {
+ test("renders with proper see-all link styling", async () => {
render();
+ await waitForRuleStackCards();
const link = screen.getByRole("link", { name: "See all templates" });
expect(link?.className).toMatch(/bg-transparent/);
expect(link?.className).toMatch(/border/);
});
- test("applies flex layout for see-all link container", () => {
+ test("applies flex layout for see-all link container", async () => {
render();
+ await waitForRuleStackCards();
const linkContainer = screen
.getByRole("link", { name: "See all templates" })
@@ -224,6 +288,18 @@ describe("RuleStack Component", () => {
expect(linkContainer).toHaveClass("flex", "justify-center");
});
+ test("falls back to static catalog when templates API errors", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => new Response("Server error", { status: 500 })),
+ );
+ render();
+ await waitForRuleStackCards();
+ for (const entry of homeFeatured) {
+ expect(screen.getByText(entry.title)).toBeInTheDocument();
+ }
+ });
+
test("handles analytics tracking", async () => {
const user = userEvent.setup();
const gtagSpy = vi.fn();
@@ -239,6 +315,7 @@ describe("RuleStack Component", () => {
});
render();
+ await waitForRuleStackCards();
const electedBoardCard = screen.getByText("Elected Board").closest("div");
await user.click(electedBoardCard);