Load rule templates from API

This commit is contained in:
adilallo
2026-04-12 21:56:34 -06:00
parent cae4df261e
commit a39b4aa04b
17 changed files with 698 additions and 429 deletions
@@ -0,0 +1,23 @@
import dynamic from "next/dynamic";
import { listRuleTemplatesFromDb } from "../../lib/server/ruleTemplates";
import { GOVERNANCE_TEMPLATE_HOME_SLUGS } from "../../lib/templates/governanceTemplateCatalog";
import { gridEntriesForSlugOrderWithCatalogFallback } from "../../lib/templates/templateGridPresentation";
const RuleStack = dynamic(() => import("../components/sections/RuleStack"), {
loading: () => (
<section className="py-[var(--spacing-scale-032)] min-h-[400px]" />
),
ssr: true,
});
/**
* Server-loaded “Popular templates” row so the first paint has card data without a client fetch.
*/
export async function MarketingRuleStackSection() {
const rows = await listRuleTemplatesFromDb();
const initialGridEntries = gridEntriesForSlugOrderWithCatalogFallback(
rows,
GOVERNANCE_TEMPLATE_HOME_SLUGS,
);
return <RuleStack initialGridEntries={initialGridEntries} />;
}
+9 -8
View File
@@ -1,8 +1,10 @@
import dynamic from "next/dynamic";
import { Suspense } from "react";
import messages from "../../messages/en/index";
import { getTranslation } from "../../lib/i18n/getTranslation";
import HeroBanner from "../components/sections/HeroBanner";
import AskOrganizer from "../components/sections/AskOrganizer";
import { MarketingRuleStackSection } from "./MarketingRuleStackSection";
// Code split below-the-fold components to reduce initial bundle size
const LogoWall = dynamic(() => import("../components/sections/LogoWall"), {
@@ -22,13 +24,6 @@ const NumberedCards = dynamic(
},
);
const RuleStack = dynamic(() => import("../components/sections/RuleStack"), {
loading: () => (
<section className="py-[var(--spacing-scale-032)] min-h-[400px]" />
),
ssr: true,
});
const FeatureGrid = dynamic(
() => import("../components/sections/FeatureGrid"),
{
@@ -98,7 +93,13 @@ export default function Page() {
<HeroBanner {...heroBannerData} />
<LogoWall />
<NumberedCards {...numberedCardsData} />
<RuleStack />
<Suspense
fallback={
<section className="py-[var(--spacing-scale-032)] min-h-[400px]" />
}
>
<MarketingRuleStackSection />
</Suspense>
<FeatureGrid {...featureGridData} />
<QuoteBlock />
<AskOrganizer {...askOrganizerData} />
@@ -0,0 +1,54 @@
"use client";
import { useRouter } from "next/navigation";
import HeaderLockup from "../../components/type/HeaderLockup";
import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid";
import type { TemplateGridCardEntry } from "../../../lib/templates/templateGridPresentation";
import { useTranslation } from "../../contexts/MessagesContext";
export interface TemplatesPageClientProps {
initialGridEntries: TemplateGridCardEntry[];
}
/**
* Full templates index — Figma 22142-898446 (title, intro, 2-col card grid).
* `initialGridEntries` is computed on the server to avoid a client-side loading flash.
*/
export default function TemplatesPageClient({
initialGridEntries,
}: TemplatesPageClientProps) {
const router = useRouter();
const t = useTranslation("pages.templates");
return (
<div className="w-full bg-black text-[var(--color-content-default-primary,white)]">
<div
className="
mx-auto w-full max-w-[1280px]
px-[20px] py-[32px]
min-[640px]:px-[32px] min-[640px]:py-[40px]
min-[1024px]:px-[64px] min-[1024px]:py-[48px]
"
>
<div className="flex w-full flex-col gap-2 py-3">
<HeaderLockup
title={t("title")}
description={t("subtitle")}
justification="left"
size="L"
/>
</div>
<div className="mt-6 min-[1024px]:mt-8">
<GovernanceTemplateGrid
entries={initialGridEntries}
onTemplateClick={(slug) => {
router.push(
`/create/review-template/${encodeURIComponent(slug)}`,
);
}}
/>
</div>
</div>
</div>
);
}
+7 -45
View File
@@ -1,47 +1,9 @@
"use client";
import { listRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates";
import { gridEntriesForFullCatalogWithFallback } from "../../../lib/templates/templateGridPresentation";
import TemplatesPageClient from "./TemplatesPageClient";
import { useRouter } from "next/navigation";
import HeaderLockup from "../../components/type/HeaderLockup";
import { GovernanceTemplateGrid } from "../../components/sections/GovernanceTemplateGrid";
import { GOVERNANCE_TEMPLATE_CATALOG } from "../../../lib/templates/governanceTemplateCatalog";
import { useTranslation } from "../../contexts/MessagesContext";
/**
* Full templates index — Figma 22142-898446 (title, intro, 2-col card grid).
*/
export default function TemplatesPage() {
const router = useRouter();
const t = useTranslation("pages.templates");
return (
<div className="w-full bg-black text-[var(--color-content-default-primary,white)]">
<div
className="
mx-auto w-full max-w-[1280px]
px-[20px] py-[32px]
min-[640px]:px-[32px] min-[640px]:py-[40px]
min-[1024px]:px-[64px] min-[1024px]:py-[48px]
"
>
<div className="flex w-full flex-col gap-2 py-3">
<HeaderLockup
title={t("title")}
description={t("subtitle")}
justification="left"
size="L"
/>
</div>
<div className="mt-6 min-[1024px]:mt-8">
<GovernanceTemplateGrid
entries={GOVERNANCE_TEMPLATE_CATALOG}
onTemplateClick={(slug) => {
router.push(
`/create/review-template/${encodeURIComponent(slug)}`,
);
}}
/>
</div>
</div>
</div>
);
export default async function TemplatesPage() {
const rows = await listRuleTemplatesFromDb();
const initialGridEntries = gridEntriesForFullCatalogWithFallback(rows);
return <TemplatesPageClient initialGridEntries={initialGridEntries} />;
}
+2 -13
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { prisma } from "../../../lib/server/db";
import { isDatabaseConfigured } from "../../../lib/server/env";
import { listRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates";
import { dbUnavailable } from "../../../lib/server/responses";
/**
@@ -11,18 +11,7 @@ export async function GET() {
return dbUnavailable();
}
const templates = await prisma.ruleTemplate.findMany({
orderBy: [{ featured: "desc" }, { sortOrder: "asc" }, { title: "asc" }],
select: {
id: true,
slug: true,
title: true,
category: true,
description: true,
body: true,
featured: true,
},
});
const templates = await listRuleTemplatesFromDb();
return NextResponse.json({ templates });
}
@@ -11,13 +11,8 @@ import {
} from "../../../../lib/create/templateReviewMapping";
import {
getGovernanceTemplateCatalogEntry,
governanceTemplateIconPath,
} from "../../../../lib/templates/governanceTemplateCatalog";
const FALLBACK_PRESENTATION = {
iconPath: governanceTemplateIconPath("consensus"),
backgroundColor: "bg-[var(--color-surface-invert-brand-teal)]",
};
import { TEMPLATE_GRID_FALLBACK_PRESENTATION } from "../../../../lib/templates/templateGridPresentation";
export interface TemplateReviewCardProps {
template: RuleTemplateDto;
@@ -37,7 +32,7 @@ export function TemplateReviewCard({
size = "L",
}: TemplateReviewCardProps) {
const catalog = getGovernanceTemplateCatalogEntry(template.slug);
const pres = catalog ?? FALLBACK_PRESENTATION;
const pres = catalog ?? TEMPLATE_GRID_FALLBACK_PRESENTATION;
const categories = templateBodyToCategories(template.body);
const summary = templateSummaryFromBody(template.description, template.body);
@@ -0,0 +1,33 @@
/**
* Placeholder grid matching GovernanceTemplateGrid layout (loading state).
*/
export function GovernanceTemplateGridSkeleton({ count }: { count: number }) {
return (
<div
className="
flex flex-col gap-[18px]
min-[768px]:grid min-[768px]:grid-cols-2 min-[768px]:gap-[18px]
min-[1024px]:gap-[24px]
"
aria-busy
aria-label="Loading templates"
>
{Array.from({ length: count }, (_, i) => (
<div
key={i}
className="
flex min-h-[120px] animate-pulse flex-col gap-3 rounded-[var(--measures-radius-200,8px)]
bg-[var(--color-surface-default-secondary,#262626)] p-4
min-[640px]:min-h-[140px] min-[640px]:rounded-[var(--measures-radius-300,12px)]
min-[1024px]:min-h-[160px] min-[1024px]:rounded-[var(--radius-measures-radius-small)]
"
>
<div className="h-10 w-10 rounded bg-[var(--color-surface-default-tertiary,#404040)] min-[640px]:h-14 min-[640px]:w-14" />
<div className="h-4 w-[55%] max-w-[280px] rounded bg-[var(--color-surface-default-tertiary,#404040)]" />
<div className="h-3 w-full max-w-[400px] rounded bg-[var(--color-surface-default-tertiary,#404040)]" />
<div className="h-3 w-[72%] max-w-[360px] rounded bg-[var(--color-surface-default-tertiary,#404040)]" />
</div>
))}
</div>
);
}
@@ -1,8 +1,15 @@
"use client";
import { memo } from "react";
import { memo, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { logger } from "../../../../lib/logger";
import {
fetchTemplates,
isTemplatesFetchAborted,
} from "../../../../lib/create/fetchTemplates";
import { GOVERNANCE_TEMPLATE_HOME_SLUGS } from "../../../../lib/templates/governanceTemplateCatalog";
import { gridEntriesForSlugOrderWithCatalogFallback } from "../../../../lib/templates/templateGridPresentation";
import type { TemplateGridCardEntry } from "../../../../lib/templates/templateGridPresentation";
import { RuleStackView } from "./RuleStack.view";
import type { RuleStackProps } from "./RuleStack.types";
@@ -19,8 +26,53 @@ declare global {
}
}
const RuleStackContainer = memo<RuleStackProps>(({ className = "" }) => {
const RuleStackContainer = memo<RuleStackProps>(
({ className = "", initialGridEntries }) => {
const router = useRouter();
const [gridEntries, setGridEntries] = useState<TemplateGridCardEntry[] | null>(
() => initialGridEntries ?? null,
);
useEffect(() => {
if (initialGridEntries !== undefined) {
return;
}
const ac = new AbortController();
let cancelled = false;
void (async () => {
try {
const result = await fetchTemplates({ signal: ac.signal });
if (cancelled) return;
if ("error" in result) {
setGridEntries(
gridEntriesForSlugOrderWithCatalogFallback(
[],
GOVERNANCE_TEMPLATE_HOME_SLUGS,
),
);
return;
}
setGridEntries(
gridEntriesForSlugOrderWithCatalogFallback(
result,
GOVERNANCE_TEMPLATE_HOME_SLUGS,
),
);
} catch (e) {
if (cancelled || isTemplatesFetchAborted(e)) return;
setGridEntries(
gridEntriesForSlugOrderWithCatalogFallback(
[],
GOVERNANCE_TEMPLATE_HOME_SLUGS,
),
);
}
})();
return () => {
cancelled = true;
ac.abort();
};
}, [initialGridEntries]);
const handleTemplateClick = (slug: string) => {
// Basic analytics tracking
@@ -44,9 +96,11 @@ const RuleStackContainer = memo<RuleStackProps>(({ className = "" }) => {
<RuleStackView
className={className}
onTemplateClick={handleTemplateClick}
gridEntries={gridEntries}
/>
);
});
},
);
RuleStackContainer.displayName = "RuleStack";
@@ -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;
}
@@ -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"
/>
<GovernanceTemplateGrid
entries={homeFeaturedTemplates}
onTemplateClick={onTemplateClick}
/>
{gridEntries === null ? (
<GovernanceTemplateGridSkeleton count={4} />
) : (
<GovernanceTemplateGrid
entries={gridEntries}
onTemplateClick={onTemplateClick}
/>
)}
<div
className="
+23 -11
View File
@@ -5,6 +5,7 @@ import { TemplateReviewCard } from "../../../components/cards/TemplateReviewCard
import { useTranslation } from "../../../contexts/MessagesContext";
import {
fetchTemplateBySlug,
isTemplatesFetchAborted,
type RuleTemplateDto,
} from "../../../../lib/create/fetchTemplates";
import messages from "../../../../messages/en/index";
@@ -35,28 +36,39 @@ export default function ReviewTemplatePage({ params }: PageProps) {
const [loading, setLoading] = useState(true);
useEffect(() => {
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]);
+30 -6
View File
@@ -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<RuleTemplateDto[] | { error: string }> {
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<RuleTemplateDto | null | { error: string }> {
const result = await fetchTemplates();
const result = await fetchTemplates(options);
if ("error" in result) {
return result;
}
+30
View File
@@ -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<RuleTemplateDto[]> {
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 [];
}
}
+103
View File
@@ -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);
}
+178 -314
View File
@@ -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"),
},
];
+36
View File
@@ -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 <Story />;
}
export default {
title: "Components/Sections/RuleStack",
component: RuleStack,
decorators: [withMockTemplatesApi],
parameters: {
layout: "fullscreen",
docs: {
+92 -15
View File
@@ -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(<RuleStack initialGridEntries={homeFeatured} />);
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(<RuleStack />);
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(<RuleStack className="custom-class" />);
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(<RuleStack />);
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(<RuleStack />);
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(<RuleStack />);
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(<RuleStack />);
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(<RuleStack />);
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(<RuleStack />);
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(<RuleStack />);
await waitForRuleStackCards();
const circlesCard = screen
.getByText("Circles")
@@ -142,6 +199,7 @@ describe("RuleStack Component", () => {
.mockImplementation(() => undefined);
render(<RuleStack />);
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(<RuleStack />);
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(<RuleStack />);
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(<RuleStack />);
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(<RuleStack />);
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(<RuleStack />);
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(<RuleStack />);
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(<RuleStack />);
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(<RuleStack />);
await waitForRuleStackCards();
const electedBoardCard = screen.getByText("Elected Board").closest("div");
await user.click(electedBoardCard);