+ >
+ ) : compactDesktopLayout === "flexWrap" ? (
+ <>
+
+ {/* md–lg: pyramid (2 + 1), each row centered; lg+: one centered row (not edge-to-edge in a 2-col grid) */}
+ {compactCards.length === 3 ? (
+ <>
+
+ )}
+ >
) : (
<>
{/* Compact under 640: single column, up to 5 recommended cards */}
-
+
{compactCards.map((item) => (
= SAVE_EXIT_FROM_STEP_INDEX;
@@ -575,6 +576,70 @@ function CreateFlowLayoutContent({
>
{footer.confirmCoreValues}
+ ) : currentStep === "communication-methods" && nextStep ? (
+ {
+ goToNextStep();
+ }}
+ >
+ {footer.confirmCommunication}
+
+ ) : currentStep === "membership-methods" && nextStep ? (
+ {
+ goToNextStep();
+ }}
+ >
+ {footer.confirmMembership}
+
+ ) : currentStep === "decision-approaches" && nextStep ? (
+ {
+ goToNextStep();
+ }}
+ >
+ {footer.confirmRightRail}
+
+ ) : currentStep === "conflict-management" && nextStep ? (
+ {
+ goToNextStep();
+ }}
+ >
+ {footer.confirmConflictManagement}
+
) : nextStep ? (
;
}
-export default function CreateFlowScreenPage({ params }: PageProps) {
- const { screenId: raw } = use(params);
- const router = useRouter();
-
- useEffect(() => {
- if (raw === "community-reflection") {
- router.replace("/create/community-save");
- }
- }, [raw, router]);
-
- if (raw === "community-reflection") {
- return null;
- }
+export default async function CreateFlowScreenPage({ params }: PageProps) {
+ const { screenId: raw } = await params;
if (!isValidStep(raw)) {
notFound();
}
- const screenId = raw as CreateFlowStep;
- return ;
+ return ;
}
diff --git a/app/create/components/CreateFlowTwoColumnSelectShell.tsx b/app/create/components/CreateFlowTwoColumnSelectShell.tsx
index ae7c2d7..b792fde 100644
--- a/app/create/components/CreateFlowTwoColumnSelectShell.tsx
+++ b/app/create/components/CreateFlowTwoColumnSelectShell.tsx
@@ -1,7 +1,10 @@
"use client";
import type { ReactNode } from "react";
-import { CreateFlowStepShell } from "./CreateFlowStepShell";
+import {
+ CreateFlowStepShell,
+ type CreateFlowContentTopBelowMd,
+} from "./CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "./createFlowLayoutTokens";
export type CreateFlowSelectShellLgVerticalAlign = "center" | "start";
@@ -9,23 +12,29 @@ export type CreateFlowSelectShellLgVerticalAlign = "center" | "start";
interface CreateFlowTwoColumnSelectShellProps {
header: ReactNode;
children: ReactNode;
+ /**
+ * Top padding below create-flow chrome. Select steps use `space-1400`; right-rail uses `space-800`
+ * (Figma Flow — Right Rail).
+ */
+ contentTopBelowMd?: CreateFlowContentTopBelowMd;
/**
* At `lg+`, layout variant: `"center"` = vertically centered pair (community size/structure).
- * `"start"` = top-weighted layout with a scrollable right column (core values): uses `items-stretch`
+ * `"start"` = top-weighted layout with a scrollable right column (core values, right-rail): uses `items-stretch`
* so the right column gets a bounded height; `items-start` would grow with content and break scroll.
*/
lgVerticalAlign?: CreateFlowSelectShellLgVerticalAlign;
}
/**
- * Two-column layout for create-flow select steps (community size/structure, core values).
- * Below `lg`, layout and scrolling match the previous single-column behavior (full page scroll).
+ * Two-column layout for create-flow select steps (community size/structure, core values) and
+ * {@link RightRailScreen} (decision approaches). Below `lg` (1024px), one column + main scrolls.
* At `lg+`, mirrors {@link CompletedScreen}: static header column + scrollable controls column
* (`min-h-0` + `overflow-y-auto` height chain; see completed page right rail).
*/
export function CreateFlowTwoColumnSelectShell({
header,
children,
+ contentTopBelowMd = "space-1400",
lgVerticalAlign = "center",
}: CreateFlowTwoColumnSelectShellProps) {
/** `stretch` is required for `min-h-0` + `overflow-y-auto` on the right column. */
@@ -38,7 +47,7 @@ export function CreateFlowTwoColumnSelectShell({
return (
;
case "core-values":
return ;
- case "cards":
- return ;
- case "right-rail":
+ case "communication-methods":
+ return ;
+ case "membership-methods":
+ return ;
+ case "decision-approaches":
return ;
+ case "conflict-management":
+ return ;
case "confirm-stakeholders":
return ;
case "final-review":
diff --git a/app/create/screens/card/CardsScreen.tsx b/app/create/screens/card/CommunicationMethodsScreen.tsx
similarity index 77%
rename from app/create/screens/card/CardsScreen.tsx
rename to app/create/screens/card/CommunicationMethodsScreen.tsx
index 4c9ce83..30ee75d 100644
--- a/app/create/screens/card/CardsScreen.tsx
+++ b/app/create/screens/card/CommunicationMethodsScreen.tsx
@@ -1,5 +1,14 @@
"use client";
+/**
+ * `communication-methods` step — Figma “Flow — Compact Card Stack” (node `20246-15828`).
+ * Registry: `layoutKind: "card"` (`CREATE_FLOW_SCREEN_REGISTRY["communication-methods"]`).
+ *
+ * Lives under `screens/card/` (not `select/`): Figma **card stack** layout is a distinct shell from
+ * two-column chip **select** frames. Future card-stack steps get their own `*Screen.tsx` here and
+ * reuse `CardStack` / `CreateFlowStepShell` as needed.
+ */
+
import { useState, useCallback, useMemo } from "react";
import { useMessages } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
@@ -9,7 +18,10 @@ import CardStack from "../../../components/utility/CardStack";
import Create from "../../../components/modals/Create";
import TextArea from "../../../components/controls/TextArea";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
-import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
+import {
+ CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
+ CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
+} from "../../components/createFlowLayoutTokens";
const IN_PERSON_CARD_ID = "in-person-meetings";
const SIGNAL_CARD_ID = "signal";
@@ -129,16 +141,24 @@ function isAddPlatformCard(cardId: string | null): boolean {
);
}
-export function CardsScreen() {
+export function CommunicationMethodsScreen() {
const m = useMessages();
const comm = m.create.communication;
const mdUp = useCreateFlowMdUp();
- const { markCreateFlowInteraction } = useCreateFlow();
+ const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const [expanded, setExpanded] = useState(false);
- const [selectedIds, setSelectedIds] = useState([]);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [pendingCardId, setPendingCardId] = useState(null);
+ const selectedIds = state.selectedCommunicationMethodIds ?? [];
+
+ const setSelectedIds = useCallback(
+ (next: string[]) => {
+ updateState({ selectedCommunicationMethodIds: next });
+ },
+ [updateState],
+ );
+
const sampleCards = useMemo(
() =>
COMMUNICATION_CARD_ORDER.map((id) => {
@@ -154,9 +174,25 @@ export function CardsScreen() {
);
const title = expanded ? comm.page.expandedTitle : comm.page.compactTitle;
- const description = expanded
- ? comm.page.expandedDescription
- : comm.page.compactDescription;
+
+ const description = expanded ? (
+ comm.page.expandedDescription
+ ) : (
+ <>
+ {comm.page.compactDescriptionBefore}
+ {
+ markCreateFlowInteraction();
+ setExpanded(true);
+ }}
+ >
+ {comm.page.compactDescriptionLinkLabel}
+
+ {comm.page.compactDescriptionAfter}
+ >
+ );
const modalConfig =
pendingCardId && pendingCardId in comm.modals
@@ -198,13 +234,15 @@ export function CardsScreen() {
const handleCreateModalConfirm = useCallback(() => {
markCreateFlowInteraction();
if (pendingCardId) {
- setSelectedIds((prev) =>
- prev.includes(pendingCardId) ? prev : [...prev, pendingCardId],
+ setSelectedIds(
+ selectedIds.includes(pendingCardId)
+ ? selectedIds
+ : [...selectedIds, pendingCardId],
);
}
setCreateModalOpen(false);
setPendingCardId(null);
- }, [markCreateFlowInteraction, pendingCardId]);
+ }, [markCreateFlowInteraction, pendingCardId, selectedIds, setSelectedIds]);
return (
-
+
diff --git a/app/create/screens/card/ConflictManagementScreen.tsx b/app/create/screens/card/ConflictManagementScreen.tsx
new file mode 100644
index 0000000..026bb70
--- /dev/null
+++ b/app/create/screens/card/ConflictManagementScreen.tsx
@@ -0,0 +1,179 @@
+"use client";
+
+/**
+ * `conflict-management` step — Figma compact card stack (node `20879-15979`).
+ * Registry: `CREATE_FLOW_SCREEN_REGISTRY["conflict-management"]`.
+ */
+
+import { useState, useCallback, useMemo } from "react";
+import { useMessages } from "../../../contexts/MessagesContext";
+import { useCreateFlow } from "../../context/CreateFlowContext";
+import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
+import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
+import CardStack from "../../../components/utility/CardStack";
+import Create from "../../../components/modals/Create";
+import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
+import {
+ CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
+ CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
+} from "../../components/createFlowLayoutTokens";
+
+const CONFLICT_CARD_ORDER = [
+ "peer-mediation",
+ "conflict-resolution-council",
+ "facilitated-negotiation",
+ "ad-hoc-arbitration",
+ "conflict-workshops",
+ "6",
+ "7",
+ "8",
+] as const;
+
+export function ConflictManagementScreen() {
+ const m = useMessages();
+ const cm = m.create.conflictManagement;
+ const mdUp = useCreateFlowMdUp();
+ const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
+ const [expanded, setExpanded] = useState(false);
+ const [createModalOpen, setCreateModalOpen] = useState(false);
+ const [pendingCardId, setPendingCardId] = useState
(null);
+
+ const selectedIds = state.selectedConflictManagementIds ?? [];
+
+ const setSelectedIds = useCallback(
+ (next: string[]) => {
+ updateState({ selectedConflictManagementIds: next });
+ },
+ [updateState],
+ );
+
+ const sampleCards = useMemo(
+ () =>
+ CONFLICT_CARD_ORDER.map((id) => {
+ const row = cm.cards[id as keyof typeof cm.cards];
+ return {
+ id,
+ label: row.label,
+ supportText: row.supportText,
+ recommended: true,
+ };
+ }),
+ [cm],
+ );
+
+ const title = expanded ? cm.page.expandedTitle : cm.page.compactTitle;
+
+ const description = expanded ? (
+ cm.page.expandedDescription
+ ) : (
+ <>
+ {cm.page.compactDescriptionBefore}
+ {
+ markCreateFlowInteraction();
+ setExpanded(true);
+ }}
+ >
+ {cm.page.compactDescriptionLinkLabel}
+
+ {cm.page.compactDescriptionAfter}
+ >
+ );
+
+ const modalConfig =
+ pendingCardId && pendingCardId in cm.modals
+ ? (() => {
+ const modal = cm.modals[pendingCardId as keyof typeof cm.modals];
+ return {
+ title: modal.title,
+ description: modal.description,
+ nextButtonText: cm.confirmModal.nextButtonText,
+ showBackButton: false as const,
+ currentStep: undefined,
+ totalSteps: undefined,
+ };
+ })()
+ : {
+ title: cm.confirmModal.title,
+ description: cm.confirmModal.description,
+ nextButtonText: cm.confirmModal.nextButtonText,
+ showBackButton: false as const,
+ currentStep: undefined,
+ totalSteps: undefined,
+ };
+
+ const handleCardClick = useCallback(
+ (id: string) => {
+ markCreateFlowInteraction();
+ setPendingCardId(id);
+ setCreateModalOpen(true);
+ },
+ [markCreateFlowInteraction],
+ );
+
+ const handleCreateModalClose = useCallback(() => {
+ setCreateModalOpen(false);
+ setPendingCardId(null);
+ }, []);
+
+ const handleCreateModalConfirm = useCallback(() => {
+ markCreateFlowInteraction();
+ if (pendingCardId) {
+ setSelectedIds(
+ selectedIds.includes(pendingCardId)
+ ? selectedIds
+ : [...selectedIds, pendingCardId],
+ );
+ }
+ setCreateModalOpen(false);
+ setPendingCardId(null);
+ }, [markCreateFlowInteraction, pendingCardId, selectedIds, setSelectedIds]);
+
+ return (
+
+
+
+
+
+
+ {
+ markCreateFlowInteraction();
+ setExpanded((prev) => !prev);
+ }}
+ hasMore={true}
+ toggleLabel={cm.page.seeAllLink}
+ compactRecommendedLimit={5}
+ compactDesktopLayout="pyramidFive"
+ headerLockupSize={mdUp ? "L" : "M"}
+ />
+
+
+
+
+
+ );
+}
diff --git a/app/create/screens/card/MembershipMethodsScreen.tsx b/app/create/screens/card/MembershipMethodsScreen.tsx
new file mode 100644
index 0000000..e67f464
--- /dev/null
+++ b/app/create/screens/card/MembershipMethodsScreen.tsx
@@ -0,0 +1,179 @@
+"use client";
+
+/**
+ * `membership-methods` step — Figma compact card stack (node `20858-13947`).
+ * Registry: `CREATE_FLOW_SCREEN_REGISTRY["membership-methods"]`.
+ */
+
+import { useState, useCallback, useMemo } from "react";
+import { useMessages } from "../../../contexts/MessagesContext";
+import { useCreateFlow } from "../../context/CreateFlowContext";
+import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
+import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
+import CardStack from "../../../components/utility/CardStack";
+import Create from "../../../components/modals/Create";
+import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
+import {
+ CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
+ CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
+} from "../../components/createFlowLayoutTokens";
+
+const MEMBERSHIP_CARD_ORDER = [
+ "open-access",
+ "orientation-required",
+ "invitation-only",
+ "contribution-based",
+ "mentorship",
+ "6",
+ "7",
+ "8",
+] as const;
+
+export function MembershipMethodsScreen() {
+ const m = useMessages();
+ const mem = m.create.membership;
+ const mdUp = useCreateFlowMdUp();
+ const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
+ const [expanded, setExpanded] = useState(false);
+ const [createModalOpen, setCreateModalOpen] = useState(false);
+ const [pendingCardId, setPendingCardId] = useState(null);
+
+ const selectedIds = state.selectedMembershipMethodIds ?? [];
+
+ const setSelectedIds = useCallback(
+ (next: string[]) => {
+ updateState({ selectedMembershipMethodIds: next });
+ },
+ [updateState],
+ );
+
+ const sampleCards = useMemo(
+ () =>
+ MEMBERSHIP_CARD_ORDER.map((id) => {
+ const row = mem.cards[id as keyof typeof mem.cards];
+ return {
+ id,
+ label: row.label,
+ supportText: row.supportText,
+ recommended: true,
+ };
+ }),
+ [mem],
+ );
+
+ const title = expanded ? mem.page.expandedTitle : mem.page.compactTitle;
+
+ const description = expanded ? (
+ mem.page.expandedDescription
+ ) : (
+ <>
+ {mem.page.compactDescriptionBefore}
+ {
+ markCreateFlowInteraction();
+ setExpanded(true);
+ }}
+ >
+ {mem.page.compactDescriptionLinkLabel}
+
+ {mem.page.compactDescriptionAfter}
+ >
+ );
+
+ const modalConfig =
+ pendingCardId && pendingCardId in mem.modals
+ ? (() => {
+ const modal = mem.modals[pendingCardId as keyof typeof mem.modals];
+ return {
+ title: modal.title,
+ description: modal.description,
+ nextButtonText: mem.confirmModal.nextButtonText,
+ showBackButton: false as const,
+ currentStep: undefined,
+ totalSteps: undefined,
+ };
+ })()
+ : {
+ title: mem.confirmModal.title,
+ description: mem.confirmModal.description,
+ nextButtonText: mem.confirmModal.nextButtonText,
+ showBackButton: false as const,
+ currentStep: undefined,
+ totalSteps: undefined,
+ };
+
+ const handleCardClick = useCallback(
+ (id: string) => {
+ markCreateFlowInteraction();
+ setPendingCardId(id);
+ setCreateModalOpen(true);
+ },
+ [markCreateFlowInteraction],
+ );
+
+ const handleCreateModalClose = useCallback(() => {
+ setCreateModalOpen(false);
+ setPendingCardId(null);
+ }, []);
+
+ const handleCreateModalConfirm = useCallback(() => {
+ markCreateFlowInteraction();
+ if (pendingCardId) {
+ setSelectedIds(
+ selectedIds.includes(pendingCardId)
+ ? selectedIds
+ : [...selectedIds, pendingCardId],
+ );
+ }
+ setCreateModalOpen(false);
+ setPendingCardId(null);
+ }, [markCreateFlowInteraction, pendingCardId, selectedIds, setSelectedIds]);
+
+ return (
+
+
+
+
+
+
+ {
+ markCreateFlowInteraction();
+ setExpanded((prev) => !prev);
+ }}
+ hasMore={true}
+ toggleLabel={mem.page.seeAllLink}
+ compactRecommendedLimit={5}
+ compactDesktopLayout="pyramidFive"
+ headerLockupSize={mdUp ? "L" : "M"}
+ />
+
+
+
+
+
+ );
+}
diff --git a/app/create/screens/right-rail/RightRailScreen.tsx b/app/create/screens/right-rail/RightRailScreen.tsx
index a76dc22..b43cb6f 100644
--- a/app/create/screens/right-rail/RightRailScreen.tsx
+++ b/app/create/screens/right-rail/RightRailScreen.tsx
@@ -1,5 +1,13 @@
"use client";
+/**
+ * `decision-approaches` step — Figma “Flow — Right Rail” (node `20523-23509`).
+ * Registry: `CREATE_FLOW_SCREEN_REGISTRY["decision-approaches"]` (`layoutKind: "right-rail"`).
+ *
+ * Layout matches {@link CreateFlowTwoColumnSelectShell}: one column below `lg` (1024px), two columns
+ * at `lg+` with a scrollable rail — same breakpoint and height chain as select steps, distinct content.
+ */
+
import { useState, useCallback, useMemo } from "react";
import DecisionMakingSidebar from "../../../components/utility/DecisionMakingSidebar";
import CardStack from "../../../components/utility/CardStack";
@@ -8,22 +16,27 @@ import type { CardStackItem } from "../../../components/utility/CardStack/CardSt
import { useMessages } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
-import {
- CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
- CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
-} from "../../components/createFlowLayoutTokens";
+import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
export function RightRailScreen() {
const m = useMessages();
const rr = m.create.rightRail;
const mdUp = useCreateFlowMdUp();
- const { markCreateFlowInteraction } = useCreateFlow();
+ const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState(
[],
);
- const [selectedIds, setSelectedIds] = useState([]);
const [expanded, setExpanded] = useState(false);
+ const selectedIds = state.selectedDecisionApproachIds ?? [];
+
+ const setSelectedIds = useCallback(
+ (next: string[]) => {
+ updateState({ selectedDecisionApproachIds: next });
+ },
+ [updateState],
+ );
+
const messageBoxItems: InfoMessageBoxItem[] = useMemo(
() =>
rr.messageBox.items.map((item) => ({
@@ -47,7 +60,16 @@ export function RightRailScreen() {
const sidebarDescription = (
<>
{rr.sidebar.descriptionBefore}
- {rr.sidebar.descriptionLink}
+ {
+ markCreateFlowInteraction();
+ setExpanded(true);
+ }}
+ >
+ {rr.sidebar.descriptionLinkLabel}
+
{rr.sidebar.descriptionAfter}
>
);
@@ -65,11 +87,13 @@ export function RightRailScreen() {
const handleCardSelect = useCallback(
(id: string) => {
markCreateFlowInteraction();
- setSelectedIds((prev) =>
- prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
+ setSelectedIds(
+ selectedIds.includes(id)
+ ? selectedIds.filter((x) => x !== id)
+ : [...selectedIds, id],
);
},
- [markCreateFlowInteraction],
+ [markCreateFlowInteraction, selectedIds, setSelectedIds],
);
const handleToggleExpand = useCallback(() => {
@@ -78,48 +102,40 @@ export function RightRailScreen() {
}, [markCreateFlowInteraction]);
return (
-
-
+
);
}
diff --git a/app/create/types.ts b/app/create/types.ts
index 1ec4ce4..68393b5 100644
--- a/app/create/types.ts
+++ b/app/create/types.ts
@@ -19,8 +19,10 @@ export type CreateFlowStep =
| "community-save"
| "review"
| "core-values"
- | "cards"
- | "right-rail"
+ | "communication-methods"
+ | "membership-methods"
+ | "decision-approaches"
+ | "conflict-management"
| "confirm-stakeholders"
| "final-review"
| "completed";
@@ -83,6 +85,14 @@ export interface CreateFlowState {
coreValuesChipsSnapshot?: CommunityStructureChipSnapshotRow[];
/** User-authored detail text keyed by chip id (preset ids or custom UUIDs). */
coreValueDetailsByChipId?: Record
;
+ /** Create Custom — communication methods step (`/create/communication-methods`); card ids from `create.communication` presets. */
+ selectedCommunicationMethodIds?: string[];
+ /** Create Custom — membership / join patterns (`/create/membership-methods`); card ids from `create.membership` presets. */
+ selectedMembershipMethodIds?: string[];
+ /** Create Custom — decision approaches (`/create/decision-approaches`); card ids from `create.rightRail` presets. */
+ selectedDecisionApproachIds?: string[];
+ /** Create Custom — conflict management (`/create/conflict-management`); card ids from `create.conflictManagement` presets. */
+ selectedConflictManagementIds?: string[];
currentStep?: CreateFlowStep;
/** Section drafts; structure will tighten as steps persist real shapes. */
sections?: Record[];
diff --git a/app/create/utils/createFlowProportionProgress.ts b/app/create/utils/createFlowProportionProgress.ts
index 2541e39..017986b 100644
--- a/app/create/utils/createFlowProportionProgress.ts
+++ b/app/create/utils/createFlowProportionProgress.ts
@@ -16,8 +16,10 @@ const PROPORTION_BY_STEP_INDEX: readonly ProportionBarState[] = [
"2-0", // community-save
"2-0", // review (Figma Flow — Review `19706:12135`: same segment fill as end of Create Community)
"2-0", // core-values (same segment as review / end of Create Community)
- "2-2", // cards
- "3-0", // right-rail
+ "2-1", // communication-methods (Figma — Compact Card Stack)
+ "2-2", // membership-methods (Figma — Compact Card Stack `20858:13947`)
+ "2-3", // decision-approaches (Figma Flow — Right Rail `20523:23509`)
+ "3-0", // conflict-management (Figma Flow — Compact Card Stack `20879:15979`; start of Review segment)
"3-1", // confirm-stakeholders
"3-2", // final-review
"3-2", // completed
diff --git a/app/create/utils/createFlowScreenRegistry.ts b/app/create/utils/createFlowScreenRegistry.ts
index 35719b2..7af764e 100644
--- a/app/create/utils/createFlowScreenRegistry.ts
+++ b/app/create/utils/createFlowScreenRegistry.ts
@@ -2,7 +2,8 @@ import type { CreateFlowStep } from "../types";
/**
* Figma layout families for the create flow (not encoded in the URL).
- * Registry and `app/create/screens/` are organized by these kinds.
+ * `app/create/screens//` mirrors these names: e.g. `layoutKind: "select"` → `screens/select/`,
+ * `"card"` → `screens/card/` (compact card-stack frames, distinct from two-column chip selects).
*/
export type CreateFlowLayoutKind =
| "informational"
@@ -90,18 +91,30 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record<
messageNamespace: "create.coreValues",
centeredBodyBelowMd: false,
},
- cards: {
+ "communication-methods": {
layoutKind: "card",
- figmaNodeId: "TBD-cards",
+ figmaNodeId: "20246-15828",
messageNamespace: "create.communication",
centeredBodyBelowMd: false,
},
- "right-rail": {
+ "membership-methods": {
+ layoutKind: "card",
+ figmaNodeId: "20858-13947",
+ messageNamespace: "create.membership",
+ centeredBodyBelowMd: false,
+ },
+ "decision-approaches": {
layoutKind: "right-rail",
- figmaNodeId: "TBD-right-rail",
+ figmaNodeId: "20523-23509",
messageNamespace: "create.rightRail",
centeredBodyBelowMd: false,
},
+ "conflict-management": {
+ layoutKind: "card",
+ figmaNodeId: "20879-15979",
+ messageNamespace: "create.conflictManagement",
+ centeredBodyBelowMd: false,
+ },
"confirm-stakeholders": {
layoutKind: "select",
figmaNodeId: "21104-46594",
@@ -128,3 +141,11 @@ export function createFlowStepUsesCenteredTextLayout(
if (!step) return false;
return CREATE_FLOW_SCREEN_REGISTRY[step].centeredBodyBelowMd;
}
+
+/** Steps whose main area uses the CardStack-style layout (`layoutKind: "card"`). */
+export function createFlowStepUsesCardLayout(
+ step: CreateFlowStep | null,
+): boolean {
+ if (!step) return false;
+ return CREATE_FLOW_SCREEN_REGISTRY[step].layoutKind === "card";
+}
diff --git a/app/create/utils/flowSteps.ts b/app/create/utils/flowSteps.ts
index 818a3fc..fad4baa 100644
--- a/app/create/utils/flowSteps.ts
+++ b/app/create/utils/flowSteps.ts
@@ -21,8 +21,10 @@ export const FLOW_STEP_ORDER: readonly CreateFlowStep[] = [
"community-save",
"review",
"core-values",
- "cards",
- "right-rail",
+ "communication-methods",
+ "membership-methods",
+ "decision-approaches",
+ "conflict-management",
"confirm-stakeholders",
"final-review",
"completed",
diff --git a/docs/create-flow.md b/docs/create-flow.md
index 031ad0f..94e3d2b 100644
--- a/docs/create-flow.md
+++ b/docs/create-flow.md
@@ -11,7 +11,7 @@ The Figma **Create Community** sequence is the **source of truth** for the first
| Stage (Figma) | Purpose (summary) | `CreateFlowStep` values (in order) |
| --- | --- | --- |
| **Create Community** | Intro, naming, structure, context, size, upload, save progress (email), then community review. | `informational` → `community-name` → `community-structure` → `community-context` → `community-size` → `community-upload` → `community-save` → `review` |
-| **Create Custom CommunityRule** | Author the CommunityRule content and structure. | `cards` → `right-rail` |
+| **Create Custom CommunityRule** | Author the CommunityRule content and structure. | `core-values` → `communication-methods` → `right-rail` (further card-stack steps get their own `screenId` and `screens/card/*Screen.tsx`; `right-rail` uses `layoutKind: "right-rail"`) |
| **Review and complete** | Stakeholders, final card, publish, success. | `confirm-stakeholders` → `final-review` → `completed` |
Treat these stages as the **canonical product sections** when adding chrome (e.g. stage headers, progress copy), breaking work across teams, or reusing flows in other surfaces. **Layout kind** is **not** encoded in the URL; it lives in [`CREATE_FLOW_SCREEN_REGISTRY`](../app/create/utils/createFlowScreenRegistry.ts) (Figma node id + `layoutKind` per step). Figma defines eight layout kinds: **informational**, **text**, **select**, **upload**, **review**, **card**, **right-rail**, **completed** — `CreateFlowLayoutKind` and [`app/create/screens/`](../app/create/screens/) mirror that list (one folder per kind; multiple steps may share a kind, e.g. several **select** screens).
@@ -34,11 +34,12 @@ Order is defined in code by [`FLOW_STEP_ORDER`](../app/create/utils/flowSteps.ts
| 6 | Create Community | `community-upload` | `/create/community-upload` |
| 7 | Create Community | `community-save` | `/create/community-save` |
| 8 | Create Community (review frame) | `review` | `/create/review` |
-| 9 | Create Custom CommunityRule | `cards` | `/create/cards` |
-| 10 | Create Custom CommunityRule | `right-rail` | `/create/right-rail` |
-| 11 | Review and complete | `confirm-stakeholders` | `/create/confirm-stakeholders` |
-| 12 | Review and complete | `final-review` | `/create/final-review` |
-| 13 | Review and complete | `completed` | `/create/completed` |
+| 9 | Create Custom CommunityRule | `core-values` | `/create/core-values` |
+| 10 | Create Custom CommunityRule | `communication-methods` | `/create/communication-methods` |
+| 11 | Create Custom CommunityRule | `right-rail` | `/create/right-rail` |
+| 12 | Review and complete | `confirm-stakeholders` | `/create/confirm-stakeholders` |
+| 13 | Review and complete | `final-review` | `/create/final-review` |
+| 14 | Review and complete | `completed` | `/create/completed` |
**Primary entry:** marketing header “Create rule” navigates to **`/create`**, which redirects to **`/create/informational`** (see [`TopNav.container.tsx`](../app/components/navigation/TopNav/TopNav.container.tsx)).
diff --git a/lib/create/migrateLegacyCreateFlowState.ts b/lib/create/migrateLegacyCreateFlowState.ts
index 7c87e49..bf6d08a 100644
--- a/lib/create/migrateLegacyCreateFlowState.ts
+++ b/lib/create/migrateLegacyCreateFlowState.ts
@@ -1,25 +1,24 @@
import type { CreateFlowState } from "../../app/create/types";
+/** Legacy `currentStep` values mapped to the current `CreateFlowStep` id. */
+const LEGACY_CREATE_FLOW_STEP_RENAMES: Readonly> = {
+ "right-rail": "decision-approaches",
+};
+
/**
- * Maps pre-rename draft keys and step ids (`community-reflection` → `community-save`).
- * Safe to run on any parsed draft payload before merging into context.
+ * Normalizes parsed draft JSON before merging into create-flow context.
+ * Renames deprecated step ids so old drafts and bookmarks stay valid.
*/
export function migrateLegacyCreateFlowState(
raw: Record | null | undefined,
): CreateFlowState {
if (!raw || typeof raw !== "object") return {};
- const next: Record = { ...raw };
- if (typeof next.communityReflection === "string") {
- if (
- next.communitySaveEmail === undefined ||
- next.communitySaveEmail === ""
- ) {
- next.communitySaveEmail = next.communityReflection;
+ const step = raw.currentStep;
+ if (typeof step === "string") {
+ const next = LEGACY_CREATE_FLOW_STEP_RENAMES[step];
+ if (next !== undefined) {
+ return { ...raw, currentStep: next } as CreateFlowState;
}
}
- delete next.communityReflection;
- if (next.currentStep === "community-reflection") {
- next.currentStep = "community-save";
- }
- return next as CreateFlowState;
+ return raw as CreateFlowState;
}
diff --git a/lib/server/validation/createFlowSchemas.ts b/lib/server/validation/createFlowSchemas.ts
index b59c3d7..2a7d013 100644
--- a/lib/server/validation/createFlowSchemas.ts
+++ b/lib/server/validation/createFlowSchemas.ts
@@ -63,6 +63,10 @@ export const createFlowStateSchema = z
coreValueDetailsByChipId: z
.record(coreValueDetailEntrySchema)
.optional(),
+ selectedCommunicationMethodIds: z.array(z.string()).max(200).optional(),
+ selectedMembershipMethodIds: z.array(z.string()).max(200).optional(),
+ selectedDecisionApproachIds: z.array(z.string()).max(200).optional(),
+ selectedConflictManagementIds: z.array(z.string()).max(200).optional(),
currentStep: createFlowStepSchema.optional(),
sections: z.array(z.unknown()).optional(),
stakeholders: z.array(z.unknown()).optional(),
diff --git a/messages/en/create/communication.json b/messages/en/create/communication.json
index 99a2976..8225070 100644
--- a/messages/en/create/communication.json
+++ b/messages/en/create/communication.json
@@ -2,7 +2,9 @@
"_comment": "Create flow – communication step: page, cards, and add-platform modals",
"page": {
"compactTitle": "How should this community communicate with each-other?",
- "compactDescription": "You can select multiple methods for different needs or add your own",
+ "compactDescriptionBefore": "You can select multiple methods for different needs or ",
+ "compactDescriptionLinkLabel": "add",
+ "compactDescriptionAfter": " your own",
"expandedTitle": "What method should this community use to communicate with eachother?",
"expandedDescription": "You can select multiple methods for different needs or add your own",
"seeAllLink": "See all communication approaches"
diff --git a/messages/en/create/conflictManagement.json b/messages/en/create/conflictManagement.json
new file mode 100644
index 0000000..2a8b625
--- /dev/null
+++ b/messages/en/create/conflictManagement.json
@@ -0,0 +1,76 @@
+{
+ "_comment": "Create flow — conflict management (Figma Flow — Compact Card Stack `20879:15979`)",
+ "page": {
+ "compactTitle": "How should conflicts be managed\nin your group?",
+ "compactDescriptionBefore": "You can also combine or ",
+ "compactDescriptionLinkLabel": "add",
+ "compactDescriptionAfter": " new approaches to the list",
+ "expandedTitle": "How should conflicts be managed in your group?",
+ "expandedDescription": "You can also combine or add new approaches to the list",
+ "seeAllLink": "See all conflict management approaches"
+ },
+ "confirmModal": {
+ "title": "Confirm selection",
+ "description": "Confirm to select this option.",
+ "nextButtonText": "Confirm"
+ },
+ "cards": {
+ "peer-mediation": {
+ "label": "Peer Mediation",
+ "supportText": "Trained members within the organization mediate disputes among peers."
+ },
+ "conflict-resolution-council": {
+ "label": "Conflict Resolution Council",
+ "supportText": "Senior members with institutional knowledge provide guidance or decisions."
+ },
+ "facilitated-negotiation": {
+ "label": "Facilitated Negotiation",
+ "supportText": "A neutral facilitator helps guide the negotiation process."
+ },
+ "ad-hoc-arbitration": {
+ "label": "Ad Hoc Arbitration",
+ "supportText": "Arbitrators are chosen specifically for a particular case."
+ },
+ "conflict-workshops": {
+ "label": "Conflict Workshops",
+ "supportText": "Structured sessions where parties collaboratively resolve disputes and improve future interactions."
+ },
+ "6": {
+ "label": "Label",
+ "supportText": "Additional conflict management approach."
+ },
+ "7": {
+ "label": "Label",
+ "supportText": "Additional conflict management approach."
+ },
+ "8": {
+ "label": "Label",
+ "supportText": "Additional conflict management approach."
+ }
+ },
+ "modals": {
+ "peer-mediation": {
+ "title": "Peer Mediation",
+ "description": "Trained members within the organization mediate disputes among peers."
+ },
+ "conflict-resolution-council": {
+ "title": "Conflict Resolution Council",
+ "description": "Senior members with institutional knowledge provide guidance or decisions."
+ },
+ "facilitated-negotiation": {
+ "title": "Facilitated Negotiation",
+ "description": "A neutral facilitator helps guide the negotiation process."
+ },
+ "ad-hoc-arbitration": {
+ "title": "Ad Hoc Arbitration",
+ "description": "Arbitrators are chosen specifically for a particular case."
+ },
+ "conflict-workshops": {
+ "title": "Conflict Workshops",
+ "description": "Structured sessions where parties collaboratively resolve disputes and improve future interactions."
+ },
+ "6": { "title": "Label", "description": "Additional conflict management approach." },
+ "7": { "title": "Label", "description": "Additional conflict management approach." },
+ "8": { "title": "Label", "description": "Additional conflict management approach." }
+ }
+}
diff --git a/messages/en/create/footer.json b/messages/en/create/footer.json
index 1708b87..24d4b23 100644
--- a/messages/en/create/footer.json
+++ b/messages/en/create/footer.json
@@ -11,5 +11,9 @@
"confirmMembers": "Confirm members",
"finalizeCommunityRule": "Finalize CommunityRule",
"confirmStakeholders": "Confirm Stakeholders",
- "confirmCoreValues": "Confirm values"
+ "confirmCoreValues": "Confirm values",
+ "confirmCommunication": "Confirm",
+ "confirmMembership": "Confirm",
+ "confirmRightRail": "Confirm",
+ "confirmConflictManagement": "Confirm"
}
diff --git a/messages/en/create/membership.json b/messages/en/create/membership.json
new file mode 100644
index 0000000..66b3959
--- /dev/null
+++ b/messages/en/create/membership.json
@@ -0,0 +1,76 @@
+{
+ "_comment": "Create flow — membership / how members join (compact card stack)",
+ "page": {
+ "compactTitle": "How do new members join\nand get connected?",
+ "compactDescriptionBefore": "You can select multiple methods for different needs or ",
+ "compactDescriptionLinkLabel": "add",
+ "compactDescriptionAfter": " your own",
+ "expandedTitle": "How should new members join and get connected?",
+ "expandedDescription": "You can select multiple methods for different needs or add your own",
+ "seeAllLink": "See all membership approaches"
+ },
+ "confirmModal": {
+ "title": "Confirm selection",
+ "description": "Confirm to select this option.",
+ "nextButtonText": "Confirm"
+ },
+ "cards": {
+ "open-access": {
+ "label": "Open Access",
+ "supportText": "Maximum inclusion. Anyone can join immediately by simply showing up."
+ },
+ "orientation-required": {
+ "label": "Orientation Required",
+ "supportText": "Newcomers must attend a training or orientation session."
+ },
+ "invitation-only": {
+ "label": "Invitation Only",
+ "supportText": "New members can only join if they are 'vouched for' by existing members."
+ },
+ "contribution-based": {
+ "label": "Contribution Based",
+ "supportText": "Membership is reserved for people contributing their labor."
+ },
+ "mentorship": {
+ "label": "Mentorship",
+ "supportText": "New members are paired with 'Mentors' to guide them through a probationary period."
+ },
+ "6": {
+ "label": "Label",
+ "supportText": "Additional membership approach."
+ },
+ "7": {
+ "label": "Label",
+ "supportText": "Additional membership approach."
+ },
+ "8": {
+ "label": "Label",
+ "supportText": "Additional membership approach."
+ }
+ },
+ "modals": {
+ "open-access": {
+ "title": "Open Access",
+ "description": "Maximum inclusion. Anyone can join immediately by simply showing up."
+ },
+ "orientation-required": {
+ "title": "Orientation Required",
+ "description": "Newcomers must attend a training or orientation session."
+ },
+ "invitation-only": {
+ "title": "Invitation Only",
+ "description": "New members can only join if they are 'vouched for' by existing members."
+ },
+ "contribution-based": {
+ "title": "Contribution Based",
+ "description": "Membership is reserved for people contributing their labor."
+ },
+ "mentorship": {
+ "title": "Mentorship",
+ "description": "New members are paired with 'Mentors' to guide them through a probationary period."
+ },
+ "6": { "title": "Label", "description": "Additional membership approach." },
+ "7": { "title": "Label", "description": "Additional membership approach." },
+ "8": { "title": "Label", "description": "Additional membership approach." }
+ }
+}
diff --git a/messages/en/create/rightRail.json b/messages/en/create/rightRail.json
index a9a1753..4855828 100644
--- a/messages/en/create/rightRail.json
+++ b/messages/en/create/rightRail.json
@@ -1,9 +1,10 @@
{
+ "_comment": "Create flow — right rail / decision approaches (Figma Flow — Right Rail `20523:23509`)",
"sidebar": {
- "title": "How should conflicts be resolved?",
- "descriptionBefore": "You can also combine or ",
- "descriptionLink": "add",
- "descriptionAfter": " new approaches to the list"
+ "title": "How should this community make difficult decisions?",
+ "descriptionBefore": "Select as many as you need to describe how your group makes decisions. You can also ",
+ "descriptionLinkLabel": "add",
+ "descriptionAfter": " new decision making approaches or interact with the categories below to filter."
},
"messageBox": {
"title": "Consider defining approaches to steward key resources:",
@@ -16,99 +17,97 @@
},
"cardStack": {
"toggleSeeAll": "See all decision approaches",
- "toggleShowLess": "Show less",
- "emptyTitle": "",
- "emptyDescription": ""
+ "toggleShowLess": "Show less"
},
"cards": [
{
- "id": "mediation",
- "label": "Mediation",
- "supportText": "Collaborative work to reach a resolution that all parties can agree upon.",
+ "id": "lazy-consensus",
+ "label": "Lazy Consensus",
+ "supportText": "A decision is assumed approved unless objections are raised within a specified timeframe.",
"recommended": true
},
{
- "id": "facilitation",
- "label": "Facilitated dialogue",
- "supportText": "Structured sessions where parties collaboratively resolve disputes.",
+ "id": "do-ocracy",
+ "label": "Do-ocracy",
+ "supportText": "Decisions are made by those who take initiative and carry out the work.",
"recommended": true
},
{
- "id": "invite-only",
- "label": "Invite-only",
- "supportText": "Private discussions with selected participants.",
+ "id": "consensus-decision-making",
+ "label": "Consensus Decision-Making",
+ "supportText": "All members must agree. Best for important decisions in small groups. Does not work well for low stakes decisions.",
"recommended": true
},
{
- "id": "arbitration",
- "label": "Arbitration",
- "supportText": "Arbitrators are chosen specifically for a particular case.",
+ "id": "rotational-leadership",
+ "label": "Rotational Leadership",
+ "supportText": "Decision-making responsibilities rotate among members.",
"recommended": true
},
{
- "id": "direct-dialogue",
- "label": "Direct dialogue",
- "supportText": "Encouraging direct, respectful dialogue between those involved.",
+ "id": "modified-consensus",
+ "label": "Modified Consensus",
+ "supportText": "Attempts to reach full agreement first, but falls back to voting if consensus isn’t possible.",
"recommended": true
},
{
"id": "label-1",
"label": "Label",
- "supportText": "",
+ "supportText": "Additional decision approach.",
"recommended": false
},
{
"id": "label-2",
"label": "Label",
- "supportText": "",
+ "supportText": "Additional decision approach.",
"recommended": false
},
{
"id": "label-3",
"label": "Label",
- "supportText": "",
+ "supportText": "Additional decision approach.",
"recommended": false
},
{
"id": "label-4",
"label": "Label",
- "supportText": "",
+ "supportText": "Additional decision approach.",
"recommended": false
},
{
"id": "label-5",
"label": "Label",
- "supportText": "",
+ "supportText": "Additional decision approach.",
"recommended": false
},
{
"id": "label-6",
"label": "Label",
- "supportText": "",
+ "supportText": "Additional decision approach.",
"recommended": false
},
{
"id": "label-7",
"label": "Label",
- "supportText": "",
+ "supportText": "Additional decision approach.",
"recommended": false
},
{
"id": "label-8",
"label": "Label",
- "supportText": "",
+ "supportText": "Additional decision approach.",
"recommended": false
},
{
"id": "label-9",
"label": "Label",
- "supportText": "",
+ "supportText": "Additional decision approach.",
"recommended": false
},
{
"id": "label-10",
"label": "Label",
- "supportText": "",
+ "supportText": "Additional decision approach.",
"recommended": false
}
]
diff --git a/messages/en/index.ts b/messages/en/index.ts
index 57455fe..32d362d 100644
--- a/messages/en/index.ts
+++ b/messages/en/index.ts
@@ -19,6 +19,8 @@ import profile from "./pages/profile.json";
import navigation from "./navigation.json";
import metadata from "./metadata.json";
import communication from "./create/communication.json";
+import createMembership from "./create/membership.json";
+import createConflictManagement from "./create/conflictManagement.json";
import createInformational from "./create/informational.json";
import createCommunityName from "./create/communityName.json";
import createCommunitySize from "./create/communitySize.json";
@@ -61,6 +63,8 @@ export default {
},
create: {
communication,
+ membership: createMembership,
+ conflictManagement: createConflictManagement,
informational: createInformational,
communityName: createCommunityName,
communitySize: createCommunitySize,
diff --git a/next.config.mjs b/next.config.mjs
index 8129e98..afa0af8 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -97,6 +97,15 @@ const nextConfig = {
return config;
},
+ async redirects() {
+ return [
+ {
+ source: "/create/right-rail",
+ destination: "/create/decision-approaches",
+ permanent: true,
+ },
+ ];
+ },
};
const withMDX = createMDX({
diff --git a/stories/pages/CardsPage.stories.js b/stories/pages/CommunicationMethodsPage.stories.js
similarity index 52%
rename from stories/pages/CardsPage.stories.js
rename to stories/pages/CommunicationMethodsPage.stories.js
index b9260a4..b85396c 100644
--- a/stories/pages/CardsPage.stories.js
+++ b/stories/pages/CommunicationMethodsPage.stories.js
@@ -1,20 +1,20 @@
-import { CardsScreen } from "../../app/create/screens/card/CardsScreen";
+import { CommunicationMethodsScreen } from "../../app/create/screens/card/CommunicationMethodsScreen";
export default {
- title: "Pages/Create Flow/Cards",
- component: CardsScreen,
+ title: "Pages/Create Flow/Communication methods",
+ component: CommunicationMethodsScreen,
parameters: {
layout: "fullscreen",
docs: {
description: {
component:
- "Communication / card selection step with modals and responsive layout.",
+ "Communication methods step (`/create/communication-methods`): card stack, modals, responsive layout.",
},
},
},
decorators: [
(Story) => (
-
+
),
diff --git a/stories/progress/ProportionBar.stories.js b/stories/progress/ProportionBar.stories.js
index 7b243af..9907056 100644
--- a/stories/progress/ProportionBar.stories.js
+++ b/stories/progress/ProportionBar.stories.js
@@ -31,6 +31,7 @@ export default {
"2-0",
"2-1",
"2-2",
+ "2-3",
"3-0",
"3-1",
"3-2",
@@ -86,6 +87,7 @@ export const AllStates = {
+
diff --git a/tests/components/ProportionBar.test.tsx b/tests/components/ProportionBar.test.tsx
index 182699b..abd28bb 100644
--- a/tests/components/ProportionBar.test.tsx
+++ b/tests/components/ProportionBar.test.tsx
@@ -40,8 +40,8 @@ describe("ProportionBar (behavioral tests)", () => {
it("renders proportion bar with correct progress value", () => {
render(
);
const progressbar = screen.getByRole("progressbar");
- // 2-1: First section full (1) + second section 1/3 filled = 1 + 1/3 ≈ 1.333
- expect(progressbar).toHaveAttribute("aria-valuenow", "1.3333333333333333");
+ // 2-1 (Figma `17861:33241`): first section full + second section 1/4 filled = 1.25.
+ expect(progressbar).toHaveAttribute("aria-valuenow", "1.25");
expect(progressbar).toHaveAttribute("aria-valuemin", "0");
expect(progressbar).toHaveAttribute("aria-valuemax", "3");
});
@@ -63,7 +63,8 @@ describe("ProportionBar (behavioral tests)", () => {
{ progress: "1-0" as const, expected: 1 / 6 }, // First section 1/6 filled
{ progress: "1-5" as const, expected: 1 }, // First section 6/6 filled (fully filled)
{ progress: "2-0" as const, expected: 1 }, // First section full, second empty
- { progress: "2-2" as const, expected: 1 + 2 / 3 }, // First section full, second section 2/3 filled
+ { progress: "2-2" as const, expected: 1 + 1 / 2 }, // 1 + 1/2 per Figma `18861:15250`
+ { progress: "2-3" as const, expected: 1 + 3 / 4 }, // 1 + 3/4 per Figma `21434:17632`
{ progress: "3-0" as const, expected: 2 }, // First two sections full, third empty
{ progress: "3-2" as const, expected: 2 + 2 / 3 }, // First two sections full, third section 2/3 filled
];
diff --git a/tests/pages/cards.test.jsx b/tests/pages/communication-methods.test.jsx
similarity index 76%
rename from tests/pages/cards.test.jsx
rename to tests/pages/communication-methods.test.jsx
index ef0efc9..7c8441e 100644
--- a/tests/pages/cards.test.jsx
+++ b/tests/pages/communication-methods.test.jsx
@@ -6,16 +6,16 @@ import {
} from "../utils/test-utils";
import userEvent from "@testing-library/user-event";
import { describe, test, expect, afterEach } from "vitest";
-import { CardsScreen } from "../../app/create/screens/card/CardsScreen";
+import { CommunicationMethodsScreen } from "../../app/create/screens/card/CommunicationMethodsScreen";
afterEach(() => {
cleanup();
});
-describe("Create flow cards page", () => {
+describe("Create flow communication-methods page", () => {
test("clicking a card opens the Create modal", async () => {
const user = userEvent.setup();
- render(
);
+ render(
);
const signalCards = screen.getAllByRole("button", {
name: /Signal: Encrypted messaging/,
@@ -29,7 +29,7 @@ describe("Create flow cards page", () => {
});
test("renders without error", () => {
- render(
);
+ render(
);
expect(
screen.getByText(
@@ -39,13 +39,12 @@ describe("Create flow cards page", () => {
});
test("renders HeaderLockup and CardStack content", () => {
- render(
);
+ render(
);
expect(
- screen.getByText(
- "You can select multiple methods for different needs or add your own",
- ),
+ screen.getByText(/You can select multiple methods for different needs or/),
).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "add" })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "See all communication approaches" }),
).toBeInTheDocument();
@@ -53,7 +52,7 @@ describe("Create flow cards page", () => {
test("toggle expands and shows Show less", async () => {
const user = userEvent.setup();
- render(
);
+ render(
);
const toggle = screen.getByRole("button", {
name: "See all communication approaches",
diff --git a/tests/pages/right-rail.test.jsx b/tests/pages/right-rail.test.jsx
index 2b1386f..472df10 100644
--- a/tests/pages/right-rail.test.jsx
+++ b/tests/pages/right-rail.test.jsx
@@ -18,7 +18,7 @@ describe("Create flow right-rail page", () => {
expect(
screen.getByRole("heading", {
- name: "How should conflicts be resolved?",
+ name: "How should this community make difficult decisions?",
}),
).toBeInTheDocument();
});
@@ -30,9 +30,9 @@ describe("Create flow right-rail page", () => {
if (element?.tagName !== "P") return false;
const text = element.textContent ?? "";
return (
- text.includes("You can also combine or") &&
+ text.includes("Select as many as you need") &&
text.includes("add") &&
- text.includes("new approaches to the list")
+ text.includes("new decision making approaches")
);
});
expect(description).toBeInTheDocument();
@@ -77,17 +77,17 @@ describe("Create flow right-rail page", () => {
expect(
screen.getByRole("button", {
- name: /Mediation: Collaborative work to reach a resolution/,
+ name: /Lazy Consensus: A decision is assumed approved/,
}),
).toBeInTheDocument();
expect(
screen.getByRole("button", {
- name: /Facilitated dialogue: Structured sessions/,
+ name: /Do-ocracy: Decisions are made by those who take initiative/,
}),
).toBeInTheDocument();
expect(
screen.getByRole("button", {
- name: /Invite-only: Private discussions with selected participants/,
+ name: /Consensus Decision-Making: All members must agree/,
}),
).toBeInTheDocument();
});
@@ -123,10 +123,10 @@ describe("Create flow right-rail page", () => {
const user = userEvent.setup();
render(
);
- const mediationCard = screen.getByRole("button", {
- name: /Mediation: Collaborative work to reach a resolution/,
+ const card = screen.getByRole("button", {
+ name: /Lazy Consensus: A decision is assumed approved/,
});
- await user.click(mediationCard);
+ await user.click(card);
expect(screen.getByText("SELECTED")).toBeInTheDocument();
});
diff --git a/tests/unit/createFlowProportionProgress.test.ts b/tests/unit/createFlowProportionProgress.test.ts
index aa0534c..2733a50 100644
--- a/tests/unit/createFlowProportionProgress.test.ts
+++ b/tests/unit/createFlowProportionProgress.test.ts
@@ -26,4 +26,28 @@ describe("getProportionBarProgressForCreateFlowStep", () => {
"2-0",
);
});
+
+ it("uses 2-1 on communication-methods", () => {
+ expect(
+ getProportionBarProgressForCreateFlowStep("communication-methods"),
+ ).toBe("2-1");
+ });
+
+ it("uses 2-2 on membership-methods", () => {
+ expect(
+ getProportionBarProgressForCreateFlowStep("membership-methods"),
+ ).toBe("2-2");
+ });
+
+ it("uses 2-3 on decision-approaches (Figma Flow — Right Rail)", () => {
+ expect(
+ getProportionBarProgressForCreateFlowStep("decision-approaches"),
+ ).toBe("2-3");
+ });
+
+ it("uses 3-0 on conflict-management (start of Review segment)", () => {
+ expect(
+ getProportionBarProgressForCreateFlowStep("conflict-management"),
+ ).toBe("3-0");
+ });
});
diff --git a/tests/unit/createFlowValidation.test.ts b/tests/unit/createFlowValidation.test.ts
index 363ec0c..45fe037 100644
--- a/tests/unit/createFlowValidation.test.ts
+++ b/tests/unit/createFlowValidation.test.ts
@@ -56,7 +56,7 @@ describe("createFlowStateSchema", () => {
it("accepts known fields and passthrough keys", () => {
const r = createFlowStateSchema.safeParse({
title: "My rule",
- currentStep: "cards",
+ currentStep: "communication-methods",
customField: { nested: [1, 2] },
});
expect(r.success).toBe(true);
diff --git a/tests/unit/flowSteps.test.ts b/tests/unit/flowSteps.test.ts
index cfc0cec..f4d3b49 100644
--- a/tests/unit/flowSteps.test.ts
+++ b/tests/unit/flowSteps.test.ts
@@ -16,7 +16,10 @@ describe("flowSteps", () => {
});
it("getNextStep returns next step in order", () => {
- expect(getNextStep("right-rail")).toBe("confirm-stakeholders");
+ expect(getNextStep("communication-methods")).toBe("membership-methods");
+ expect(getNextStep("membership-methods")).toBe("decision-approaches");
+ expect(getNextStep("decision-approaches")).toBe("conflict-management");
+ expect(getNextStep("conflict-management")).toBe("confirm-stakeholders");
expect(getNextStep("confirm-stakeholders")).toBe("final-review");
});
@@ -67,6 +70,6 @@ describe("flowSteps", () => {
const opts = { skipCommunitySave: true } as const;
expect(getNextStep("community-size", opts)).toBe("community-upload");
expect(getNextStep("review", opts)).toBe("core-values");
- expect(getPreviousStep("cards", opts)).toBe("core-values");
+ expect(getPreviousStep("communication-methods", opts)).toBe("core-values");
});
});
diff --git a/tests/unit/migrateLegacyCreateFlowState.test.ts b/tests/unit/migrateLegacyCreateFlowState.test.ts
index acd2fae..a06b829 100644
--- a/tests/unit/migrateLegacyCreateFlowState.test.ts
+++ b/tests/unit/migrateLegacyCreateFlowState.test.ts
@@ -2,27 +2,12 @@ import { describe, it, expect } from "vitest";
import { migrateLegacyCreateFlowState } from "../../lib/create/migrateLegacyCreateFlowState";
describe("migrateLegacyCreateFlowState", () => {
- it("maps communityReflection to communitySaveEmail when save email empty", () => {
+ it("passes through object payloads", () => {
const out = migrateLegacyCreateFlowState({
title: "T",
- communityReflection: "old@example.com",
- });
- expect(out.communitySaveEmail).toBe("old@example.com");
- expect("communityReflection" in out).toBe(false);
- });
-
- it("does not overwrite existing communitySaveEmail", () => {
- const out = migrateLegacyCreateFlowState({
- communityReflection: "old@example.com",
- communitySaveEmail: "kept@example.com",
- });
- expect(out.communitySaveEmail).toBe("kept@example.com");
- });
-
- it("rewrites currentStep slug", () => {
- const out = migrateLegacyCreateFlowState({
- currentStep: "community-reflection",
+ currentStep: "community-save",
});
+ expect(out.title).toBe("T");
expect(out.currentStep).toBe("community-save");
});
@@ -30,4 +15,13 @@ describe("migrateLegacyCreateFlowState", () => {
expect(migrateLegacyCreateFlowState(null)).toEqual({});
expect(migrateLegacyCreateFlowState(undefined)).toEqual({});
});
+
+ it("renames legacy right-rail step to decision-approaches", () => {
+ const out = migrateLegacyCreateFlowState({
+ currentStep: "right-rail",
+ title: "T",
+ });
+ expect(out.currentStep).toBe("decision-approaches");
+ expect(out.title).toBe("T");
+ });
});
From 4854c49c4a81b705deaf2dce9134724def559824 Mon Sep 17 00:00:00 2001
From: adilallo <39313955+adilallo@users.noreply.github.com>
Date: Fri, 17 Apr 2026 23:45:29 -0600
Subject: [PATCH 06/15] Implement modals across create flow
---
app/components/buttons/InlineTextButton.tsx | 59 ++
app/components/controls/Chip/Chip.types.ts | 7 +
app/components/controls/Chip/Chip.view.tsx | 13 +-
.../controls/Incrementer/Incrementer.tsx | 143 ++++
.../controls/Incrementer/IncrementerBlock.tsx | 72 ++
app/components/controls/Incrementer/index.tsx | 5 +
.../components/ApplicableScopeField.tsx | 142 ++++
app/create/components/ModalTextAreaField.tsx | 60 ++
app/create/screens/CreateFlowScreenView.tsx | 4 +-
.../card/CommunicationMethodsScreen.tsx | 143 ++--
.../screens/card/ConflictManagementScreen.tsx | 169 +++-
.../screens/card/MembershipMethodsScreen.tsx | 138 +++-
.../right-rail/DecisionApproachesScreen.tsx | 320 ++++++++
.../screens/right-rail/RightRailScreen.tsx | 141 ----
docs/template-recommendation-matrix.md | 728 ++++++++++++++++++
messages/en/create/communication.json | 36 +
messages/en/create/conflictManagement.json | 83 +-
messages/en/create/membership.json | 73 +-
messages/en/create/rightRail.json | 33 +
...s.js => DecisionApproachesPage.stories.js} | 6 +-
....test.jsx => decision-approaches.test.jsx} | 32 +-
21 files changed, 2089 insertions(+), 318 deletions(-)
create mode 100644 app/components/buttons/InlineTextButton.tsx
create mode 100644 app/components/controls/Incrementer/Incrementer.tsx
create mode 100644 app/components/controls/Incrementer/IncrementerBlock.tsx
create mode 100644 app/components/controls/Incrementer/index.tsx
create mode 100644 app/create/components/ApplicableScopeField.tsx
create mode 100644 app/create/components/ModalTextAreaField.tsx
create mode 100644 app/create/screens/right-rail/DecisionApproachesScreen.tsx
delete mode 100644 app/create/screens/right-rail/RightRailScreen.tsx
create mode 100644 docs/template-recommendation-matrix.md
rename stories/pages/{RightRailPage.stories.js => DecisionApproachesPage.stories.js} (75%)
rename tests/pages/{right-rail.test.jsx => decision-approaches.test.jsx} (80%)
diff --git a/app/components/buttons/InlineTextButton.tsx b/app/components/buttons/InlineTextButton.tsx
new file mode 100644
index 0000000..c9a1364
--- /dev/null
+++ b/app/components/buttons/InlineTextButton.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import { memo } from "react";
+
+export interface InlineTextButtonProps {
+ /**
+ * Button label content.
+ */
+ children: React.ReactNode;
+ /**
+ * Click handler.
+ */
+ onClick?: (_event: React.MouseEvent
) => void;
+ /**
+ * Extra class names. Use `className` to override typography/color when the
+ * button must inherit parent font-size/leading (e.g. mid-paragraph usage).
+ */
+ className?: string;
+ disabled?: boolean;
+ ariaLabel?: string;
+ type?: "button" | "submit" | "reset";
+}
+
+/**
+ * Small text-styled button for in-paragraph "link"-like controls (expand,
+ * add, etc.). The Figma "link" treatment is a tertiary-colored underline with
+ * a 3px underline-offset and inherited typography, which sits between a real
+ * anchor and a styled `Button`.
+ *
+ * Use this anywhere a `` is needed inline with body copy — do not use
+ * for primary/secondary actions (reach for `Button` instead).
+ */
+function InlineTextButtonComponent({
+ children,
+ onClick,
+ className = "",
+ disabled = false,
+ ariaLabel,
+ type = "button",
+}: InlineTextButtonProps) {
+ const baseClasses =
+ "cursor-pointer border-none bg-transparent p-0 font-inter font-normal text-[length:inherit] leading-[inherit] text-[color:var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)] disabled:cursor-not-allowed disabled:opacity-60";
+
+ return (
+
+ {children}
+
+ );
+}
+
+InlineTextButtonComponent.displayName = "InlineTextButton";
+
+export default memo(InlineTextButtonComponent);
diff --git a/app/components/controls/Chip/Chip.types.ts b/app/components/controls/Chip/Chip.types.ts
index dab6332..7b91bf9 100644
--- a/app/components/controls/Chip/Chip.types.ts
+++ b/app/components/controls/Chip/Chip.types.ts
@@ -33,6 +33,13 @@ export interface ChipProps {
*/
size?: ChipSizeValue;
className?: string;
+ /**
+ * Whether the chip should be non-interactive. Defaults to `true` when
+ * `state === "disabled"` to preserve historical behavior. Pass
+ * `disabled={false}` alongside `state="Disabled"` to render the dimmed
+ * "disabled" visual while keeping the chip clickable — useful for toggle
+ * groups where the unselected state is the disabled Figma visual.
+ */
disabled?: boolean;
onClick?: (event: React.MouseEvent) => void;
/**
diff --git a/app/components/controls/Chip/Chip.view.tsx b/app/components/controls/Chip/Chip.view.tsx
index a393411..6b11c84 100644
--- a/app/components/controls/Chip/Chip.view.tsx
+++ b/app/components/controls/Chip/Chip.view.tsx
@@ -20,7 +20,10 @@ function ChipView({
inputRef,
ariaLabel,
}: ChipViewProps) {
- const isDisabled = disabled || state === "disabled";
+ // The container is the source of truth for `disabled`. This allows
+ // `state="disabled"` to be used purely as a visual (for toggle-group chips
+ // that look dimmed while remaining clickable) by passing `disabled={false}`.
+ const isDisabled = disabled ?? false;
const isSelected = state === "selected";
const isCustom = state === "custom";
@@ -57,11 +60,13 @@ function ChipView({
} else if (state === "disabled") {
background = "bg-[var(--color-surface-default-secondary,#141414)]"; // dark background
border = "border-none";
- textColor = "text-[color:var(--color-content-default-tertiary,#b4b4b4)]";
+ // Per Figma (node 19839:13842) disabled uses invert-tertiary for the
+ // strongly dimmed look, not default-tertiary.
+ textColor = "text-[color:var(--color-content-invert-tertiary,#2d2d2d)]";
} else if (isSelected) {
- background = "bg-[var(--color-surface-inverse-brandaccent,#fdfaa8)]"; // yellow selected
+ background = "bg-[var(--color-surface-invert-brand-primary,#fefcc9)]"; // yellow selected
border = `${borderWidth} border-[var(--color-border-default-brand-primary,#fdfaa8)]`;
- textColor = "text-[color:var(--color-content-inverse-primary,black)]";
+ textColor = "text-[color:var(--color-content-invert-primary,black)]";
} else {
// Unselected default
background =
diff --git a/app/components/controls/Incrementer/Incrementer.tsx b/app/components/controls/Incrementer/Incrementer.tsx
new file mode 100644
index 0000000..8549036
--- /dev/null
+++ b/app/components/controls/Incrementer/Incrementer.tsx
@@ -0,0 +1,143 @@
+"use client";
+
+import { memo } from "react";
+
+export interface IncrementerProps {
+ value: number;
+ /** Minimum value (default `-Infinity`). */
+ min?: number;
+ /** Maximum value (default `Infinity`). */
+ max?: number;
+ /** Step size applied to +/- actions (default `1`). */
+ step?: number;
+ onChange: (_next: number) => void;
+ /**
+ * Optional formatter for the displayed value. Receives the raw number and
+ * should return the rendered content. Default: `String(value)`.
+ */
+ formatValue?: (_value: number) => React.ReactNode;
+ /**
+ * When true, the whole incrementer is non-interactive and the value renders
+ * in the "inactive" (tertiary) color per Figma.
+ */
+ disabled?: boolean;
+ /** Accessible label for the decrement button (default "Decrease"). */
+ decrementAriaLabel?: string;
+ /** Accessible label for the increment button (default "Increase"). */
+ incrementAriaLabel?: string;
+ className?: string;
+}
+
+const STEP_BUTTON_CLASSES =
+ "bg-[var(--color-surface-default-secondary,#141414)] text-[var(--color-content-default-primary,#fff)] inline-flex shrink-0 items-center justify-center overflow-clip rounded-[var(--measures-radius-full,9999px)] px-[var(--space-200,8px)] py-[var(--measures-spacing-150,6px)] transition-[background,color,transform] duration-200 ease-in-out hover:scale-[1.02] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary,#fff)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary,#000)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100";
+
+function MinusIcon() {
+ return (
+
+
+
+ );
+}
+
+function PlusIcon() {
+ return (
+
+
+
+ );
+}
+
+/**
+ * Figma: "Control / Incrementer" (`17857:30943`). A compact `[ - value + ]`
+ * row used for numeric step inputs (e.g. a percentage setting).
+ *
+ * For a labelled variant that matches "Control / Incrementer Block"
+ * (`19883:13283`), compose with {@link IncrementerBlock} instead.
+ */
+function IncrementerComponent({
+ value,
+ min = Number.NEGATIVE_INFINITY,
+ max = Number.POSITIVE_INFINITY,
+ step = 1,
+ onChange,
+ formatValue,
+ disabled = false,
+ decrementAriaLabel = "Decrease",
+ incrementAriaLabel = "Increase",
+ className = "",
+}: IncrementerProps) {
+ const clampedValue = Math.min(Math.max(value, min), max);
+ const atMin = clampedValue <= min;
+ const atMax = clampedValue >= max;
+
+ const decrement = () => {
+ if (disabled || atMin) return;
+ onChange(Math.max(min, clampedValue - step));
+ };
+ const increment = () => {
+ if (disabled || atMax) return;
+ onChange(Math.min(max, clampedValue + step));
+ };
+
+ const valueColor = disabled
+ ? "text-[color:var(--color-content-default-tertiary,#b4b4b4)]"
+ : "text-[color:var(--color-content-default-primary,#fff)]";
+
+ return (
+
+
+
+
+
+ {formatValue ? formatValue(clampedValue) : clampedValue}
+
+
+
+
+
+ );
+}
+
+IncrementerComponent.displayName = "Incrementer";
+
+export default memo(IncrementerComponent);
diff --git a/app/components/controls/Incrementer/IncrementerBlock.tsx b/app/components/controls/Incrementer/IncrementerBlock.tsx
new file mode 100644
index 0000000..5cd261b
--- /dev/null
+++ b/app/components/controls/Incrementer/IncrementerBlock.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import { memo } from "react";
+import Incrementer, { type IncrementerProps } from "./Incrementer";
+import InputLabel from "../../utility/InputLabel";
+import type {
+ InputLabelPaletteValue,
+ InputLabelSizeValue,
+} from "../../utility/InputLabel/InputLabel.types";
+
+export interface IncrementerBlockProps extends IncrementerProps {
+ /** Label text displayed above the incrementer. */
+ label: string;
+ /** Show the help "?" icon next to the label. Defaults to `true`. */
+ helpIcon?: boolean;
+ /**
+ * Helper text shown to the right of the label. Pass a string or `true` to
+ * render the default "Optional text".
+ */
+ helperText?: boolean | string;
+ /** Show an asterisk indicating a required field. */
+ asterisk?: boolean;
+ /**
+ * Size of the label (`"s"` or `"m"`). Defaults to `"s"` to match the Figma
+ * "Incrementer Block" spec.
+ */
+ labelSize?: InputLabelSizeValue;
+ /** Palette. Defaults to `"default"`. */
+ palette?: InputLabelPaletteValue;
+ /**
+ * Class applied to the root `` wrapping the label + incrementer. Use
+ * this to control the block's layout width (e.g. `w-full`).
+ */
+ blockClassName?: string;
+}
+
+/**
+ * Figma: "Control / Incrementer Block" (`19883:13283`). An `InputLabel` plus
+ * an {@link Incrementer} row, stacked with a 12px gap.
+ */
+function IncrementerBlockComponent({
+ label,
+ helpIcon = true,
+ helperText,
+ asterisk,
+ labelSize = "s",
+ palette = "default",
+ blockClassName = "",
+ className,
+ ...incrementerProps
+}: IncrementerBlockProps) {
+ return (
+
+
+
+
+ );
+}
+
+IncrementerBlockComponent.displayName = "IncrementerBlock";
+
+export default memo(IncrementerBlockComponent);
diff --git a/app/components/controls/Incrementer/index.tsx b/app/components/controls/Incrementer/index.tsx
new file mode 100644
index 0000000..c454d77
--- /dev/null
+++ b/app/components/controls/Incrementer/index.tsx
@@ -0,0 +1,5 @@
+export { default } from "./Incrementer";
+export { default as Incrementer } from "./Incrementer";
+export { default as IncrementerBlock } from "./IncrementerBlock";
+export type { IncrementerProps } from "./Incrementer";
+export type { IncrementerBlockProps } from "./IncrementerBlock";
diff --git a/app/create/components/ApplicableScopeField.tsx b/app/create/components/ApplicableScopeField.tsx
new file mode 100644
index 0000000..730bd93
--- /dev/null
+++ b/app/create/components/ApplicableScopeField.tsx
@@ -0,0 +1,142 @@
+"use client";
+
+/**
+ * Shared "Applicable Scope" field used by the `decision-approaches` and
+ * `conflict-management` create flow modals. Pairs an `InputLabel` with a
+ * horizontally-wrapping list of toggle-chips plus an inline "+ Add" affordance
+ * that reveals a pill text input for creating new scope values.
+ */
+
+import { memo, useState } from "react";
+import Chip from "../../components/controls/Chip";
+import InputLabel from "../../components/utility/InputLabel";
+
+export interface ApplicableScopeFieldProps {
+ /** Label rendered above the capsule row. */
+ label: string;
+ /** Text for the "+ Add …" affordance (e.g. "Add Applicable Scope"). */
+ addLabel: string;
+ /**
+ * The full list of chip values shown to the user. Each value is a unique
+ * string (chip label).
+ */
+ scopes: string[];
+ /** Values currently toggled on (rendered in the Chip "Selected" state). */
+ selectedScopes: string[];
+ /** Fired when a chip is clicked; caller toggles inclusion in `selectedScopes`. */
+ onToggleScope: (_scope: string) => void;
+ /**
+ * Fired when the user submits a new scope via the inline input. Duplicate
+ * values (already in `scopes`) are filtered out before the callback fires.
+ */
+ onAddScope: (_scope: string) => void;
+ /**
+ * Optional placeholder for the inline input. Defaults to `addLabel`.
+ */
+ inputPlaceholder?: string;
+ className?: string;
+}
+
+function ApplicableScopeFieldComponent({
+ label,
+ addLabel,
+ scopes,
+ selectedScopes,
+ onToggleScope,
+ onAddScope,
+ inputPlaceholder,
+ className = "",
+}: ApplicableScopeFieldProps) {
+ const [draft, setDraft] = useState("");
+ const [isAdding, setIsAdding] = useState(false);
+
+ const submitDraft = () => {
+ const trimmed = draft.trim();
+ if (!trimmed) {
+ setIsAdding(false);
+ setDraft("");
+ return;
+ }
+ if (!scopes.includes(trimmed)) {
+ onAddScope(trimmed);
+ }
+ setDraft("");
+ setIsAdding(false);
+ };
+
+ return (
+
+ );
+}
+
+function AddGlyph() {
+ return (
+
+
+
+ );
+}
+
+ApplicableScopeFieldComponent.displayName = "ApplicableScopeField";
+
+export default memo(ApplicableScopeFieldComponent);
diff --git a/app/create/components/ModalTextAreaField.tsx b/app/create/components/ModalTextAreaField.tsx
new file mode 100644
index 0000000..0b13c24
--- /dev/null
+++ b/app/create/components/ModalTextAreaField.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+/**
+ * Shared "labelled text area" field used by every create flow modal section.
+ * Pairs an `InputLabel` (with help icon) with a `TextArea` set to the embedded
+ * appearance — matching the Figma "Control / Text Area" pattern.
+ */
+
+import { memo } from "react";
+import TextArea from "../../components/controls/TextArea";
+import InputLabel from "../../components/utility/InputLabel";
+
+export interface ModalTextAreaFieldProps {
+ /** Label rendered above the text area. */
+ label: string;
+ /** Show the help "?" icon next to the label (default `true`). */
+ helpIcon?: boolean;
+ /** Current text value. */
+ value: string;
+ /** Fired on every change with the new value (no event). */
+ onChange: (_value: string) => void;
+ /** Optional rows for the underlying `
diff --git a/stories/controls/Chip.stories.js b/stories/controls/Chip.stories.js
new file mode 100644
index 0000000..0370303
--- /dev/null
+++ b/stories/controls/Chip.stories.js
@@ -0,0 +1,83 @@
+import Chip from "../../app/components/controls/Chip";
+
+export default {
+ title: "Components/Controls/Chip",
+ component: Chip,
+ parameters: {
+ layout: "centered",
+ },
+ argTypes: {
+ label: {
+ control: "text",
+ description: "Text displayed inside the chip",
+ },
+ state: {
+ control: "select",
+ options: ["unselected", "selected", "disabled", "custom"],
+ description: "Visual state of the chip",
+ },
+ palette: {
+ control: "select",
+ options: ["default", "inverse"],
+ description: "Color palette of the chip",
+ },
+ size: {
+ control: "select",
+ options: ["s", "m"],
+ description: "Size of the chip",
+ },
+ disabled: {
+ control: "boolean",
+ description: "Override the disabled behaviour independently of state",
+ },
+ onClick: { action: "clicked" },
+ onRemove: { action: "removed" },
+ onCheck: { action: "checked" },
+ onClose: { action: "closed" },
+ },
+};
+
+export const Default = {
+ args: {
+ label: "Worker cooperative",
+ state: "unselected",
+ palette: "default",
+ size: "m",
+ },
+};
+
+export const Selected = {
+ args: {
+ label: "Worker cooperative",
+ state: "selected",
+ palette: "default",
+ size: "m",
+ },
+};
+
+export const Disabled = {
+ args: {
+ label: "Worker cooperative",
+ state: "disabled",
+ palette: "default",
+ size: "m",
+ },
+};
+
+export const Inverse = {
+ args: {
+ label: "Worker cooperative",
+ state: "selected",
+ palette: "inverse",
+ size: "m",
+ },
+};
+
+export const Small = {
+ args: {
+ label: "Worker cooperative",
+ state: "unselected",
+ palette: "default",
+ size: "s",
+ },
+};
diff --git a/stories/controls/InputWithCounter.stories.js b/stories/controls/InputWithCounter.stories.js
new file mode 100644
index 0000000..e0ae38b
--- /dev/null
+++ b/stories/controls/InputWithCounter.stories.js
@@ -0,0 +1,82 @@
+import React from "react";
+import InputWithCounter from "../../app/components/controls/InputWithCounter";
+
+export default {
+ title: "Components/Controls/InputWithCounter",
+ component: InputWithCounter,
+ parameters: {
+ layout: "centered",
+ },
+ argTypes: {
+ label: {
+ control: "text",
+ description: "Label rendered above the input",
+ },
+ placeholder: {
+ control: "text",
+ description: "Placeholder text shown when value is empty",
+ },
+ value: {
+ control: "text",
+ description: "Current value of the input (controlled)",
+ },
+ maxLength: {
+ control: { type: "number", min: 1, max: 500, step: 1 },
+ description: "Maximum number of characters allowed",
+ },
+ showHelpIcon: {
+ control: "boolean",
+ description: "Whether to show the help icon next to the label",
+ },
+ onChange: { action: "changed" },
+ },
+};
+
+const Template = (args) => {
+ const [value, setValue] = React.useState(args.value ?? "");
+ return (
+
+ {
+ setValue(next);
+ args.onChange?.(next);
+ }}
+ />
+
+ );
+};
+
+export const Default = {
+ render: Template,
+ args: {
+ label: "Community name",
+ placeholder: "Enter a name",
+ value: "",
+ maxLength: 50,
+ showHelpIcon: false,
+ },
+};
+
+export const WithHelpIcon = {
+ render: Template,
+ args: {
+ label: "Community name",
+ placeholder: "Enter a name",
+ value: "",
+ maxLength: 50,
+ showHelpIcon: true,
+ },
+};
+
+export const WithInitialValue = {
+ render: Template,
+ args: {
+ label: "Community name",
+ placeholder: "Enter a name",
+ value: "My community",
+ maxLength: 30,
+ showHelpIcon: false,
+ },
+};
diff --git a/stories/controls/MultiSelect.stories.js b/stories/controls/MultiSelect.stories.js
new file mode 100644
index 0000000..1952354
--- /dev/null
+++ b/stories/controls/MultiSelect.stories.js
@@ -0,0 +1,85 @@
+import React from "react";
+import MultiSelect from "../../app/components/controls/MultiSelect";
+
+export default {
+ title: "Components/Controls/MultiSelect",
+ component: MultiSelect,
+ parameters: {
+ layout: "centered",
+ },
+ argTypes: {
+ label: {
+ control: "text",
+ description: "Label displayed above the chip set",
+ },
+ showHelpIcon: {
+ control: "boolean",
+ description: "Whether to show the help icon next to the label",
+ },
+ size: {
+ control: "select",
+ options: ["s", "m"],
+ description: "Size variant of the chips",
+ },
+ palette: {
+ control: "select",
+ options: ["default", "inverse"],
+ description: "Color palette applied to the chips",
+ },
+ addButton: {
+ control: "boolean",
+ description: "Whether to show the add button",
+ },
+ addButtonText: {
+ control: "text",
+ description: "Text rendered on the add button",
+ },
+ formHeader: {
+ control: "boolean",
+ description: "Whether to show the label/help-icon header",
+ },
+ onChipClick: { action: "chip-clicked" },
+ onAddClick: { action: "add-clicked" },
+ },
+};
+
+const defaultOptions = [
+ { id: "1", label: "Worker cooperative", state: "unselected" },
+ { id: "2", label: "Consumer cooperative", state: "selected" },
+ { id: "3", label: "Housing cooperative", state: "unselected" },
+ { id: "4", label: "Producer cooperative", state: "unselected" },
+];
+
+export const Default = {
+ args: {
+ label: "Organization type",
+ showHelpIcon: true,
+ size: "m",
+ palette: "default",
+ options: defaultOptions,
+ addButton: true,
+ addButtonText: "Add organization type",
+ formHeader: true,
+ },
+};
+
+export const Small = {
+ args: {
+ ...Default.args,
+ size: "s",
+ },
+};
+
+export const Inverse = {
+ args: {
+ ...Default.args,
+ palette: "inverse",
+ },
+};
+
+export const NoAddButton = {
+ args: {
+ ...Default.args,
+ addButton: false,
+ },
+};
diff --git a/stories/controls/RadioButton.stories.js b/stories/controls/RadioButton.stories.js
index 95ae82c..ee5237a 100644
--- a/stories/controls/RadioButton.stories.js
+++ b/stories/controls/RadioButton.stories.js
@@ -21,24 +21,13 @@ export default {
},
mode: {
control: "select",
- options: ["standard", "inverse", "Standard", "Inverse"],
- description:
- "Visual mode of the radio button (case-insensitive: accepts both lowercase and PascalCase)",
+ options: ["standard", "inverse"],
+ description: "Visual mode of the radio button",
},
state: {
control: "select",
- options: [
- "default",
- "hover",
- "focus",
- "selected",
- "Default",
- "Hover",
- "Focus",
- "Selected",
- ],
- description:
- "Interaction state for static display (case-insensitive: accepts both lowercase and PascalCase)",
+ options: ["default", "hover", "focus", "selected"],
+ description: "Interaction state for static display",
},
disabled: {
control: "boolean",
@@ -286,15 +275,15 @@ export const FigmaPascalCase = () => {
setStandardChecked(checked)}
/>
setInverseChecked(checked)}
/>
@@ -314,7 +303,7 @@ export const FigmaPascalCase = () => {
label="Inverse Mode (mixed) - still works"
checked={false}
mode="inverse"
- state="Default"
+ state="default"
/>
diff --git a/stories/create-flow/ApplicableScopeField.stories.js b/stories/create-flow/ApplicableScopeField.stories.js
index 13c7377..107417c 100644
--- a/stories/create-flow/ApplicableScopeField.stories.js
+++ b/stories/create-flow/ApplicableScopeField.stories.js
@@ -1,5 +1,5 @@
import React from "react";
-import ApplicableScopeField from "../../app/create/components/ApplicableScopeField";
+import ApplicableScopeField from "../../app/(app)/create/components/ApplicableScopeField";
export default {
title: "Create Flow/ApplicableScopeField",
diff --git a/stories/create-flow/ModalTextAreaField.stories.js b/stories/create-flow/ModalTextAreaField.stories.js
index d557fbe..6e17f72 100644
--- a/stories/create-flow/ModalTextAreaField.stories.js
+++ b/stories/create-flow/ModalTextAreaField.stories.js
@@ -1,5 +1,5 @@
import React from "react";
-import ModalTextAreaField from "../../app/create/components/ModalTextAreaField";
+import ModalTextAreaField from "../../app/(app)/create/components/ModalTextAreaField";
export default {
title: "Create Flow/ModalTextAreaField",
diff --git a/stories/localization/LanguageSwitcher.stories.js b/stories/localization/LanguageSwitcher.stories.js
new file mode 100644
index 0000000..51489eb
--- /dev/null
+++ b/stories/localization/LanguageSwitcher.stories.js
@@ -0,0 +1,19 @@
+import LanguageSwitcher from "../../app/components/localization/LanguageSwitcher";
+
+export default {
+ title: "Components/Localization/LanguageSwitcher",
+ component: LanguageSwitcher,
+ parameters: {
+ layout: "centered",
+ },
+ argTypes: {
+ className: {
+ control: "text",
+ description: "Optional wrapper className",
+ },
+ },
+};
+
+export const Default = {
+ args: {},
+};
diff --git a/stories/modals/Login.stories.tsx b/stories/modals/Login.stories.tsx
index b449be4..e240e2d 100644
--- a/stories/modals/Login.stories.tsx
+++ b/stories/modals/Login.stories.tsx
@@ -121,7 +121,7 @@ export const HeaderOverlayBlurred = {
),
};
-/** Matches `app/login/page.tsx`: dedicated route, solid yellow, no portal. */
+/** Matches `app/(app)/login/page.tsx`: dedicated route, solid yellow, no portal. */
export const FullPageRouteSolid = {
name: "Full-page route (/login — solid)",
parameters: {
diff --git a/stories/navigation/NavigationItem.stories.js b/stories/navigation/NavigationItem.stories.js
new file mode 100644
index 0000000..dc6785d
--- /dev/null
+++ b/stories/navigation/NavigationItem.stories.js
@@ -0,0 +1,75 @@
+import NavigationItem from "../../app/components/navigation/NavigationItem";
+
+export default {
+ title: "Components/Navigation/NavigationItem",
+ component: NavigationItem,
+ parameters: {
+ layout: "centered",
+ },
+ argTypes: {
+ href: {
+ control: "text",
+ description: "Anchor href",
+ },
+ variant: {
+ control: "select",
+ options: ["default"],
+ description: "Visual variant",
+ },
+ size: {
+ control: "select",
+ options: ["default", "xsmall"],
+ description: "Size variant",
+ },
+ disabled: {
+ control: "boolean",
+ description: "Disable interaction (renders as span)",
+ },
+ isActive: {
+ control: "boolean",
+ description: "Mark the item as currently active",
+ },
+ children: {
+ control: "text",
+ description: "Item label",
+ },
+ },
+};
+
+export const Default = {
+ args: {
+ children: "Templates",
+ href: "#",
+ variant: "default",
+ size: "default",
+ },
+};
+
+export const Active = {
+ args: {
+ children: "Templates",
+ href: "#",
+ variant: "default",
+ size: "default",
+ isActive: true,
+ },
+};
+
+export const Disabled = {
+ args: {
+ children: "Templates",
+ href: "#",
+ variant: "default",
+ size: "default",
+ disabled: true,
+ },
+};
+
+export const XSmall = {
+ args: {
+ children: "Templates",
+ href: "#",
+ variant: "default",
+ size: "xsmall",
+ },
+};
diff --git a/stories/pages/CommunicationMethodsPage.stories.js b/stories/pages/CommunicationMethodsPage.stories.js
index b85396c..ffd9d99 100644
--- a/stories/pages/CommunicationMethodsPage.stories.js
+++ b/stories/pages/CommunicationMethodsPage.stories.js
@@ -1,4 +1,4 @@
-import { CommunicationMethodsScreen } from "../../app/create/screens/card/CommunicationMethodsScreen";
+import { CommunicationMethodsScreen } from "../../app/(app)/create/screens/card/CommunicationMethodsScreen";
export default {
title: "Pages/Create Flow/Communication methods",
diff --git a/stories/pages/CompletedPage.stories.js b/stories/pages/CompletedPage.stories.js
index 21128b9..3e3ce00 100644
--- a/stories/pages/CompletedPage.stories.js
+++ b/stories/pages/CompletedPage.stories.js
@@ -1,4 +1,4 @@
-import { CompletedScreen } from "../../app/create/screens/completed/CompletedScreen";
+import { CompletedScreen } from "../../app/(app)/create/screens/completed/CompletedScreen";
export default {
title: "Pages/Create Flow/Completed",
diff --git a/stories/pages/ConfirmStakeholdersPage.stories.js b/stories/pages/ConfirmStakeholdersPage.stories.js
index adb22e0..85c2d0c 100644
--- a/stories/pages/ConfirmStakeholdersPage.stories.js
+++ b/stories/pages/ConfirmStakeholdersPage.stories.js
@@ -1,4 +1,4 @@
-import { ConfirmStakeholdersScreen } from "../../app/create/screens/select/ConfirmStakeholdersScreen";
+import { ConfirmStakeholdersScreen } from "../../app/(app)/create/screens/select/ConfirmStakeholdersScreen";
export default {
title: "Pages/Create Flow/Confirm stakeholders",
diff --git a/stories/pages/DecisionApproachesPage.stories.js b/stories/pages/DecisionApproachesPage.stories.js
index 1ab27b8..4385740 100644
--- a/stories/pages/DecisionApproachesPage.stories.js
+++ b/stories/pages/DecisionApproachesPage.stories.js
@@ -1,4 +1,4 @@
-import { DecisionApproachesScreen } from "../../app/create/screens/right-rail/DecisionApproachesScreen";
+import { DecisionApproachesScreen } from "../../app/(app)/create/screens/right-rail/DecisionApproachesScreen";
export default {
title: "Pages/Create Flow/Decision approaches",
diff --git a/stories/pages/FinalReviewPage.stories.js b/stories/pages/FinalReviewPage.stories.js
index 0653fe9..096cd2b 100644
--- a/stories/pages/FinalReviewPage.stories.js
+++ b/stories/pages/FinalReviewPage.stories.js
@@ -1,4 +1,4 @@
-import { FinalReviewScreen } from "../../app/create/screens/review/FinalReviewScreen";
+import { FinalReviewScreen } from "../../app/(app)/create/screens/review/FinalReviewScreen";
export default {
title: "Pages/Create Flow/Final review",
diff --git a/stories/pages/InformationalPage.stories.js b/stories/pages/InformationalPage.stories.js
index 21f4d34..6faa8c2 100644
--- a/stories/pages/InformationalPage.stories.js
+++ b/stories/pages/InformationalPage.stories.js
@@ -1,4 +1,4 @@
-import { InformationalScreen } from "../../app/create/screens/informational/InformationalScreen";
+import { InformationalScreen } from "../../app/(app)/create/screens/informational/InformationalScreen";
export default {
title: "Pages/Create/Informational",
diff --git a/stories/pages/ReviewPage.stories.js b/stories/pages/ReviewPage.stories.js
index b68ee0a..5e6ca46 100644
--- a/stories/pages/ReviewPage.stories.js
+++ b/stories/pages/ReviewPage.stories.js
@@ -1,4 +1,4 @@
-import { CommunityReviewScreen } from "../../app/create/screens/review/CommunityReviewScreen";
+import { CommunityReviewScreen } from "../../app/(app)/create/screens/review/CommunityReviewScreen";
export default {
title: "Pages/Create/Review",
diff --git a/stories/pages/SelectPage.stories.js b/stories/pages/SelectPage.stories.js
index a0c5307..b861666 100644
--- a/stories/pages/SelectPage.stories.js
+++ b/stories/pages/SelectPage.stories.js
@@ -1,4 +1,4 @@
-import { CommunitySizeSelectScreen } from "../../app/create/screens/select/CommunitySizeSelectScreen";
+import { CommunitySizeSelectScreen } from "../../app/(app)/create/screens/select/CommunitySizeSelectScreen";
export default {
title: "Pages/Create/CommunitySize",
diff --git a/stories/pages/TextPage.stories.js b/stories/pages/TextPage.stories.js
index 1510cea..b6ef156 100644
--- a/stories/pages/TextPage.stories.js
+++ b/stories/pages/TextPage.stories.js
@@ -1,4 +1,4 @@
-import { CreateFlowTextFieldScreen } from "../../app/create/screens/text/CreateFlowTextFieldScreen";
+import { CreateFlowTextFieldScreen } from "../../app/(app)/create/screens/text/CreateFlowTextFieldScreen";
export default {
title: "Pages/Create/CommunityName",
diff --git a/stories/pages/UploadPage.stories.js b/stories/pages/UploadPage.stories.js
index 387e999..9a1ef53 100644
--- a/stories/pages/UploadPage.stories.js
+++ b/stories/pages/UploadPage.stories.js
@@ -1,4 +1,4 @@
-import { CommunityUploadScreen } from "../../app/create/screens/upload/CommunityUploadScreen";
+import { CommunityUploadScreen } from "../../app/(app)/create/screens/upload/CommunityUploadScreen";
export default {
title: "Pages/Create/CommunityUpload",
diff --git a/stories/progress/ProportionBar.stories.js b/stories/progress/ProportionBar.stories.js
index 9907056..b5b1773 100644
--- a/stories/progress/ProportionBar.stories.js
+++ b/stories/progress/ProportionBar.stories.js
@@ -15,7 +15,7 @@ export default {
argTypes: {
variant: {
control: { type: "select" },
- options: ["default", "segmented", "Default", "Segmented"],
+ options: ["default", "segmented"],
description:
"Segmented: pill-shaped partial fills (create-flow footer / Figma).",
},
diff --git a/stories/sections/CommunityRuleDocument.stories.js b/stories/sections/CommunityRuleDocument.stories.js
new file mode 100644
index 0000000..f882157
--- /dev/null
+++ b/stories/sections/CommunityRuleDocument.stories.js
@@ -0,0 +1,58 @@
+import CommunityRuleDocument from "../../app/components/sections/CommunityRuleDocument";
+
+const sampleSections = [
+ {
+ categoryName: "Decision making",
+ entries: [
+ {
+ title: "How proposals pass",
+ body: "Important decisions require unanimous agreement. Proposals pass only if no serious objections remain.",
+ },
+ {
+ title: "Blocks",
+ body: "Anyone with a serious objection may block consensus and require further discussion.",
+ },
+ ],
+ },
+ {
+ categoryName: "Membership",
+ entries: [
+ {
+ title: "Joining",
+ body: "New members are welcomed by consensus of existing members.",
+ },
+ ],
+ },
+];
+
+export default {
+ title: "Components/Sections/CommunityRuleDocument",
+ component: CommunityRuleDocument,
+ parameters: {
+ layout: "padded",
+ },
+ argTypes: {
+ sections: {
+ control: false,
+ description: "Document sections, each with a categoryName and entries.",
+ },
+ useCardStyle: {
+ control: "boolean",
+ description: "When true, wraps the document in a white card with a teal bar",
+ },
+ },
+};
+
+export const Default = {
+ args: {
+ sections: sampleSections,
+ useCardStyle: false,
+ },
+};
+
+export const CardStyle = {
+ args: {
+ sections: sampleSections,
+ useCardStyle: true,
+ },
+};
diff --git a/stories/sections/GovernanceTemplateGrid.stories.js b/stories/sections/GovernanceTemplateGrid.stories.js
new file mode 100644
index 0000000..1762e20
--- /dev/null
+++ b/stories/sections/GovernanceTemplateGrid.stories.js
@@ -0,0 +1,29 @@
+import { GovernanceTemplateGrid } from "../../app/components/sections/GovernanceTemplateGrid";
+import { GOVERNANCE_TEMPLATE_CATALOG } from "../../lib/templates/governanceTemplateCatalog";
+
+export default {
+ title: "Components/Sections/GovernanceTemplateGrid",
+ component: GovernanceTemplateGrid,
+ parameters: {
+ layout: "fullscreen",
+ },
+ argTypes: {
+ entries: {
+ control: false,
+ description: "Catalog entries to render as a 2-column grid of RuleCards",
+ },
+ onTemplateClick: { action: "template-clicked" },
+ },
+};
+
+export const Default = {
+ args: {
+ entries: GOVERNANCE_TEMPLATE_CATALOG.slice(0, 4),
+ },
+};
+
+export const SingleEntry = {
+ args: {
+ entries: GOVERNANCE_TEMPLATE_CATALOG.slice(0, 1),
+ },
+};
diff --git a/stories/utility/ErrorBoundary.stories.js b/stories/utility/ErrorBoundary.stories.js
deleted file mode 100644
index a2eccf4..0000000
--- a/stories/utility/ErrorBoundary.stories.js
+++ /dev/null
@@ -1,40 +0,0 @@
-import ErrorBoundary from "../../app/components/utility/ErrorBoundary";
-
-export default {
- title: "Components/Utility/ErrorBoundary",
- component: ErrorBoundary,
- parameters: {
- layout: "centered",
- docs: {
- description: {
- component:
- "An error boundary component that catches JavaScript errors in its child component tree. Displays a fallback UI when errors occur and logs error information for debugging.",
- },
- },
- },
- argTypes: {
- children: {
- control: { type: "text" },
- description: "Child components to wrap with error boundary",
- },
- },
-};
-
-export const Default = {
- args: {
- children:
Normal content
,
- },
-};
-
-export const WithError = {
- render: () => {
- const ThrowError = () => {
- throw new Error("Test error for ErrorBoundary");
- };
- return (
-
-
-
- );
- },
-};
diff --git a/stories/utility/ModalFooter.stories.js b/stories/utility/ModalFooter.stories.js
new file mode 100644
index 0000000..983d5ca
--- /dev/null
+++ b/stories/utility/ModalFooter.stories.js
@@ -0,0 +1,71 @@
+import ModalFooter from "../../app/components/utility/ModalFooter";
+
+export default {
+ title: "Components/Utility/ModalFooter",
+ component: ModalFooter,
+ parameters: {
+ layout: "fullscreen",
+ },
+ argTypes: {
+ showBackButton: {
+ control: "boolean",
+ description: "Whether to render the back button on the left",
+ },
+ showNextButton: {
+ control: "boolean",
+ description: "Whether to render the next button on the right",
+ },
+ backButtonText: {
+ control: "text",
+ description: "Override text for the back button",
+ },
+ nextButtonText: {
+ control: "text",
+ description: "Override text for the next button",
+ },
+ nextButtonDisabled: {
+ control: "boolean",
+ description: "Whether the next button is disabled",
+ },
+ currentStep: {
+ control: { type: "number", min: 1, max: 10, step: 1 },
+ description: "Current step (used by the centered Stepper)",
+ },
+ totalSteps: {
+ control: { type: "number", min: 1, max: 10, step: 1 },
+ description: "Total number of steps",
+ },
+ stepper: {
+ control: "boolean",
+ description: "Whether to render the centered stepper",
+ },
+ onBack: { action: "back-clicked" },
+ onNext: { action: "next-clicked" },
+ },
+};
+
+export const Default = {
+ args: {
+ showBackButton: true,
+ showNextButton: true,
+ currentStep: 2,
+ totalSteps: 4,
+ },
+};
+
+export const NextDisabled = {
+ args: {
+ showBackButton: true,
+ showNextButton: true,
+ nextButtonDisabled: true,
+ currentStep: 1,
+ totalSteps: 4,
+ },
+};
+
+export const NextOnly = {
+ args: {
+ showBackButton: false,
+ showNextButton: true,
+ },
+};
diff --git a/stories/utility/ModalHeader.stories.js b/stories/utility/ModalHeader.stories.js
new file mode 100644
index 0000000..12498ee
--- /dev/null
+++ b/stories/utility/ModalHeader.stories.js
@@ -0,0 +1,42 @@
+import ModalHeader from "../../app/components/utility/ModalHeader";
+
+export default {
+ title: "Components/Utility/ModalHeader",
+ component: ModalHeader,
+ parameters: {
+ layout: "fullscreen",
+ },
+ argTypes: {
+ showCloseButton: {
+ control: "boolean",
+ description: "Whether to render the close button on the left",
+ },
+ showMoreOptionsButton: {
+ control: "boolean",
+ description: "Whether to render the more-options button on the right",
+ },
+ onClose: { action: "close-clicked" },
+ onMoreOptions: { action: "more-options-clicked" },
+ },
+};
+
+export const Default = {
+ args: {
+ showCloseButton: true,
+ showMoreOptionsButton: true,
+ },
+};
+
+export const CloseOnly = {
+ args: {
+ showCloseButton: true,
+ showMoreOptionsButton: false,
+ },
+};
+
+export const MoreOptionsOnly = {
+ args: {
+ showCloseButton: false,
+ showMoreOptionsButton: true,
+ },
+};
diff --git a/stories/utility/Separator.stories.js b/stories/utility/Separator.stories.js
new file mode 100644
index 0000000..f708afe
--- /dev/null
+++ b/stories/utility/Separator.stories.js
@@ -0,0 +1,28 @@
+import Separator from "../../app/components/utility/Separator";
+
+export default {
+ title: "Components/Utility/Separator",
+ component: Separator,
+ parameters: {
+ layout: "padded",
+ },
+ argTypes: {},
+};
+
+export const Default = {
+ render: () => (
+
+
+
+ ),
+};
+
+export const InContext = {
+ render: () => (
+
+
Above the separator
+
+
Below the separator
+
+ ),
+};
diff --git a/tests/components/ApplicableScopeField.test.tsx b/tests/components/ApplicableScopeField.test.tsx
index 0dee4b0..7daaa2c 100644
--- a/tests/components/ApplicableScopeField.test.tsx
+++ b/tests/components/ApplicableScopeField.test.tsx
@@ -3,7 +3,7 @@ import { describe, it, expect, vi } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom/vitest";
-import ApplicableScopeField from "../../app/create/components/ApplicableScopeField";
+import ApplicableScopeField from "../../app/(app)/create/components/ApplicableScopeField";
import { componentTestSuite } from "../utils/componentTestSuite";
import { renderWithProviders } from "../utils/test-utils";
diff --git a/tests/components/Button.test.tsx b/tests/components/Button.test.tsx
index a5a9957..4cd964d 100644
--- a/tests/components/Button.test.tsx
+++ b/tests/components/Button.test.tsx
@@ -39,6 +39,7 @@ const config: ComponentTestSuiteConfig
= {
componentTestSuite(config);
+// Pure presentational; no provider context needed.
describe("Button (behavioral tests)", () => {
it("calls onClick when clicked", async () => {
const user = userEvent.setup();
diff --git a/tests/components/Chip.test.tsx b/tests/components/Chip.test.tsx
new file mode 100644
index 0000000..5463f39
--- /dev/null
+++ b/tests/components/Chip.test.tsx
@@ -0,0 +1,33 @@
+import { describe } from "vitest";
+import {
+ componentTestSuite,
+ type ComponentTestSuiteConfig,
+} from "../utils/componentTestSuite";
+import Chip from "../../app/components/controls/Chip";
+
+type Props = React.ComponentProps;
+
+const config: ComponentTestSuiteConfig = {
+ component: Chip,
+ name: "Chip",
+ props: {
+ label: "Worker cooperative",
+ state: "unselected",
+ palette: "default",
+ size: "m",
+ } as Props,
+ primaryRole: "button",
+ testCases: {
+ renders: true,
+ accessibility: true,
+ keyboardNavigation: true,
+ disabledState: true,
+ },
+ states: {
+ disabledProps: { disabled: true, state: "disabled" },
+ },
+};
+
+describe("Chip", () => {
+ componentTestSuite(config);
+});
diff --git a/tests/components/CommunityRuleDocument.test.tsx b/tests/components/CommunityRuleDocument.test.tsx
new file mode 100644
index 0000000..e52aa17
--- /dev/null
+++ b/tests/components/CommunityRuleDocument.test.tsx
@@ -0,0 +1,37 @@
+import { describe } from "vitest";
+import {
+ componentTestSuite,
+ type ComponentTestSuiteConfig,
+} from "../utils/componentTestSuite";
+import CommunityRuleDocument from "../../app/components/sections/CommunityRuleDocument";
+
+type Props = React.ComponentProps;
+
+const sampleSections = [
+ {
+ categoryName: "Decision making",
+ entries: [
+ {
+ title: "How proposals pass",
+ body: "Important decisions require unanimous agreement.",
+ },
+ ],
+ },
+];
+
+const config: ComponentTestSuiteConfig = {
+ component: CommunityRuleDocument,
+ name: "CommunityRuleDocument",
+ props: {
+ sections: sampleSections,
+ } as Props,
+ requiredProps: ["sections"],
+ testCases: {
+ renders: true,
+ accessibility: true,
+ },
+};
+
+describe("CommunityRuleDocument", () => {
+ componentTestSuite(config);
+});
diff --git a/tests/components/CompletedPage.test.tsx b/tests/components/CompletedPage.test.tsx
index 4cc7474..cc30bd0 100644
--- a/tests/components/CompletedPage.test.tsx
+++ b/tests/components/CompletedPage.test.tsx
@@ -1,7 +1,7 @@
import { describe, it, expect } from "vitest";
import { renderWithProviders as render, screen } from "../utils/test-utils";
import "@testing-library/jest-dom/vitest";
-import { CompletedScreen } from "../../app/create/screens/completed/CompletedScreen";
+import { CompletedScreen } from "../../app/(app)/create/screens/completed/CompletedScreen";
describe("CompletedScreen", () => {
it("renders without crashing", () => {
diff --git a/tests/components/ConfirmStakeholdersPage.test.tsx b/tests/components/ConfirmStakeholdersPage.test.tsx
index cdde162..8f85373 100644
--- a/tests/components/ConfirmStakeholdersPage.test.tsx
+++ b/tests/components/ConfirmStakeholdersPage.test.tsx
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithProviders as render, screen } from "../utils/test-utils";
import "@testing-library/jest-dom/vitest";
-import { ConfirmStakeholdersScreen } from "../../app/create/screens/select/ConfirmStakeholdersScreen";
+import { ConfirmStakeholdersScreen } from "../../app/(app)/create/screens/select/ConfirmStakeholdersScreen";
describe("ConfirmStakeholdersScreen", () => {
it("renders title and description", () => {
diff --git a/tests/components/ContentBanner.test.tsx b/tests/components/ContentBanner.test.tsx
index 0c73395..c4c7321 100644
--- a/tests/components/ContentBanner.test.tsx
+++ b/tests/components/ContentBanner.test.tsx
@@ -43,6 +43,7 @@ const mockPost: BlogPost = {
lastModified: new Date("2025-04-15"),
};
+// Pure presentational; no provider context needed.
describe("ContentBanner", () => {
it("renders without crashing", () => {
render( );
diff --git a/tests/components/ContextMenu.test.tsx b/tests/components/ContextMenu.test.tsx
index b5ac1f5..c2899df 100644
--- a/tests/components/ContextMenu.test.tsx
+++ b/tests/components/ContextMenu.test.tsx
@@ -1,6 +1,6 @@
import React from "react";
-import ContextMenu from "../../app/components/ContextMenu/ContextMenu";
-import ContextMenuItem from "../../app/components/ContextMenu/ContextMenuItem";
+import ContextMenu from "../../app/components/modals/ContextMenu/ContextMenu";
+import ContextMenuItem from "../../app/components/modals/ContextMenuItem";
import { componentTestSuite } from "../utils/componentTestSuite";
type ContextMenuProps = React.ComponentProps;
diff --git a/tests/components/ContextMenuItem.test.tsx b/tests/components/ContextMenuItem.test.tsx
index e14aa43..bdb27f3 100644
--- a/tests/components/ContextMenuItem.test.tsx
+++ b/tests/components/ContextMenuItem.test.tsx
@@ -1,5 +1,5 @@
import React from "react";
-import ContextMenuItem from "../../app/components/ContextMenu/ContextMenuItem";
+import ContextMenuItem from "../../app/components/modals/ContextMenuItem";
import { componentTestSuite } from "../utils/componentTestSuite";
type ContextMenuItemProps = React.ComponentProps;
diff --git a/tests/components/CoreValuesSelectScreen.test.tsx b/tests/components/CoreValuesSelectScreen.test.tsx
index c6ddaf7..a4ca54b 100644
--- a/tests/components/CoreValuesSelectScreen.test.tsx
+++ b/tests/components/CoreValuesSelectScreen.test.tsx
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, fireEvent, waitFor, within } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { renderWithProviders } from "../utils/test-utils";
-import { CoreValuesSelectScreen } from "../../app/create/screens/select/CoreValuesSelectScreen";
+import { CoreValuesSelectScreen } from "../../app/(app)/create/screens/select/CoreValuesSelectScreen";
describe("CoreValuesSelectScreen", () => {
beforeEach(() => {
diff --git a/tests/components/CreateFlowFooter.test.tsx b/tests/components/CreateFlowFooter.test.tsx
index 947d66e..fa8d5ae 100644
--- a/tests/components/CreateFlowFooter.test.tsx
+++ b/tests/components/CreateFlowFooter.test.tsx
@@ -35,6 +35,7 @@ const config: ComponentTestSuiteConfig = {
componentTestSuite(config);
+// Pure presentational; no provider context needed (no useMessages/useAuthModal/useCreateFlow consumers).
describe("CreateFlowFooter (behavioral tests)", () => {
it("renders Back button", () => {
render( );
diff --git a/tests/components/FinalReviewPage.test.tsx b/tests/components/FinalReviewPage.test.tsx
index 231bb15..364c46a 100644
--- a/tests/components/FinalReviewPage.test.tsx
+++ b/tests/components/FinalReviewPage.test.tsx
@@ -6,8 +6,8 @@ import {
waitFor,
} from "../utils/test-utils";
import "@testing-library/jest-dom/vitest";
-import { FinalReviewScreen } from "../../app/create/screens/review/FinalReviewScreen";
-import { useCreateFlow } from "../../app/create/context/CreateFlowContext";
+import { FinalReviewScreen } from "../../app/(app)/create/screens/review/FinalReviewScreen";
+import { useCreateFlow } from "../../app/(app)/create/context/CreateFlowContext";
const FALLBACK_CARD_TITLE = "Your community";
const FALLBACK_CARD_DESCRIPTION_SNIPPET =
diff --git a/tests/components/GovernanceTemplateGrid.test.tsx b/tests/components/GovernanceTemplateGrid.test.tsx
new file mode 100644
index 0000000..8c61694
--- /dev/null
+++ b/tests/components/GovernanceTemplateGrid.test.tsx
@@ -0,0 +1,28 @@
+import { describe, vi } from "vitest";
+import {
+ componentTestSuite,
+ type ComponentTestSuiteConfig,
+} from "../utils/componentTestSuite";
+import { GovernanceTemplateGrid } from "../../app/components/sections/GovernanceTemplateGrid";
+import { GOVERNANCE_TEMPLATE_CATALOG } from "../../lib/templates/governanceTemplateCatalog";
+
+type Props = React.ComponentProps;
+
+const config: ComponentTestSuiteConfig = {
+ component: GovernanceTemplateGrid,
+ name: "GovernanceTemplateGrid",
+ props: {
+ entries: GOVERNANCE_TEMPLATE_CATALOG.slice(0, 2),
+ onTemplateClick: vi.fn(),
+ } as Props,
+ requiredProps: ["entries", "onTemplateClick"],
+ primaryRole: "button",
+ testCases: {
+ renders: true,
+ accessibility: true,
+ },
+};
+
+describe("GovernanceTemplateGrid", () => {
+ componentTestSuite(config);
+});
diff --git a/tests/components/HeaderLockup.test.tsx b/tests/components/HeaderLockup.test.tsx
index 3a8e273..4c8912d 100644
--- a/tests/components/HeaderLockup.test.tsx
+++ b/tests/components/HeaderLockup.test.tsx
@@ -36,6 +36,7 @@ const config: ComponentTestSuiteConfig = {
componentTestSuite(config);
+// Pure presentational; no provider context needed.
describe("HeaderLockup (behavioral tests)", () => {
it("renders title", () => {
render( );
@@ -84,8 +85,8 @@ describe("HeaderLockup (behavioral tests)", () => {
expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument();
});
- it("accepts PascalCase props", () => {
- render( );
+ it("accepts justification and size props", () => {
+ render( );
expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument();
});
});
diff --git a/tests/components/Icon.test.tsx b/tests/components/Icon.test.tsx
new file mode 100644
index 0000000..27921f9
--- /dev/null
+++ b/tests/components/Icon.test.tsx
@@ -0,0 +1,26 @@
+import { describe } from "vitest";
+import {
+ componentTestSuite,
+ type ComponentTestSuiteConfig,
+} from "../utils/componentTestSuite";
+import { Icon } from "../../app/components/asset";
+
+type Props = React.ComponentProps;
+
+const config: ComponentTestSuiteConfig = {
+ component: Icon,
+ name: "Icon",
+ props: {
+ name: "exclamation",
+ size: 24,
+ } as Props,
+ requiredProps: ["name"],
+ testCases: {
+ renders: true,
+ accessibility: true,
+ },
+};
+
+describe("Icon", () => {
+ componentTestSuite(config);
+});
diff --git a/tests/components/IconCard.test.tsx b/tests/components/IconCard.test.tsx
index d4e9859..c39bbe0 100644
--- a/tests/components/IconCard.test.tsx
+++ b/tests/components/IconCard.test.tsx
@@ -38,6 +38,7 @@ const config: ComponentTestSuiteConfig = {
componentTestSuite(config);
+// Pure presentational; no provider context needed.
describe("IconCard (behavioral tests)", () => {
it("calls onClick when clicked", () => {
const handleClick = vi.fn();
diff --git a/tests/components/InformationalPage.test.tsx b/tests/components/InformationalPage.test.tsx
index 0d2f622..a0b8d98 100644
--- a/tests/components/InformationalPage.test.tsx
+++ b/tests/components/InformationalPage.test.tsx
@@ -1,7 +1,7 @@
import { describe, it, expect } from "vitest";
import { renderWithProviders as render, screen } from "../utils/test-utils";
import "@testing-library/jest-dom/vitest";
-import { InformationalScreen } from "../../app/create/screens/informational/InformationalScreen";
+import { InformationalScreen } from "../../app/(app)/create/screens/informational/InformationalScreen";
describe("InformationalScreen", () => {
it("renders without crashing", () => {
diff --git a/tests/components/InputLabel.test.tsx b/tests/components/InputLabel.test.tsx
index f59966d..c177dbe 100644
--- a/tests/components/InputLabel.test.tsx
+++ b/tests/components/InputLabel.test.tsx
@@ -17,16 +17,16 @@ const config: ComponentTestSuiteConfig = {
helpIcon: false,
asterisk: false,
helperText: false,
- size: "S",
- palette: "Default",
+ size: "s",
+ palette: "default",
} as Props,
requiredProps: ["label"],
optionalProps: {
helpIcon: true,
asterisk: true,
helperText: true,
- size: "M",
- palette: "Inverse",
+ size: "m",
+ palette: "inverse",
},
primaryRole: undefined, // InputLabel is not directly interactive
testCases: {
@@ -67,28 +67,28 @@ describe("InputLabel – behaviour specifics", () => {
expect(screen.getByText("Custom helper")).toBeInTheDocument();
});
- it("applies size S styling", () => {
- render( );
+ it("applies size s styling", () => {
+ render( );
const label = screen.getByText("Test Label");
expect(label).toHaveClass("text-[length:var(--sizing-350,14px)]");
});
- it("applies size M styling", () => {
- render( );
+ it("applies size m styling", () => {
+ render( );
const label = screen.getByText("Test Label");
expect(label).toHaveClass("text-[length:var(--sizing-400,16px)]");
});
- it("applies Default palette styling", () => {
- render( );
+ it("applies default palette styling", () => {
+ render( );
const label = screen.getByText("Test Label");
expect(label).toHaveClass(
"text-[color:var(--color-content-default-secondary,#d2d2d2)]",
);
});
- it("applies Inverse palette styling", () => {
- render( );
+ it("applies inverse palette styling", () => {
+ render( );
const label = screen.getByText("Test Label");
expect(label).toHaveClass(
"text-[color:var(--color-content-inverse-secondary,#1f1f1f)]",
diff --git a/tests/components/InputWithCounter.test.tsx b/tests/components/InputWithCounter.test.tsx
new file mode 100644
index 0000000..93bd3a5
--- /dev/null
+++ b/tests/components/InputWithCounter.test.tsx
@@ -0,0 +1,31 @@
+import { describe, vi } from "vitest";
+import {
+ componentTestSuite,
+ type ComponentTestSuiteConfig,
+} from "../utils/componentTestSuite";
+import InputWithCounter from "../../app/components/controls/InputWithCounter";
+
+type Props = React.ComponentProps;
+
+const config: ComponentTestSuiteConfig = {
+ component: InputWithCounter,
+ name: "InputWithCounter",
+ props: {
+ label: "Community name",
+ placeholder: "Enter a name",
+ value: "",
+ onChange: vi.fn(),
+ maxLength: 50,
+ showHelpIcon: false,
+ } as Props,
+ requiredProps: ["value", "onChange", "maxLength"],
+ primaryRole: "textbox",
+ testCases: {
+ renders: true,
+ accessibility: true,
+ },
+};
+
+describe("InputWithCounter", () => {
+ componentTestSuite(config);
+});
diff --git a/tests/components/LanguageSwitcher.test.tsx b/tests/components/LanguageSwitcher.test.tsx
new file mode 100644
index 0000000..daff467
--- /dev/null
+++ b/tests/components/LanguageSwitcher.test.tsx
@@ -0,0 +1,23 @@
+import { describe } from "vitest";
+import {
+ componentTestSuite,
+ type ComponentTestSuiteConfig,
+} from "../utils/componentTestSuite";
+import LanguageSwitcher from "../../app/components/localization/LanguageSwitcher";
+
+type Props = React.ComponentProps;
+
+const config: ComponentTestSuiteConfig = {
+ component: LanguageSwitcher,
+ name: "LanguageSwitcher",
+ props: {} as Props,
+ primaryRole: "combobox",
+ testCases: {
+ renders: true,
+ accessibility: true,
+ },
+};
+
+describe("LanguageSwitcher", () => {
+ componentTestSuite(config);
+});
diff --git a/tests/components/LoginForm.test.tsx b/tests/components/LoginForm.test.tsx
index 8a56e58..4ae6ab9 100644
--- a/tests/components/LoginForm.test.tsx
+++ b/tests/components/LoginForm.test.tsx
@@ -32,10 +32,10 @@ vi.mock("../../lib/create/api", () => ({
requestMagicLink: vi.fn(),
}));
-vi.mock("../../app/create/utils/anonymousDraftStorage", async (importOriginal) => {
+vi.mock("../../app/(app)/create/utils/anonymousDraftStorage", async (importOriginal) => {
const actual =
await importOriginal<
- typeof import("../../app/create/utils/anonymousDraftStorage")
+ typeof import("../../app/(app)/create/utils/anonymousDraftStorage")
>();
return {
...actual,
@@ -44,7 +44,7 @@ vi.mock("../../app/create/utils/anonymousDraftStorage", async (importOriginal) =
});
import { requestMagicLink } from "../../lib/create/api";
-import { setTransferPendingFlag } from "../../app/create/utils/anonymousDraftStorage";
+import { setTransferPendingFlag } from "../../app/(app)/create/utils/anonymousDraftStorage";
function renderLoginForm() {
return renderWithProviders(
diff --git a/tests/components/Logo.test.tsx b/tests/components/Logo.test.tsx
index ffcbd66..7969ec7 100644
--- a/tests/components/Logo.test.tsx
+++ b/tests/components/Logo.test.tsx
@@ -27,6 +27,7 @@ const config: ComponentTestSuiteConfig = {
componentTestSuite(config);
+// Pure presentational; no provider context needed.
describe("Logo (behavioral tests)", () => {
it("renders as a link to home", () => {
render( );
diff --git a/tests/components/ModalFooter.test.tsx b/tests/components/ModalFooter.test.tsx
new file mode 100644
index 0000000..80a7545
--- /dev/null
+++ b/tests/components/ModalFooter.test.tsx
@@ -0,0 +1,30 @@
+import { describe, vi } from "vitest";
+import {
+ componentTestSuite,
+ type ComponentTestSuiteConfig,
+} from "../utils/componentTestSuite";
+import ModalFooter from "../../app/components/utility/ModalFooter";
+
+type Props = React.ComponentProps;
+
+const config: ComponentTestSuiteConfig = {
+ component: ModalFooter,
+ name: "ModalFooter",
+ props: {
+ showBackButton: true,
+ showNextButton: true,
+ onBack: vi.fn(),
+ onNext: vi.fn(),
+ currentStep: 2,
+ totalSteps: 4,
+ } as Props,
+ primaryRole: "button",
+ testCases: {
+ renders: true,
+ accessibility: true,
+ },
+};
+
+describe("ModalFooter", () => {
+ componentTestSuite(config);
+});
diff --git a/tests/components/ModalHeader.test.tsx b/tests/components/ModalHeader.test.tsx
new file mode 100644
index 0000000..6ed1ed8
--- /dev/null
+++ b/tests/components/ModalHeader.test.tsx
@@ -0,0 +1,28 @@
+import { describe, vi } from "vitest";
+import {
+ componentTestSuite,
+ type ComponentTestSuiteConfig,
+} from "../utils/componentTestSuite";
+import ModalHeader from "../../app/components/utility/ModalHeader";
+
+type Props = React.ComponentProps;
+
+const config: ComponentTestSuiteConfig = {
+ component: ModalHeader,
+ name: "ModalHeader",
+ props: {
+ showCloseButton: true,
+ showMoreOptionsButton: true,
+ onClose: vi.fn(),
+ onMoreOptions: vi.fn(),
+ } as Props,
+ primaryRole: "button",
+ testCases: {
+ renders: true,
+ accessibility: true,
+ },
+};
+
+describe("ModalHeader", () => {
+ componentTestSuite(config);
+});
diff --git a/tests/components/ModalTextAreaField.test.tsx b/tests/components/ModalTextAreaField.test.tsx
index 98a5bd9..043b4b5 100644
--- a/tests/components/ModalTextAreaField.test.tsx
+++ b/tests/components/ModalTextAreaField.test.tsx
@@ -3,7 +3,7 @@ import { describe, it, expect, vi } from "vitest";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom/vitest";
-import ModalTextAreaField from "../../app/create/components/ModalTextAreaField";
+import ModalTextAreaField from "../../app/(app)/create/components/ModalTextAreaField";
import { componentTestSuite } from "../utils/componentTestSuite";
import { renderWithProviders } from "../utils/test-utils";
diff --git a/tests/components/MultiSelect.test.tsx b/tests/components/MultiSelect.test.tsx
index 1f2b376..464ddb0 100644
--- a/tests/components/MultiSelect.test.tsx
+++ b/tests/components/MultiSelect.test.tsx
@@ -11,8 +11,8 @@ import {
type Props = React.ComponentProps;
const defaultChipOptions = [
- { id: "1", label: "Option 1", state: "Unselected" as const },
- { id: "2", label: "Option 2", state: "Selected" as const },
+ { id: "1", label: "Option 1", state: "unselected" as const },
+ { id: "2", label: "Option 2", state: "selected" as const },
];
const config: ComponentTestSuiteConfig = {
@@ -21,8 +21,8 @@ const config: ComponentTestSuiteConfig = {
props: {
label: "Test Label",
showHelpIcon: false,
- size: "S",
- palette: "Default",
+ size: "s",
+ palette: "default",
options: defaultChipOptions,
addButton: true,
addButtonText: "",
@@ -31,8 +31,8 @@ const config: ComponentTestSuiteConfig = {
optionalProps: {
label: "Optional Label",
showHelpIcon: true,
- size: "M",
- palette: "Inverse",
+ size: "m",
+ palette: "inverse",
onChipClick: vi.fn(),
onAddClick: vi.fn(),
addButton: false,
@@ -144,7 +144,7 @@ describe("MultiSelect – behaviour specifics", () => {
it("handles custom chip confirm", async () => {
const handleConfirm = vi.fn();
const customOptions = [
- { id: "custom-1", label: "", state: "Custom" as const },
+ { id: "custom-1", label: "", state: "custom" as const },
];
render(
{
it("handles custom chip close", async () => {
const handleClose = vi.fn();
const customOptions = [
- { id: "custom-1", label: "", state: "Custom" as const },
+ { id: "custom-1", label: "", state: "custom" as const },
];
render(
,
diff --git a/tests/components/NavigationItem.test.tsx b/tests/components/NavigationItem.test.tsx
new file mode 100644
index 0000000..f05d24c
--- /dev/null
+++ b/tests/components/NavigationItem.test.tsx
@@ -0,0 +1,29 @@
+import { describe } from "vitest";
+import {
+ componentTestSuite,
+ type ComponentTestSuiteConfig,
+} from "../utils/componentTestSuite";
+import NavigationItem from "../../app/components/navigation/NavigationItem";
+
+type Props = React.ComponentProps;
+
+const config: ComponentTestSuiteConfig = {
+ component: NavigationItem,
+ name: "NavigationItem",
+ props: {
+ children: "Templates",
+ href: "#",
+ variant: "default",
+ size: "default",
+ } as Props,
+ primaryRole: "link",
+ testCases: {
+ renders: true,
+ accessibility: true,
+ keyboardNavigation: true,
+ },
+};
+
+describe("NavigationItem", () => {
+ componentTestSuite(config);
+});
diff --git a/tests/components/NumberedList.test.tsx b/tests/components/NumberedList.test.tsx
index 8e8f2f8..0b9b958 100644
--- a/tests/components/NumberedList.test.tsx
+++ b/tests/components/NumberedList.test.tsx
@@ -49,6 +49,7 @@ const config: ComponentTestSuiteConfig = {
componentTestSuite(config);
+// Pure presentational; no provider context needed.
describe("NumberedList (behavioral tests)", () => {
it("renders all items", () => {
render( );
diff --git a/tests/components/ProportionBar.test.tsx b/tests/components/ProportionBar.test.tsx
index abd28bb..c6fdd18 100644
--- a/tests/components/ProportionBar.test.tsx
+++ b/tests/components/ProportionBar.test.tsx
@@ -36,6 +36,7 @@ const config: ComponentTestSuiteConfig = {
componentTestSuite(config);
+// Pure presentational; no provider context needed.
describe("ProportionBar (behavioral tests)", () => {
it("renders proportion bar with correct progress value", () => {
render( );
diff --git a/tests/components/RelatedArticles.test.tsx b/tests/components/RelatedArticles.test.tsx
index 65a2016..a0ecf58 100644
--- a/tests/components/RelatedArticles.test.tsx
+++ b/tests/components/RelatedArticles.test.tsx
@@ -63,6 +63,7 @@ const mockPosts: BlogPost[] = [
},
];
+// Pure presentational; no provider context needed (mocked thumbnail + useIsMobile).
describe("RelatedArticles", () => {
it("renders without crashing", () => {
render(
diff --git a/tests/components/ReviewPage.test.tsx b/tests/components/ReviewPage.test.tsx
index 612f2f9..880db9b 100644
--- a/tests/components/ReviewPage.test.tsx
+++ b/tests/components/ReviewPage.test.tsx
@@ -1,7 +1,7 @@
import { describe, it, expect } from "vitest";
import { renderWithProviders as render, screen } from "../utils/test-utils";
import "@testing-library/jest-dom/vitest";
-import { CommunityReviewScreen } from "../../app/create/screens/review/CommunityReviewScreen";
+import { CommunityReviewScreen } from "../../app/(app)/create/screens/review/CommunityReviewScreen";
describe("CommunityReviewScreen", () => {
it("renders without crashing", () => {
diff --git a/tests/components/SelectPage.test.tsx b/tests/components/SelectPage.test.tsx
index 438a0e5..c940303 100644
--- a/tests/components/SelectPage.test.tsx
+++ b/tests/components/SelectPage.test.tsx
@@ -1,7 +1,7 @@
import { describe, it, expect } from "vitest";
import { renderWithProviders as render, screen } from "../utils/test-utils";
import "@testing-library/jest-dom/vitest";
-import { CommunitySizeSelectScreen } from "../../app/create/screens/select/CommunitySizeSelectScreen";
+import { CommunitySizeSelectScreen } from "../../app/(app)/create/screens/select/CommunitySizeSelectScreen";
describe("CommunitySizeSelectScreen", () => {
it("renders HeaderLockup title", () => {
diff --git a/tests/components/Separator.test.tsx b/tests/components/Separator.test.tsx
new file mode 100644
index 0000000..5405ff1
--- /dev/null
+++ b/tests/components/Separator.test.tsx
@@ -0,0 +1,22 @@
+import { describe } from "vitest";
+import {
+ componentTestSuite,
+ type ComponentTestSuiteConfig,
+} from "../utils/componentTestSuite";
+import Separator from "../../app/components/utility/Separator";
+
+type Props = React.ComponentProps;
+
+const config: ComponentTestSuiteConfig = {
+ component: Separator,
+ name: "Separator",
+ props: {} as Props,
+ testCases: {
+ renders: true,
+ accessibility: true,
+ },
+};
+
+describe("Separator", () => {
+ componentTestSuite(config);
+});
diff --git a/tests/components/Stepper.test.tsx b/tests/components/Stepper.test.tsx
index fe0edd9..bf81024 100644
--- a/tests/components/Stepper.test.tsx
+++ b/tests/components/Stepper.test.tsx
@@ -37,6 +37,7 @@ const config: ComponentTestSuiteConfig = {
componentTestSuite(config);
+// Pure presentational; no provider context needed.
describe("Stepper (behavioral tests)", () => {
it("renders with correct number of steps", () => {
render( );
diff --git a/tests/components/TemplateReviewCard.test.tsx b/tests/components/TemplateReviewCard.test.tsx
new file mode 100644
index 0000000..7367c7f
--- /dev/null
+++ b/tests/components/TemplateReviewCard.test.tsx
@@ -0,0 +1,52 @@
+import { describe } from "vitest";
+import {
+ componentTestSuite,
+ type ComponentTestSuiteConfig,
+} from "../utils/componentTestSuite";
+import { TemplateReviewCard } from "../../app/components/cards/TemplateReviewCard";
+
+type Props = React.ComponentProps;
+
+const sampleTemplate = {
+ id: "tmpl-1",
+ slug: "consensus",
+ title: "Consensus",
+ category: null,
+ description:
+ "Important decisions require unanimous agreement. Proposals pass only if no serious objections remain.",
+ body: {
+ sections: [
+ {
+ categoryName: "Decision making",
+ entries: [
+ {
+ title: "How proposals pass",
+ body: "Unanimous agreement is required.",
+ },
+ ],
+ },
+ ],
+ },
+ sortOrder: 1,
+ featured: true,
+};
+
+const config: ComponentTestSuiteConfig = {
+ component: TemplateReviewCard,
+ name: "TemplateReviewCard",
+ props: {
+ template: sampleTemplate,
+ size: "L",
+ } as Props,
+ primaryRole: "button",
+ testCases: {
+ renders: true,
+ // RuleCard contains nested interactive elements (chips inside a clickable card)
+ // which trigger axe's "nested-interactive" rule. Tracked by RuleCard itself.
+ accessibility: false,
+ },
+};
+
+describe("TemplateReviewCard", () => {
+ componentTestSuite(config);
+});
diff --git a/tests/components/TextInput.test.tsx b/tests/components/TextInput.test.tsx
index a565f36..77c1e28 100644
--- a/tests/components/TextInput.test.tsx
+++ b/tests/components/TextInput.test.tsx
@@ -32,6 +32,7 @@ componentTestSuite({
},
});
+// Pure presentational; no provider context needed.
describe("TextInput (size tests)", () => {
it("renders with medium size by default", () => {
const { container } = render( );
@@ -45,9 +46,4 @@ describe("TextInput (size tests)", () => {
expect(input).toHaveClass("h-[32px]");
});
- it("accepts PascalCase size prop", () => {
- const { container } = render( );
- const input = container.querySelector("input");
- expect(input).toHaveClass("h-[32px]");
- });
});
diff --git a/tests/components/TextPage.test.tsx b/tests/components/TextPage.test.tsx
index 8be403c..6722f9e 100644
--- a/tests/components/TextPage.test.tsx
+++ b/tests/components/TextPage.test.tsx
@@ -1,7 +1,7 @@
import { describe, it, expect } from "vitest";
import { renderWithProviders as render, screen } from "../utils/test-utils";
import "@testing-library/jest-dom/vitest";
-import { CreateFlowTextFieldScreen } from "../../app/create/screens/text/CreateFlowTextFieldScreen";
+import { CreateFlowTextFieldScreen } from "../../app/(app)/create/screens/text/CreateFlowTextFieldScreen";
describe("CreateFlowTextFieldScreen (community name)", () => {
it("renders main heading", () => {
diff --git a/tests/components/Tooltip.test.tsx b/tests/components/Tooltip.test.tsx
index 1a83aa9..983c651 100644
--- a/tests/components/Tooltip.test.tsx
+++ b/tests/components/Tooltip.test.tsx
@@ -30,6 +30,7 @@ componentTestSuite({
},
});
+// Pure presentational; no provider context needed.
describe("Tooltip (behavioral tests)", () => {
it("shows tooltip on hover", async () => {
const user = userEvent.setup();
diff --git a/tests/components/Upload.test.tsx b/tests/components/Upload.test.tsx
index 27a79a7..dc16887 100644
--- a/tests/components/Upload.test.tsx
+++ b/tests/components/Upload.test.tsx
@@ -30,6 +30,7 @@ componentTestSuite({
},
});
+// Pure presentational; no provider context needed.
describe("Upload (behavioral tests)", () => {
it("renders with active state by default", () => {
render( );
diff --git a/tests/components/UploadPage.test.tsx b/tests/components/UploadPage.test.tsx
index 772ddf7..0184bac 100644
--- a/tests/components/UploadPage.test.tsx
+++ b/tests/components/UploadPage.test.tsx
@@ -1,7 +1,7 @@
import { describe, it, expect } from "vitest";
import { renderWithProviders as render, screen } from "../utils/test-utils";
import "@testing-library/jest-dom/vitest";
-import { CommunityUploadScreen } from "../../app/create/screens/upload/CommunityUploadScreen";
+import { CommunityUploadScreen } from "../../app/(app)/create/screens/upload/CommunityUploadScreen";
describe("CommunityUploadScreen", () => {
it("renders HeaderLockup", () => {
diff --git a/tests/components/AuthModalContext.test.tsx b/tests/contexts/AuthModalContext.test.tsx
similarity index 94%
rename from tests/components/AuthModalContext.test.tsx
rename to tests/contexts/AuthModalContext.test.tsx
index 70a2f7a..22b058e 100644
--- a/tests/components/AuthModalContext.test.tsx
+++ b/tests/contexts/AuthModalContext.test.tsx
@@ -31,10 +31,10 @@ vi.mock("../../lib/create/api", () => ({
requestMagicLink: vi.fn(),
}));
-vi.mock("../../app/create/utils/anonymousDraftStorage", async (importOriginal) => {
+vi.mock("../../app/(app)/create/utils/anonymousDraftStorage", async (importOriginal) => {
const actual =
await importOriginal<
- typeof import("../../app/create/utils/anonymousDraftStorage")
+ typeof import("../../app/(app)/create/utils/anonymousDraftStorage")
>();
return {
...actual,
@@ -43,7 +43,7 @@ vi.mock("../../app/create/utils/anonymousDraftStorage", async (importOriginal) =
});
import { requestMagicLink } from "../../lib/create/api";
-import { setTransferPendingFlag } from "../../app/create/utils/anonymousDraftStorage";
+import { setTransferPendingFlag } from "../../app/(app)/create/utils/anonymousDraftStorage";
function LoginTrigger() {
const { openLogin, closeLogin } = useAuthModal();
diff --git a/tests/pages/communication-methods.test.jsx b/tests/pages/communication-methods.test.jsx
index 7c8441e..5ee3d16 100644
--- a/tests/pages/communication-methods.test.jsx
+++ b/tests/pages/communication-methods.test.jsx
@@ -6,7 +6,7 @@ import {
} from "../utils/test-utils";
import userEvent from "@testing-library/user-event";
import { describe, test, expect, afterEach } from "vitest";
-import { CommunicationMethodsScreen } from "../../app/create/screens/card/CommunicationMethodsScreen";
+import { CommunicationMethodsScreen } from "../../app/(app)/create/screens/card/CommunicationMethodsScreen";
afterEach(() => {
cleanup();
diff --git a/tests/pages/decision-approaches.test.jsx b/tests/pages/decision-approaches.test.jsx
index 3a9c912..672f161 100644
--- a/tests/pages/decision-approaches.test.jsx
+++ b/tests/pages/decision-approaches.test.jsx
@@ -6,7 +6,7 @@ import {
} from "../utils/test-utils";
import userEvent from "@testing-library/user-event";
import { describe, test, expect, afterEach } from "vitest";
-import { DecisionApproachesScreen } from "../../app/create/screens/right-rail/DecisionApproachesScreen";
+import { DecisionApproachesScreen } from "../../app/(app)/create/screens/right-rail/DecisionApproachesScreen";
afterEach(() => {
cleanup();
diff --git a/tests/unit/ContentContainer.test.jsx b/tests/unit/ContentContainer.test.jsx
index 0df31f5..3e18cf3 100644
--- a/tests/unit/ContentContainer.test.jsx
+++ b/tests/unit/ContentContainer.test.jsx
@@ -24,6 +24,7 @@ const mockPost = {
},
};
+// Pure presentational; no provider context needed.
describe("ContentContainer", () => {
it("renders with default props", () => {
render( );
diff --git a/tests/unit/ContentThumbnailTemplate.test.jsx b/tests/unit/ContentThumbnailTemplate.test.jsx
index 374684e..8bdc018 100644
--- a/tests/unit/ContentThumbnailTemplate.test.jsx
+++ b/tests/unit/ContentThumbnailTemplate.test.jsx
@@ -35,6 +35,7 @@ const mockPost = {
},
};
+// Pure presentational; no provider context needed.
describe("ContentThumbnailTemplate", () => {
describe("Vertical Variant", () => {
it("should render vertical variant with responsive dimensions", () => {
diff --git a/tests/unit/Layout.test.jsx b/tests/unit/Layout.test.jsx
index aa48dae..84b18c8 100644
--- a/tests/unit/Layout.test.jsx
+++ b/tests/unit/Layout.test.jsx
@@ -1,5 +1,7 @@
import { describe, test, expect, vi } from "vitest";
import RootLayout from "../../app/layout";
+import MarketingLayout from "../../app/(marketing)/layout";
+import AppLayout from "../../app/(app)/layout";
// Mock the font imports since they're Next.js specific
vi.mock("next/font/google", () => ({
@@ -70,30 +72,46 @@ describe("RootLayout", () => {
expect(container).toBeTruthy();
});
- test("renders main content area", () => {
- const testContent = "Test content";
- const tree = RootLayout({ children: {testContent}
});
- const main = findDescendant(
- tree,
- (n) => n.type === "main" && n.props?.className?.includes("flex-1"),
- );
- expect(main).toBeTruthy();
-
- const childText = findDescendant(
- main,
- (n) => typeof n === "string" && n.includes(testContent),
- );
- expect(childText).toBeTruthy();
- });
-
- test("renders children content correctly", () => {
+ test("renders children directly inside the flex container (no at root)", () => {
const testContent = "This is test content";
const tree = RootLayout({ children: {testContent}
});
- const main = findDescendant(tree, (n) => n.type === "main");
+
+ expect(findDescendant(tree, (n) => n?.type === "main")).toBeNull();
+
const childText = findDescendant(
- main,
+ tree,
(n) => typeof n === "string" && n.includes(testContent),
);
expect(childText).toBeTruthy();
});
});
+
+describe("Group layouts (chrome composition)", () => {
+ test("MarketingLayout wraps children in and appends Footer", () => {
+ const tree = MarketingLayout({ children: marketing-child
});
+ const main = findDescendant(
+ tree,
+ (n) => n?.type === "main" && n.props?.className?.includes("flex-1"),
+ );
+ expect(main).toBeTruthy();
+ expect(
+ findDescendant(main, (n) => typeof n === "string" && n.includes("marketing-child")),
+ ).toBeTruthy();
+
+ // Footer is loaded via next/dynamic — it appears as a render prop component
+ // sibling to . Verify the layout returns more than just .
+ const childrenArr = Array.isArray(tree.props.children)
+ ? tree.props.children
+ : [tree.props.children];
+ expect(childrenArr.length).toBeGreaterThan(1);
+ });
+
+ test("AppLayout wraps children in with no footer", () => {
+ const tree = AppLayout({ children: app-child
});
+ expect(tree.type).toBe("main");
+ expect(tree.props.className).toContain("flex-1");
+ expect(
+ findDescendant(tree, (n) => typeof n === "string" && n.includes("app-child")),
+ ).toBeTruthy();
+ });
+});
diff --git a/tests/unit/LogoWall.test.jsx b/tests/unit/LogoWall.test.jsx
index 2b83130..302236e 100644
--- a/tests/unit/LogoWall.test.jsx
+++ b/tests/unit/LogoWall.test.jsx
@@ -6,6 +6,7 @@ afterEach(() => {
cleanup();
});
+// Pure presentational; no provider context needed.
describe("LogoWall Component", () => {
test("renders with default logos", () => {
render( );
diff --git a/tests/unit/NumberCard.test.jsx b/tests/unit/NumberCard.test.jsx
index 20d4bb7..0345d64 100644
--- a/tests/unit/NumberCard.test.jsx
+++ b/tests/unit/NumberCard.test.jsx
@@ -2,6 +2,7 @@ import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import NumberCard from "../../app/components/cards/NumberCard";
+// Pure presentational; no provider context needed.
describe("NumberCard Component", () => {
const defaultProps = {
number: 1,
@@ -200,7 +201,7 @@ describe("NumberCard Component", () => {
});
it("applies Small size variant correctly", () => {
- render( );
+ render( );
// For Small size, text is directly in card div (no wrapper), so use closest("div")
const card = screen.getByText("Test Card Text").closest("div");
@@ -219,7 +220,7 @@ describe("NumberCard Component", () => {
});
it("applies Medium size variant correctly", () => {
- render( );
+ render( );
const card = screen
.getByText("Test Card Text")
@@ -237,7 +238,7 @@ describe("NumberCard Component", () => {
});
it("applies Large size variant correctly", () => {
- render( );
+ render( );
const card = screen
.getByText("Test Card Text")
@@ -257,7 +258,7 @@ describe("NumberCard Component", () => {
});
it("applies XLarge size variant correctly", () => {
- render( );
+ render( );
const card = screen
.getByText("Test Card Text")
diff --git a/tests/unit/RuleCard.test.jsx b/tests/unit/RuleCard.test.jsx
index 5f1c29f..33befdc 100644
--- a/tests/unit/RuleCard.test.jsx
+++ b/tests/unit/RuleCard.test.jsx
@@ -159,7 +159,7 @@ describe("RuleCard Component", () => {
{
name: "Values",
chipOptions: [
- { id: "v1", label: "Consciousness", state: "Unselected" },
+ { id: "v1", label: "Consciousness", state: "unselected" },
],
},
];
diff --git a/tests/unit/buildPublishPayload.test.ts b/tests/unit/buildPublishPayload.test.ts
index 1032109..7531fb5 100644
--- a/tests/unit/buildPublishPayload.test.ts
+++ b/tests/unit/buildPublishPayload.test.ts
@@ -4,7 +4,7 @@ import {
parseDocumentSectionsForDisplay,
parseSectionsFromCreateFlowState,
} from "../../lib/create/buildPublishPayload";
-import type { CreateFlowState } from "../../app/create/types";
+import type { CreateFlowState } from "../../app/(app)/create/types";
describe("buildPublishPayload", () => {
it("returns error when title missing", () => {
@@ -97,8 +97,8 @@ describe("buildPublishPayload", () => {
title: "T",
selectedCoreValueIds: ["1", "2"],
coreValuesChipsSnapshot: [
- { id: "1", label: "Alpha", state: "Selected" },
- { id: "2", label: "Beta", state: "Selected" },
+ { id: "1", label: "Alpha", state: "selected" },
+ { id: "2", label: "Beta", state: "selected" },
],
coreValueDetailsByChipId: {
"1": { meaning: "m1", signals: "s1" },
diff --git a/tests/unit/createFlowLayoutTokens.test.ts b/tests/unit/createFlowLayoutTokens.test.ts
index 7e96ba6..fda3e2f 100644
--- a/tests/unit/createFlowLayoutTokens.test.ts
+++ b/tests/unit/createFlowLayoutTokens.test.ts
@@ -3,7 +3,7 @@ import {
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
-} from "../../app/create/components/createFlowLayoutTokens";
+} from "../../app/(app)/create/components/createFlowLayoutTokens";
describe("createFlowLayoutTokens", () => {
it("exports create-flow column and two-column max class strings", () => {
diff --git a/tests/unit/createFlowProportionProgress.test.ts b/tests/unit/createFlowProportionProgress.test.ts
index 2733a50..722152b 100644
--- a/tests/unit/createFlowProportionProgress.test.ts
+++ b/tests/unit/createFlowProportionProgress.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
-import { getProportionBarProgressForCreateFlowStep } from "../../app/create/utils/createFlowProportionProgress";
+import { getProportionBarProgressForCreateFlowStep } from "../../app/(app)/create/utils/createFlowProportionProgress";
describe("getProportionBarProgressForCreateFlowStep", () => {
it("uses 1-2 on community-structure (third Create Community step)", () => {
diff --git a/tests/unit/createFlowValidation.test.ts b/tests/unit/createFlowValidation.test.ts
index 45fe037..ec47882 100644
--- a/tests/unit/createFlowValidation.test.ts
+++ b/tests/unit/createFlowValidation.test.ts
@@ -83,8 +83,8 @@ describe("createFlowStateSchema", () => {
const r = createFlowStateSchema.safeParse({
communityStructureChipSnapshots: {
organizationTypes: [
- { id: "1", label: "Co-op", state: "Selected" },
- { id: "custom-uuid", label: "My type", state: "Selected" },
+ { id: "1", label: "Co-op", state: "selected" },
+ { id: "custom-uuid", label: "My type", state: "selected" },
],
scale: [{ id: "1", label: "Local" }],
maturity: [],
diff --git a/tests/unit/flowSteps.test.ts b/tests/unit/flowSteps.test.ts
index f4d3b49..36a02b7 100644
--- a/tests/unit/flowSteps.test.ts
+++ b/tests/unit/flowSteps.test.ts
@@ -5,7 +5,7 @@ import {
getPreviousStep,
isValidStep,
getStepIndex,
-} from "../../app/create/utils/flowSteps";
+} from "../../app/(app)/create/utils/flowSteps";
describe("flowSteps", () => {
it("places confirm-stakeholders immediately before final-review", () => {
diff --git a/tests/unit/hasCreateFlowUserInput.test.ts b/tests/unit/hasCreateFlowUserInput.test.ts
index dd71b0d..2f8ad15 100644
--- a/tests/unit/hasCreateFlowUserInput.test.ts
+++ b/tests/unit/hasCreateFlowUserInput.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
-import { hasCreateFlowUserInput } from "../../app/create/utils/hasCreateFlowUserInput";
+import { hasCreateFlowUserInput } from "../../app/(app)/create/utils/hasCreateFlowUserInput";
describe("hasCreateFlowUserInput", () => {
it("returns false for empty state", () => {
diff --git a/tests/unit/saveDraftToServer.test.ts b/tests/unit/saveDraftToServer.test.ts
index bf4af65..a0d20c8 100644
--- a/tests/unit/saveDraftToServer.test.ts
+++ b/tests/unit/saveDraftToServer.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { saveDraftToServer } from "../../lib/create/api";
-import type { CreateFlowState } from "../../app/create/types";
+import type { CreateFlowState } from "../../app/(app)/create/types";
const minimalState: CreateFlowState = {};
diff --git a/tests/utils/test-utils.tsx b/tests/utils/test-utils.tsx
index ae71a46..e5b219f 100644
--- a/tests/utils/test-utils.tsx
+++ b/tests/utils/test-utils.tsx
@@ -2,7 +2,7 @@ import React, { type ReactElement } from "react";
import { render, type RenderOptions } from "@testing-library/react";
import { AuthModalProvider } from "../../app/contexts/AuthModalContext";
import { MessagesProvider } from "../../app/contexts/MessagesContext";
-import { CreateFlowProvider } from "../../app/create/context/CreateFlowContext";
+import { CreateFlowProvider } from "../../app/(app)/create/context/CreateFlowContext";
import messages from "../../messages/en/index";
/**
diff --git a/vitest.config.mjs b/vitest.config.mjs
index 5a0726f..9944ad7 100644
--- a/vitest.config.mjs
+++ b/vitest.config.mjs
@@ -26,10 +26,10 @@ export default defineConfig({
setupFiles: ["./vitest.setup.ts"],
include: [
"tests/components/**/*.test.{js,jsx,ts,tsx}",
+ "tests/contexts/**/*.test.{js,jsx,ts,tsx}",
"tests/pages/**/*.test.{js,jsx,ts,tsx}",
"tests/utils/**/*.test.{js,jsx,ts,tsx}",
- "tests/unit/**/*.test.{js,jsx,ts,tsx}", // Legacy - remaining non-component tests
- "tests/e2e/**/*.e2e.test.{js,jsx,ts,tsx}",
+ "tests/unit/**/*.test.{js,jsx,ts,tsx}",
],
exclude: ["tests/e2e/**/*.spec.{js,jsx,ts,tsx}"],
// Disable CSS processing in tests to avoid jsdom parsing errors with Tailwind v4
@@ -40,7 +40,7 @@ export default defineConfig({
reporter: ["text", "lcov"],
include: [
"app/**/*.{js,jsx,ts,tsx}",
- "components/**/*.{js,jsx,ts,tsx}",
+ "lib/**/*.{js,jsx,ts,tsx}",
"!**/*.test.{js,jsx,ts,tsx}",
"!**/*.spec.{js,jsx,ts,tsx}",
"!**/node_modules/**",
From 45bbbb8a355aa63b08475b095c5a6503e4710d45 Mon Sep 17 00:00:00 2001
From: adilallo <39313955+adilallo@users.noreply.github.com>
Date: Mon, 20 Apr 2026 12:41:10 -0600
Subject: [PATCH 09/15] Implement create custom recommendations
---
.cursor/rules/create-flow.mdc | 13 +-
.cursor/rules/localization.mdc | 18 +-
app/(app)/create/CreateFlowLayoutClient.tsx | 40 +-
app/(app)/create/SignedInDraftHydration.tsx | 55 +-
.../create/context/CreateFlowContext.tsx | 68 +-
.../create/hooks/useFacetRecommendations.ts | 232 ++++
.../create/screens/CreateFlowScreenView.tsx | 6 +-
.../card/CommunicationMethodsScreen.tsx | 95 +-
.../screens/card/ConflictManagementScreen.tsx | 85 +-
.../screens/card/MembershipMethodsScreen.tsx | 92 +-
.../screens/completed/CompletedScreen.tsx | 2 +-
.../informational/InformationalScreen.tsx | 2 +-
.../screens/review/CommunityReviewScreen.tsx | 2 +-
.../screens/review/FinalReviewScreen.tsx | 6 +-
.../right-rail/DecisionApproachesScreen.tsx | 105 +-
.../select/CommunitySizeSelectScreen.tsx | 2 +-
.../select/CommunityStructureSelectScreen.tsx | 2 +-
.../select/ConfirmStakeholdersScreen.tsx | 2 +-
.../screens/select/CoreValuesSelectScreen.tsx | 2 +-
.../screens/upload/CommunityUploadScreen.tsx | 2 +-
app/(app)/create/types.ts | 8 +-
.../create/utils/createFlowScreenRegistry.ts | 36 +-
app/api/create-flow/methods/route.ts | 56 +
app/api/drafts/me/route.ts | 17 +
app/api/templates/route.ts | 26 +-
.../utility/CardStack/CardStack.container.tsx | 2 +
.../utility/CardStack/CardStack.types.ts | 11 +
.../utility/CardStack/CardStack.view.tsx | 21 +-
data/create/customRule/_facetGroups.json | 79 ++
data/create/customRule/communication.json | 321 +++++
.../create/customRule/conflictManagement.json | 553 ++++++++
.../create/customRule/decisionApproaches.json | 930 +++++++++++++
data/create/customRule/membership.json | 553 ++++++++
docs/guides/template-recommendation-matrix.md | 1189 +++++++++--------
lib/create/api.ts | 17 +
lib/server/methodRecommendations.ts | 171 +++
lib/server/ruleTemplates.ts | 106 +-
lib/server/templateMethods.ts | 76 ++
lib/server/validation/methodFacetsSchemas.ts | 273 ++++
messages/en/create/communication.json | 120 --
.../{ => community}/communityContext.json | 0
.../create/{ => community}/communityName.json | 0
.../create/{ => community}/communitySave.json | 0
.../create/{ => community}/communitySize.json | 0
.../{ => community}/communityStructure.json | 0
.../{ => community}/communityUpload.json | 0
.../create/{ => community}/informational.json | 0
.../en/create/{ => community}/review.json | 0
messages/en/create/conflictManagement.json | 143 --
.../en/create/customRule/communication.json | 137 ++
.../create/customRule/conflictManagement.json | 305 +++++
.../create/{ => customRule}/coreValues.json | 0
.../create/customRule/decisionApproaches.json | 535 ++++++++
messages/en/create/customRule/membership.json | 217 +++
messages/en/create/footer.json | 2 +-
messages/en/create/membership.json | 133 --
.../{ => reviewAndComplete}/completed.json | 0
.../confirmStakeholders.json | 0
.../{ => reviewAndComplete}/finalReview.json | 0
.../{ => reviewAndComplete}/publish.json | 0
messages/en/create/rightRail.json | 147 --
messages/en/index.ts | 82 +-
.../migration.sql | 21 +
prisma/schema.prisma | 24 +
prisma/seed.ts | 9 +
prisma/seed/methodFacets.ts | 117 ++
stories/pages/TextPage.stories.js | 2 +-
tests/components/TextPage.test.tsx | 4 +-
tests/pages/decision-approaches.test.jsx | 11 +-
tests/unit/createFlowMethodsRoute.test.ts | 69 +
tests/unit/deriveCompactCards.test.ts | 93 ++
tests/unit/methodFacets.test.ts | 88 ++
tests/unit/methodFacetsSchemas.test.ts | 83 ++
tests/unit/methodRecommendations.test.ts | 160 +++
tests/unit/templateMethods.test.ts | 77 ++
75 files changed, 6403 insertions(+), 1452 deletions(-)
create mode 100644 app/(app)/create/hooks/useFacetRecommendations.ts
create mode 100644 app/api/create-flow/methods/route.ts
create mode 100644 data/create/customRule/_facetGroups.json
create mode 100644 data/create/customRule/communication.json
create mode 100644 data/create/customRule/conflictManagement.json
create mode 100644 data/create/customRule/decisionApproaches.json
create mode 100644 data/create/customRule/membership.json
create mode 100644 lib/server/methodRecommendations.ts
create mode 100644 lib/server/templateMethods.ts
create mode 100644 lib/server/validation/methodFacetsSchemas.ts
delete mode 100644 messages/en/create/communication.json
rename messages/en/create/{ => community}/communityContext.json (100%)
rename messages/en/create/{ => community}/communityName.json (100%)
rename messages/en/create/{ => community}/communitySave.json (100%)
rename messages/en/create/{ => community}/communitySize.json (100%)
rename messages/en/create/{ => community}/communityStructure.json (100%)
rename messages/en/create/{ => community}/communityUpload.json (100%)
rename messages/en/create/{ => community}/informational.json (100%)
rename messages/en/create/{ => community}/review.json (100%)
delete mode 100644 messages/en/create/conflictManagement.json
create mode 100644 messages/en/create/customRule/communication.json
create mode 100644 messages/en/create/customRule/conflictManagement.json
rename messages/en/create/{ => customRule}/coreValues.json (100%)
create mode 100644 messages/en/create/customRule/decisionApproaches.json
create mode 100644 messages/en/create/customRule/membership.json
delete mode 100644 messages/en/create/membership.json
rename messages/en/create/{ => reviewAndComplete}/completed.json (100%)
rename messages/en/create/{ => reviewAndComplete}/confirmStakeholders.json (100%)
rename messages/en/create/{ => reviewAndComplete}/finalReview.json (100%)
rename messages/en/create/{ => reviewAndComplete}/publish.json (100%)
delete mode 100644 messages/en/create/rightRail.json
create mode 100644 prisma/migrations/20260418170000_add_method_facet/migration.sql
create mode 100644 prisma/seed/methodFacets.ts
create mode 100644 tests/unit/createFlowMethodsRoute.test.ts
create mode 100644 tests/unit/deriveCompactCards.test.ts
create mode 100644 tests/unit/methodFacets.test.ts
create mode 100644 tests/unit/methodFacetsSchemas.test.ts
create mode 100644 tests/unit/methodRecommendations.test.ts
create mode 100644 tests/unit/templateMethods.test.ts
diff --git a/.cursor/rules/create-flow.mdc b/.cursor/rules/create-flow.mdc
index 9c9a417..fb76949 100644
--- a/.cursor/rules/create-flow.mdc
+++ b/.cursor/rules/create-flow.mdc
@@ -39,9 +39,16 @@ file are a smell once they're used more than once.
## Copy & data
-- Step copy lives in `messages/en/create/.json`, wired into
- `messages/en/index.ts` under the `create:` namespace (see
- `localization.mdc` for the standard pattern).
+- Step copy lives in `messages/en/create//.json` where
+ `` is one of `community`, `customRule`, `reviewAndComplete`
+ (matches Figma stages — see `docs/create-flow.md`). Cross-cutting chrome
+ (`footer.json`, `topNav.json`, `draftHydration.json`,
+ `templateReview.json`) and shared layout-shell strings (`select.json`,
+ `text.json`, `upload.json`) live at the `create/` root. Wire each new
+ JSON into `messages/en/index.ts` under the matching `create..*`
+ namespace (see `localization.mdc`).
+- Modal `sections` defaults are DB-shaped seed placeholders, not UI
+ constants — expect replacement with live data.
- Modal `sections` defaults are DB-shaped seed placeholders, not UI
constants — expect replacement with live data.
diff --git a/.cursor/rules/localization.mdc b/.cursor/rules/localization.mdc
index 467d5bd..b141dd1 100644
--- a/.cursor/rules/localization.mdc
+++ b/.cursor/rules/localization.mdc
@@ -15,9 +15,13 @@ notation). Never hard-code user-facing strings in components.
- `messages/en/ .json` for single-file areas (`common.json`,
`navigation.json`, `metadata.json`).
- `messages/en//.json` for areas with multiple buckets:
- `components/*.json`, `pages/*.json`, `create/*.json`. One JSON per
- component / page / create-flow step — don't shoehorn unrelated copy into
- a shared file.
+ `components/*.json`, `pages/*.json`. One JSON per component / page —
+ don't shoehorn unrelated copy into a shared file.
+- `messages/en/create//.json` — wizard steps grouped by Figma
+ stage (`community`, `customRule`, `reviewAndComplete`). Cross-cutting
+ chrome (footer, top nav, draft hydration, template review) and shared
+ layout-shell strings (`select.json`, `text.json`, `upload.json`) live at
+ the `create/` root.
- Optional `"_comment"` at the top of a JSON documents the bundle's purpose.
## Registration — required
@@ -25,12 +29,14 @@ notation). Never hard-code user-facing strings in components.
Every new JSON must be wired into `messages/en/index.ts`:
```typescript
-import createConflictManagement from "./create/conflictManagement.json";
+import createConflictManagement from "./create/customRule/conflictManagement.json";
export default {
// …
create: {
- conflictManagement: createConflictManagement,
+ customRule: {
+ conflictManagement: createConflictManagement,
+ },
},
};
```
@@ -44,7 +50,7 @@ step means consumers can't read your strings and TypeScript won't flag the gap.
import { useMessages } from "../contexts/MessagesContext";
const m = useMessages();
-const title = m.create.conflictManagement.page.compactTitle; // fully typed
+const title = m.create.customRule.conflictManagement.page.compactTitle; // fully typed
```
Use `useTranslation(namespace)` only when you need dot-path lookup by dynamic
diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx
index 6448898..36d6f24 100644
--- a/app/(app)/create/CreateFlowLayoutClient.tsx
+++ b/app/(app)/create/CreateFlowLayoutClient.tsx
@@ -28,7 +28,11 @@ import {
requestMagicLink,
} from "../../../lib/create/api";
import { safeInternalPath } from "../../../lib/safeInternalPath";
-import { setTransferPendingFlag } from "./utils/anonymousDraftStorage";
+import {
+ clearAnonymousCreateFlowStorage,
+ setTransferPendingFlag,
+} from "./utils/anonymousDraftStorage";
+import { deleteServerDraft } from "../../../lib/create/api";
import { writeLastPublishedRule } from "../../../lib/create/lastPublishedRule";
import {
fetchTemplateBySlug,
@@ -64,10 +68,14 @@ function CreateFlowSessionShell({ children }: { children: ReactNode }) {
}, []);
const sessionResolved = sessionUser !== undefined;
- const enableAnonymousPersistence = sessionResolved && sessionUser === null;
+ // Mirror in-progress draft to localStorage for ALL visitors once we know who
+ // they are. Refresh-survival is the same UX for guest and signed-in users;
+ // signed-in users additionally get an explicit "Save & Exit" that PUTs to
+ // the server (handled in `useCreateFlowExit`).
+ const enableLocalDraftMirroring = sessionResolved;
return (
-
+
setPublishBannerMessage(null)}
className="w-full"
@@ -622,7 +644,7 @@ function CreateFlowLayoutContent({
goToNextStep();
}}
>
- {footer.confirmRightRail}
+ {footer.confirmDecisionApproaches}
) : currentStep === "conflict-management" && nextStep ? (
{currentStep === "final-review"
? isPublishing
- ? messages.create.publish.finalizeButtonPublishing
+ ? messages.create.reviewAndComplete.publish.finalizeButtonPublishing
: footer.finalizeCommunityRule
: currentStep === "confirm-stakeholders"
? footer.confirmStakeholders
diff --git a/app/(app)/create/SignedInDraftHydration.tsx b/app/(app)/create/SignedInDraftHydration.tsx
index c191c86..bae94e5 100644
--- a/app/(app)/create/SignedInDraftHydration.tsx
+++ b/app/(app)/create/SignedInDraftHydration.tsx
@@ -5,7 +5,6 @@ import { useSearchParams } from "next/navigation";
import type { CreateFlowState } from "./types";
import { createFlowStateHasKeys } from "../../../lib/create/draftHydrationUtils";
import {
- clearAnonymousCreateFlowStorage,
hasTransferPendingFlag,
readAnonymousCreateFlowState,
} from "./utils/anonymousDraftStorage";
@@ -16,11 +15,18 @@ import messages from "../../../messages/en/index";
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
/**
- * When sync is on and the user is signed in, fetch `GET /api/drafts/me` once and merge into context.
- * Skips when `?syncDraft=1` or transfer-pending — {@link PostLoginDraftTransfer} owns that path.
+ * When sync is on and the user is signed in, restore the server-side draft only
+ * when there is no in-flight localStorage draft to defer to. localStorage is
+ * the on-every-keystroke buffer (CreateFlowProvider mirrors state there for
+ * everyone), so a refresh mid-flow already has the freshest data; pulling the
+ * server draft on top would clobber unsaved keystrokes with a stale snapshot.
*
- * **Conflict:** If both server draft and `create-flow-anonymous` are non-empty, `window.confirm`
- * chooses account draft (OK) vs browser copy (Cancel); browser storage is cleared after resolution.
+ * Server draft becomes authoritative only when localStorage is empty — i.e.
+ * fresh device, after explicit Save & Exit (which clears localStorage), or
+ * after Exit-from-completed clears local state.
+ *
+ * Skips when `?syncDraft=1` or transfer-pending — {@link PostLoginDraftTransfer}
+ * owns that path.
*/
export function SignedInDraftHydration({
sessionUser,
@@ -54,6 +60,14 @@ export function SignedInDraftHydration({
return;
}
+ // Local draft wins over server: no fetch, no replaceState. The provider
+ // already hydrated from localStorage at mount, so the user sees their
+ // unsaved keystrokes immediately.
+ if (createFlowStateHasKeys(readAnonymousCreateFlowState())) {
+ finishedUserIdRef.current = userId;
+ return;
+ }
+
let cancelled = false;
setLoadingHydration(true);
@@ -62,43 +76,14 @@ export function SignedInDraftHydration({
const serverDraft = await fetchDraftFromServer();
if (cancelled) return;
- const localDraft = readAnonymousCreateFlowState();
- const hasServer =
- serverDraft != null && createFlowStateHasKeys(serverDraft);
- const hasLocal = createFlowStateHasKeys(localDraft);
-
if (touchedRef.current) {
finishedUserIdRef.current = userId;
return;
}
- if (hasServer && hasLocal) {
- const useAccount =
- typeof window !== "undefined" &&
- window.confirm(messages.create.draftHydration.conflictPrompt);
- if (cancelled) return;
- if (useAccount) {
- replaceState(serverDraft as CreateFlowState);
- } else {
- replaceState(localDraft);
- }
- clearAnonymousCreateFlowStorage();
- finishedUserIdRef.current = userId;
- return;
- }
-
- if (hasServer) {
+ if (serverDraft != null && createFlowStateHasKeys(serverDraft)) {
replaceState(serverDraft as CreateFlowState);
- clearAnonymousCreateFlowStorage();
- finishedUserIdRef.current = userId;
- return;
}
-
- if (hasLocal) {
- replaceState(localDraft);
- clearAnonymousCreateFlowStorage();
- }
-
finishedUserIdRef.current = userId;
} finally {
if (!cancelled) setLoadingHydration(false);
diff --git a/app/(app)/create/context/CreateFlowContext.tsx b/app/(app)/create/context/CreateFlowContext.tsx
index ad927b3..4ce6097 100644
--- a/app/(app)/create/context/CreateFlowContext.tsx
+++ b/app/(app)/create/context/CreateFlowContext.tsx
@@ -32,22 +32,29 @@ interface CreateFlowProviderProps {
children: ReactNode;
initialStep?: CreateFlowStep | null;
/**
- * When true (signed-out, session resolved), load/sync `create-flow-anonymous` in localStorage.
- * When false, in-memory only (authenticated fresh create).
+ * When true (session resolved, guest or signed-in), mirror in-flight draft to
+ * `create-flow-anonymous` in localStorage so refresh / dev-restart never wipes
+ * progress. When false, in-memory only (e.g. unit tests, pre-session-resolve).
+ *
+ * Signed-in users additionally get an explicit "Save & Exit" that PUTs to the
+ * server (`useCreateFlowExit`); the server draft is the cross-device snapshot,
+ * localStorage is the on-every-keystroke buffer.
*/
- enableAnonymousPersistence?: boolean;
+ enableLocalDraftMirroring?: boolean;
}
/**
- * Create flow state. Anonymous users mirror state to localStorage; authenticated users stay in memory.
+ * Create flow state. All users mirror in-flight state to localStorage when
+ * `enableLocalDraftMirroring` is true; signed-in users layer an explicit
+ * server-draft snapshot on top via {@link useCreateFlowExit}.
*/
export function CreateFlowProvider({
children,
initialStep = null,
- enableAnonymousPersistence = false,
+ enableLocalDraftMirroring = false,
}: CreateFlowProviderProps) {
const [state, setState] = useState(() => {
- const base = enableAnonymousPersistence
+ const base = enableLocalDraftMirroring
? readAnonymousCreateFlowState()
: {};
const storedDetails = readCoreValueDetailsFromLocalStorage();
@@ -62,15 +69,23 @@ export function CreateFlowProvider({
});
const [interactionTouched, setInteractionTouched] = useState(false);
const [currentStep] = useState(initialStep);
- const prevPersistRef = useRef(enableAnonymousPersistence);
+ const prevPersistRef = useRef(enableLocalDraftMirroring);
+ const persistWriteSkipRef = useRef(true);
useEffect(() => {
clearLegacyCreateFlowKeysOnce();
}, []);
- // Session resolved as guest after initial paint: hydrate from localStorage if still empty.
+ // Session resolved after initial paint: hydrate from localStorage, merging
+ // with anything already in state. We can't bail on `prev` being non-empty:
+ // the initializer pre-populates `coreValueDetailsByChipId` from a separate
+ // localStorage key, so `prev` is virtually always non-empty here.
+ // Merge strategy: `prev` wins for fields the user might have touched between
+ // mount and session-resolve; `from` fills in anything else; coreValueDetails
+ // is union-merged (prev wins per chip id since it loaded from the dedicated
+ // `create-flow-core-value-details` key).
useEffect(() => {
- if (!enableAnonymousPersistence) {
+ if (!enableLocalDraftMirroring) {
prevPersistRef.current = false;
return;
}
@@ -79,14 +94,39 @@ export function CreateFlowProvider({
if (!wasOff) return;
const from = readAnonymousCreateFlowState();
if (Object.keys(from).length === 0) return;
- // eslint-disable-next-line react-hooks/set-state-in-effect -- hydrate anonymous draft when guest persistence turns on
- setState((prev) => (Object.keys(prev).length > 0 ? prev : { ...from }));
- }, [enableAnonymousPersistence]);
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- hydrate local draft when mirroring turns on
+ setState((prev) => {
+ const merged: CreateFlowState = { ...from, ...prev };
+ const fromDetails = from.coreValueDetailsByChipId;
+ const prevDetails = prev.coreValueDetailsByChipId;
+ if (fromDetails || prevDetails) {
+ merged.coreValueDetailsByChipId = {
+ ...(fromDetails ?? {}),
+ ...(prevDetails ?? {}),
+ };
+ }
+ return merged;
+ });
+ }, [enableLocalDraftMirroring]);
useEffect(() => {
- if (!enableAnonymousPersistence) return;
+ if (!enableLocalDraftMirroring) {
+ // Reset so the next OFF→ON transition skips its first write again.
+ persistWriteSkipRef.current = true;
+ return;
+ }
+ // Skip the very first write that runs on the same render where mirroring
+ // turned ON — the hydrate effect (above) is racing to setState the loaded
+ // draft, and writing the still-empty pre-hydrate state here would clobber
+ // localStorage. The next render (with the hydrated state) will write
+ // normally. Without this guard, drafts get wiped during HMR / any
+ // auth-session refetch that re-toggles `enableLocalDraftMirroring`.
+ if (persistWriteSkipRef.current) {
+ persistWriteSkipRef.current = false;
+ return;
+ }
writeAnonymousCreateFlowState(state);
- }, [state, enableAnonymousPersistence]);
+ }, [state, enableLocalDraftMirroring]);
/** Meaning/signals for core values: survives refresh for signed-in users; merged with anonymous draft when both exist. */
useEffect(() => {
diff --git a/app/(app)/create/hooks/useFacetRecommendations.ts b/app/(app)/create/hooks/useFacetRecommendations.ts
new file mode 100644
index 0000000..7da4f3a
--- /dev/null
+++ b/app/(app)/create/hooks/useFacetRecommendations.ts
@@ -0,0 +1,232 @@
+"use client";
+
+import { useEffect, useMemo, useRef, useState } from "react";
+import facetGroups from "../../../../data/create/customRule/_facetGroups.json";
+import {
+ type CreateFlowState,
+} from "../types";
+import { useCreateFlow } from "../context/CreateFlowContext";
+
+/**
+ * Card-deck section ids served by `/api/create-flow/methods` (CR-88 §9.2).
+ */
+export type RecommendationSection =
+ | "communication"
+ | "membership"
+ | "decisionApproaches"
+ | "conflictManagement";
+
+const FACET_GROUPS = ["size", "orgType", "scale", "maturity"] as const;
+type FacetGroupId = (typeof FACET_GROUPS)[number];
+
+/** Reverse map chipId → canonical facet value id, per group. */
+const CHIP_TO_VALUE_BY_GROUP: Record> = (() => {
+ const out: Record> = {
+ size: {},
+ orgType: {},
+ scale: {},
+ maturity: {},
+ };
+ for (const group of FACET_GROUPS) {
+ const block = (facetGroups as Record)[group];
+ if (block && typeof block === "object" && "values" in block) {
+ const values = (block as { values: Record })
+ .values;
+ for (const [valueId, entry] of Object.entries(values)) {
+ out[group][entry.chipId] = valueId;
+ }
+ }
+ }
+ return out;
+})();
+
+/** Chip-id state accessors per group. */
+const STATE_KEY_BY_GROUP: Record = {
+ size: "selectedCommunitySizeIds",
+ orgType: "selectedOrganizationTypeIds",
+ scale: "selectedScaleIds",
+ maturity: "selectedMaturityIds",
+};
+
+function readChipIds(
+ state: CreateFlowState,
+ group: FacetGroupId,
+): string[] {
+ const value = state[STATE_KEY_BY_GROUP[group]];
+ return Array.isArray(value) ? (value as string[]) : [];
+}
+
+function buildFacetQuery(state: CreateFlowState): string {
+ const params = new URLSearchParams();
+ for (const group of FACET_GROUPS) {
+ const valuesById = CHIP_TO_VALUE_BY_GROUP[group];
+ for (const chipId of readChipIds(state, group)) {
+ const valueId = valuesById[chipId];
+ if (valueId) {
+ params.append(`facet.${group}`, valueId);
+ }
+ }
+ }
+ return params.toString();
+}
+
+export type FacetRecommendationsResult = {
+ /** `true` once the network call completes (or short-circuits with no facets). */
+ isReady: boolean;
+ /** `slug → score`; missing slug means `0`. */
+ scoresBySlug: Record;
+ /**
+ * `true` iff the user has selected at least one community facet. When
+ * `false`, callers should preserve authoring order rather than reranking.
+ */
+ hasAnyFacets: boolean;
+};
+
+const EMPTY_SCORES: Record = {};
+
+/**
+ * Calls `GET /api/create-flow/methods?section=&facet.*=...` for the
+ * card-deck step `section` and returns a `slug → score` map for re-ranking
+ * the messages-file `methods[]` array (CR-88 §10).
+ *
+ * Returns `{ isReady: true, scoresBySlug: {} }` when the user has not selected
+ * any community facets — callers fall back to the authoring order.
+ *
+ * Network failures resolve to `scoresBySlug: {}` so the wizard is never
+ * blocked on the recommendation backend.
+ */
+export function useFacetRecommendations(
+ section: RecommendationSection,
+): FacetRecommendationsResult {
+ const { state } = useCreateFlow();
+ const queryString = useMemo(() => buildFacetQuery(state), [state]);
+ const hasAnyFacets = queryString.length > 0;
+
+ const [result, setResult] = useState({
+ isReady: !hasAnyFacets,
+ scoresBySlug: EMPTY_SCORES,
+ hasAnyFacets,
+ });
+
+ // Track the last successful request input so we don't re-fetch on every state poke.
+ const lastQueryRef = useRef(null);
+
+ useEffect(() => {
+ if (!hasAnyFacets) {
+ setResult({
+ isReady: true,
+ scoresBySlug: EMPTY_SCORES,
+ hasAnyFacets: false,
+ });
+ lastQueryRef.current = null;
+ return;
+ }
+ const requestKey = `${section}?${queryString}`;
+ if (lastQueryRef.current === requestKey) return;
+ lastQueryRef.current = requestKey;
+
+ const ctrl = new AbortController();
+ setResult((prev) => ({ ...prev, isReady: false, hasAnyFacets: true }));
+ fetch(`/api/create-flow/methods?section=${section}&${queryString}`, {
+ credentials: "include",
+ signal: ctrl.signal,
+ })
+ .then(async (res) => {
+ if (!res.ok) throw new Error(`status ${res.status}`);
+ return (await res.json()) as {
+ methods?: { slug: string; matches?: { score?: number } }[];
+ };
+ })
+ .then((json) => {
+ const scoresBySlug: Record = {};
+ for (const m of json.methods ?? []) {
+ if (typeof m.slug === "string") {
+ scoresBySlug[m.slug] = m.matches?.score ?? 0;
+ }
+ }
+ setResult({ isReady: true, scoresBySlug, hasAnyFacets: true });
+ })
+ .catch((e) => {
+ if ((e as { name?: string }).name === "AbortError") return;
+ setResult({
+ isReady: true,
+ scoresBySlug: EMPTY_SCORES,
+ hasAnyFacets: true,
+ });
+ });
+
+ return () => {
+ ctrl.abort();
+ // Clear the dedup key so React 19 Strict Mode's mount → unmount → mount
+ // cycle (and any future remount) re-issues the request instead of
+ // returning early on the same key.
+ if (lastQueryRef.current === requestKey) {
+ lastQueryRef.current = null;
+ }
+ };
+ }, [section, queryString, hasAnyFacets]);
+
+ return result;
+}
+
+/**
+ * Stable comparator for re-ranking a messages-file `methods[]` array. Higher
+ * `scoresBySlug[id]` first; ties fall back to authoring index, so a
+ * zero-facet user sees the original ordering verbatim.
+ */
+export function rankMethodsByScore(
+ methods: readonly T[],
+ scoresBySlug: Record,
+): T[] {
+ const indexById = new Map();
+ methods.forEach((m, i) => indexById.set(m.id, i));
+ return [...methods].sort((a, b) => {
+ const sa = scoresBySlug[a.id] ?? 0;
+ const sb = scoresBySlug[b.id] ?? 0;
+ if (sa !== sb) return sb - sa;
+ return (indexById.get(a.id) ?? 0) - (indexById.get(b.id) ?? 0);
+ });
+}
+
+/**
+ * Picks (a) which method ids fill the compact card stack and (b) which of
+ * those should render with the "Recommended" tag. The messages JSON no
+ * longer carries a static `recommended` flag — both selections come
+ * entirely from facet scores (CR-88 §10).
+ *
+ * Behavior:
+ * - Facets selected & at least one method scored > 0 →
+ * `compactCardIds` = up to `limit` top-scored methods (1..limit cards;
+ * never padded with unrecommended fillers). All shown cards get the
+ * "Recommended" badge.
+ * - No facets selected, or every method scored 0 → `compactCardIds` =
+ * first `limit` in ranked/authoring order, `recommendedIds` empty (no
+ * badges shown — honest "no signal yet" fallback).
+ *
+ * `CardStack.view` is responsible for laying out variable-length compact
+ * arrays gracefully (uses `.map`/`.slice` and length-guarded indexing).
+ */
+export function deriveCompactCards(
+ rankedMethods: readonly T[],
+ scoresBySlug: Record,
+ hasAnyFacets: boolean,
+ limit: number,
+): { compactCardIds: string[]; recommendedIds: Set } {
+ const fallback = () => ({
+ compactCardIds: rankedMethods.slice(0, limit).map((m) => m.id),
+ recommendedIds: new Set(),
+ });
+
+ if (!hasAnyFacets) return fallback();
+
+ const matched = rankedMethods.filter(
+ (m) => (scoresBySlug[m.id] ?? 0) > 0,
+ );
+ if (matched.length === 0) return fallback();
+
+ const top = matched.slice(0, limit);
+ return {
+ compactCardIds: top.map((m) => m.id),
+ recommendedIds: new Set(top.map((m) => m.id)),
+ };
+}
diff --git a/app/(app)/create/screens/CreateFlowScreenView.tsx b/app/(app)/create/screens/CreateFlowScreenView.tsx
index 502af91..a12d06c 100644
--- a/app/(app)/create/screens/CreateFlowScreenView.tsx
+++ b/app/(app)/create/screens/CreateFlowScreenView.tsx
@@ -35,7 +35,7 @@ export function CreateFlowScreenView({
case "community-name":
return (
@@ -45,7 +45,7 @@ export function CreateFlowScreenView({
case "community-context":
return (
entry.id === platformCardId);
+ const sections = method?.sections;
+ const defaults: Record = {
+ corePrinciple: sections?.corePrinciple ?? "",
+ logisticsAdmin: sections?.logisticsAdmin ?? "",
+ codeOfConduct: sections?.codeOfConduct ?? "",
};
const [sectionValues, setSectionValues] = useState<
@@ -96,7 +85,7 @@ function AddPlatformModalContent({
export function CommunicationMethodsScreen() {
const m = useMessages();
- const comm = m.create.communication;
+ const comm = m.create.customRule.communication;
const mdUp = useCreateFlowMdUp();
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const [expanded, setExpanded] = useState(false);
@@ -112,18 +101,32 @@ export function CommunicationMethodsScreen() {
[updateState],
);
+ const { scoresBySlug, hasAnyFacets } =
+ useFacetRecommendations("communication");
+ const rankedMethods = useMemo(
+ () => rankMethodsByScore(comm.methods, scoresBySlug),
+ [comm.methods, scoresBySlug],
+ );
+
+ const { compactCardIds, recommendedIds } = useMemo(
+ () => deriveCompactCards(rankedMethods, scoresBySlug, hasAnyFacets, 5),
+ [rankedMethods, scoresBySlug, hasAnyFacets],
+ );
+
const sampleCards = useMemo(
() =>
- COMMUNICATION_CARD_ORDER.map((id) => {
- const row = comm.cards[id as keyof typeof comm.cards];
- return {
- id,
- label: row.label,
- supportText: row.supportText,
- recommended: true,
- };
- }),
- [comm],
+ rankedMethods.map((entry) => ({
+ id: entry.id,
+ label: entry.label,
+ supportText: entry.supportText,
+ recommended: recommendedIds.has(entry.id),
+ })),
+ [rankedMethods, recommendedIds],
+ );
+
+ const methodById = useMemo(
+ () => new Map(rankedMethods.map((entry) => [entry.id, entry])),
+ [rankedMethods],
);
const title = expanded ? comm.page.expandedTitle : comm.page.compactTitle;
@@ -157,25 +160,10 @@ export function CommunicationMethodsScreen() {
};
}
- if (pendingCardId in comm.modals) {
- const modal = comm.modals[pendingCardId as keyof typeof comm.modals];
- return {
- title: modal.title,
- description: modal.description,
- nextButtonText: comm.addPlatform.nextButtonText,
- showBackButton: false as const,
- currentStep: undefined,
- totalSteps: undefined,
- };
- }
-
- const cardRow =
- pendingCardId in comm.cards
- ? comm.cards[pendingCardId as keyof typeof comm.cards]
- : null;
+ const method = methodById.get(pendingCardId);
return {
- title: cardRow?.label ?? comm.confirmModal.title,
- description: cardRow?.supportText ?? comm.confirmModal.description,
+ title: method?.label ?? comm.confirmModal.title,
+ description: method?.supportText ?? comm.confirmModal.description,
nextButtonText: comm.addPlatform.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
@@ -235,7 +223,8 @@ export function CommunicationMethodsScreen() {
}}
hasMore={true}
toggleLabel={comm.page.seeAllLink}
- compactRecommendedLimit={3}
+ compactRecommendedLimit={5}
+ compactCardIds={compactCardIds}
compactDesktopLayout="flexWrap"
headerLockupSize={mdUp ? "L" : "M"}
/>
diff --git a/app/(app)/create/screens/card/ConflictManagementScreen.tsx b/app/(app)/create/screens/card/ConflictManagementScreen.tsx
index 3bac17e..3b804ff 100644
--- a/app/(app)/create/screens/card/ConflictManagementScreen.tsx
+++ b/app/(app)/create/screens/card/ConflictManagementScreen.tsx
@@ -7,7 +7,7 @@
* Card click opens the Figma "Add Approach" create modal (node `20874-172292`) with four
* controls: Core Principle, Applicable Scope (capsules), Process Protocol, and Restoration
* & Fallbacks. Section defaults are sourced from
- * `messages/en/create/conflictManagement.json` and will be replaced with DB-driven
+ * `messages/en/create/customRule/conflictManagement.json` and will be replaced with DB-driven
* content; labels are hard-coded per the Figma design.
*/
@@ -15,6 +15,11 @@ import { useState, useCallback, useMemo } from "react";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
+import {
+ deriveCompactCards,
+ rankMethodsByScore,
+ useFacetRecommendations,
+} from "../../hooks/useFacetRecommendations";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import CardStack from "../../../../components/utility/CardStack";
import Create from "../../../../components/modals/Create";
@@ -27,17 +32,6 @@ import {
import ModalTextAreaField from "../../components/ModalTextAreaField";
import ApplicableScopeField from "../../components/ApplicableScopeField";
-const CONFLICT_CARD_ORDER = [
- "peer-mediation",
- "conflict-resolution-council",
- "facilitated-negotiation",
- "ad-hoc-arbitration",
- "conflict-workshops",
- "6",
- "7",
- "8",
-] as const;
-
type ConflictModalSections = {
corePrinciple: string;
applicableScope: string[];
@@ -53,12 +47,9 @@ function AddConflictApproachModalContent({
}) {
const { markCreateFlowInteraction } = useCreateFlow();
const m = useMessages();
- const cm = m.create.conflictManagement;
- const modal =
- approachCardId in cm.modals
- ? cm.modals[approachCardId as keyof typeof cm.modals]
- : null;
- const modalSections = modal?.sections;
+ const cm = m.create.customRule.conflictManagement;
+ const method = cm.methods.find((entry) => entry.id === approachCardId);
+ const modalSections = method?.sections;
const defaults: ConflictModalSections = {
corePrinciple: modalSections?.corePrinciple ?? "",
applicableScope: modalSections?.applicableScope ?? [],
@@ -126,7 +117,7 @@ function AddConflictApproachModalContent({
export function ConflictManagementScreen() {
const m = useMessages();
- const cm = m.create.conflictManagement;
+ const cm = m.create.customRule.conflictManagement;
const mdUp = useCreateFlowMdUp();
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const [expanded, setExpanded] = useState(false);
@@ -142,18 +133,32 @@ export function ConflictManagementScreen() {
[updateState],
);
+ const { scoresBySlug, hasAnyFacets } =
+ useFacetRecommendations("conflictManagement");
+ const rankedMethods = useMemo(
+ () => rankMethodsByScore(cm.methods, scoresBySlug),
+ [cm.methods, scoresBySlug],
+ );
+
+ const { compactCardIds, recommendedIds } = useMemo(
+ () => deriveCompactCards(rankedMethods, scoresBySlug, hasAnyFacets, 5),
+ [rankedMethods, scoresBySlug, hasAnyFacets],
+ );
+
const sampleCards = useMemo(
() =>
- CONFLICT_CARD_ORDER.map((id) => {
- const row = cm.cards[id as keyof typeof cm.cards];
- return {
- id,
- label: row.label,
- supportText: row.supportText,
- recommended: true,
- };
- }),
- [cm],
+ rankedMethods.map((entry) => ({
+ id: entry.id,
+ label: entry.label,
+ supportText: entry.supportText,
+ recommended: recommendedIds.has(entry.id),
+ })),
+ [rankedMethods, recommendedIds],
+ );
+
+ const methodById = useMemo(
+ () => new Map(rankedMethods.map((entry) => [entry.id, entry])),
+ [rankedMethods],
);
const title = expanded ? cm.page.expandedTitle : cm.page.compactTitle;
@@ -187,25 +192,10 @@ export function ConflictManagementScreen() {
};
}
- if (pendingCardId in cm.modals) {
- const modal = cm.modals[pendingCardId as keyof typeof cm.modals];
- return {
- title: modal.title,
- description: modal.description,
- nextButtonText: cm.addApproach.nextButtonText,
- showBackButton: false as const,
- currentStep: undefined,
- totalSteps: undefined,
- };
- }
-
- const cardRow =
- pendingCardId in cm.cards
- ? cm.cards[pendingCardId as keyof typeof cm.cards]
- : null;
+ const method = methodById.get(pendingCardId);
return {
- title: cardRow?.label ?? cm.confirmModal.title,
- description: cardRow?.supportText ?? cm.confirmModal.description,
+ title: method?.label ?? cm.confirmModal.title,
+ description: method?.supportText ?? cm.confirmModal.description,
nextButtonText: cm.addApproach.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
@@ -266,6 +256,7 @@ export function ConflictManagementScreen() {
hasMore={true}
toggleLabel={cm.page.seeAllLink}
compactRecommendedLimit={5}
+ compactCardIds={compactCardIds}
compactDesktopLayout="pyramidFive"
headerLockupSize={mdUp ? "L" : "M"}
/>
diff --git a/app/(app)/create/screens/card/MembershipMethodsScreen.tsx b/app/(app)/create/screens/card/MembershipMethodsScreen.tsx
index de23389..bd20b52 100644
--- a/app/(app)/create/screens/card/MembershipMethodsScreen.tsx
+++ b/app/(app)/create/screens/card/MembershipMethodsScreen.tsx
@@ -7,7 +7,7 @@
* Card click opens the Figma create modal (node `20858-13948`) with three
* editable sections — Eligibility & Philosophy, Joining Process, and
* Expectations & Removal. Section defaults come from
- * `messages/en/create/membership.json` and will be replaced with DB-driven
+ * `messages/en/create/customRule/membership.json` and will be replaced with DB-driven
* content.
*/
@@ -15,6 +15,11 @@ import { useState, useCallback, useMemo } from "react";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
+import {
+ deriveCompactCards,
+ rankMethodsByScore,
+ useFacetRecommendations,
+} from "../../hooks/useFacetRecommendations";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import CardStack from "../../../../components/utility/CardStack";
import Create from "../../../../components/modals/Create";
@@ -33,17 +38,6 @@ const SECTION_FIELDS = [
] as const;
type SectionField = (typeof SECTION_FIELDS)[number];
-const MEMBERSHIP_CARD_ORDER = [
- "open-access",
- "orientation-required",
- "invitation-only",
- "contribution-based",
- "mentorship",
- "6",
- "7",
- "8",
-] as const;
-
function AddMembershipModalContent({
membershipCardId,
}: {
@@ -51,15 +45,13 @@ function AddMembershipModalContent({
}) {
const { markCreateFlowInteraction } = useCreateFlow();
const m = useMessages();
- const mem = m.create.membership;
- const modal =
- membershipCardId in mem.modals
- ? mem.modals[membershipCardId as keyof typeof mem.modals]
- : null;
- const defaults = modal?.sections ?? {
- eligibility: "",
- joiningProcess: "",
- expectations: "",
+ const mem = m.create.customRule.membership;
+ const method = mem.methods.find((entry) => entry.id === membershipCardId);
+ const sections = method?.sections;
+ const defaults: Record = {
+ eligibility: sections?.eligibility ?? "",
+ joiningProcess: sections?.joiningProcess ?? "",
+ expectations: sections?.expectations ?? "",
};
const [sectionValues, setSectionValues] = useState<
@@ -95,7 +87,7 @@ function AddMembershipModalContent({
export function MembershipMethodsScreen() {
const m = useMessages();
- const mem = m.create.membership;
+ const mem = m.create.customRule.membership;
const mdUp = useCreateFlowMdUp();
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const [expanded, setExpanded] = useState(false);
@@ -111,18 +103,32 @@ export function MembershipMethodsScreen() {
[updateState],
);
+ const { scoresBySlug, hasAnyFacets } =
+ useFacetRecommendations("membership");
+ const rankedMethods = useMemo(
+ () => rankMethodsByScore(mem.methods, scoresBySlug),
+ [mem.methods, scoresBySlug],
+ );
+
+ const { compactCardIds, recommendedIds } = useMemo(
+ () => deriveCompactCards(rankedMethods, scoresBySlug, hasAnyFacets, 5),
+ [rankedMethods, scoresBySlug, hasAnyFacets],
+ );
+
const sampleCards = useMemo(
() =>
- MEMBERSHIP_CARD_ORDER.map((id) => {
- const row = mem.cards[id as keyof typeof mem.cards];
- return {
- id,
- label: row.label,
- supportText: row.supportText,
- recommended: true,
- };
- }),
- [mem],
+ rankedMethods.map((entry) => ({
+ id: entry.id,
+ label: entry.label,
+ supportText: entry.supportText,
+ recommended: recommendedIds.has(entry.id),
+ })),
+ [rankedMethods, recommendedIds],
+ );
+
+ const methodById = useMemo(
+ () => new Map(rankedMethods.map((entry) => [entry.id, entry])),
+ [rankedMethods],
);
const title = expanded ? mem.page.expandedTitle : mem.page.compactTitle;
@@ -156,25 +162,10 @@ export function MembershipMethodsScreen() {
};
}
- if (pendingCardId in mem.modals) {
- const modal = mem.modals[pendingCardId as keyof typeof mem.modals];
- return {
- title: modal.title,
- description: modal.description,
- nextButtonText: mem.addPlatform.nextButtonText,
- showBackButton: false as const,
- currentStep: undefined,
- totalSteps: undefined,
- };
- }
-
- const cardRow =
- pendingCardId in mem.cards
- ? mem.cards[pendingCardId as keyof typeof mem.cards]
- : null;
+ const method = methodById.get(pendingCardId);
return {
- title: cardRow?.label ?? mem.confirmModal.title,
- description: cardRow?.supportText ?? mem.confirmModal.description,
+ title: method?.label ?? mem.confirmModal.title,
+ description: method?.supportText ?? mem.confirmModal.description,
nextButtonText: mem.addPlatform.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
@@ -235,6 +226,7 @@ export function MembershipMethodsScreen() {
hasMore={true}
toggleLabel={mem.page.seeAllLink}
compactRecommendedLimit={5}
+ compactCardIds={compactCardIds}
compactDesktopLayout="pyramidFive"
headerLockupSize={mdUp ? "L" : "M"}
/>
diff --git a/app/(app)/create/screens/completed/CompletedScreen.tsx b/app/(app)/create/screens/completed/CompletedScreen.tsx
index 7e4294c..ee22a4c 100644
--- a/app/(app)/create/screens/completed/CompletedScreen.tsx
+++ b/app/(app)/create/screens/completed/CompletedScreen.tsx
@@ -17,7 +17,7 @@ import {
export function CompletedScreen() {
const mdUp = useCreateFlowMdUp();
const m = useMessages();
- const completed = m.create.completed;
+ const completed = m.create.reviewAndComplete.completed;
const fallbackSections = useMemo(
() =>
diff --git a/app/(app)/create/screens/informational/InformationalScreen.tsx b/app/(app)/create/screens/informational/InformationalScreen.tsx
index 7069bf4..46ec934 100644
--- a/app/(app)/create/screens/informational/InformationalScreen.tsx
+++ b/app/(app)/create/screens/informational/InformationalScreen.tsx
@@ -14,7 +14,7 @@ import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowL
*/
export function InformationalScreen() {
const mdUp = useCreateFlowMdUp();
- const copy = useMessages().create.informational;
+ const copy = useMessages().create.community.informational;
const items = [
{
diff --git a/app/(app)/create/screens/review/CommunityReviewScreen.tsx b/app/(app)/create/screens/review/CommunityReviewScreen.tsx
index e1ff1cf..33f17f2 100644
--- a/app/(app)/create/screens/review/CommunityReviewScreen.tsx
+++ b/app/(app)/create/screens/review/CommunityReviewScreen.tsx
@@ -14,7 +14,7 @@ import {
/** Create Community review — Figma `19706:12135` (`/create/review`; two columns from `lg:`; column caps in `createFlowLayoutTokens`). */
export function CommunityReviewScreen() {
const lgUp = useCreateFlowLgUp();
- const t = useTranslation("create.review");
+ const t = useTranslation("create.community.review");
const { state } = useCreateFlow();
const cardTitle =
diff --git a/app/(app)/create/screens/review/FinalReviewScreen.tsx b/app/(app)/create/screens/review/FinalReviewScreen.tsx
index db45cb6..ed070f1 100644
--- a/app/(app)/create/screens/review/FinalReviewScreen.tsx
+++ b/app/(app)/create/screens/review/FinalReviewScreen.tsx
@@ -27,12 +27,12 @@ function buildFinalReviewCategories(
export function FinalReviewScreen() {
const { state } = useCreateFlow();
const mdUp = useCreateFlowMdUp();
- const t = useTranslation("create.finalReview");
+ const t = useTranslation("create.reviewAndComplete.finalReview");
const m = useMessages();
const finalReviewCategories = useMemo(
- () => buildFinalReviewCategories(m.create.finalReview.categories),
- [m.create.finalReview.categories],
+ () => buildFinalReviewCategories(m.create.reviewAndComplete.finalReview.categories),
+ [m.create.reviewAndComplete.finalReview.categories],
);
const ruleCardTitle = useMemo(() => {
diff --git a/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx b/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx
index 9773969..3e5193a 100644
--- a/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx
+++ b/app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx
@@ -9,8 +9,9 @@
*
* Card click opens the Figma "Add Approach" create modal (node `20870-72155`) with five controls:
* Core Principle, Applicable Scope, Step-by-Step Instructions, Consensus Level, and Objections &
- * Deadlocks. Section defaults are sourced from `messages/en/create/rightRail.json` and will be
- * replaced with DB-driven content; labels are hard-coded per the Figma design.
+ * Deadlocks. Section defaults are sourced from `messages/en/create/customRule/decisionApproaches.json` (read
+ * via `m.create.customRule.decisionApproaches`) and will be replaced with DB-driven content; labels are
+ * hard-coded per the Figma design.
*/
import { useState, useCallback, useMemo } from "react";
@@ -24,6 +25,11 @@ import type { CardStackItem } from "../../../../components/utility/CardStack/Car
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
+import {
+ deriveCompactCards,
+ rankMethodsByScore,
+ useFacetRecommendations,
+} from "../../hooks/useFacetRecommendations";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import ModalTextAreaField from "../../components/ModalTextAreaField";
import ApplicableScopeField from "../../components/ApplicableScopeField";
@@ -49,12 +55,9 @@ function AddDecisionApproachModalContent({
}) {
const { markCreateFlowInteraction } = useCreateFlow();
const m = useMessages();
- const rr = m.create.rightRail;
- const modal =
- approachCardId in rr.modals
- ? rr.modals[approachCardId as keyof typeof rr.modals]
- : null;
- const modalSections = modal?.sections;
+ const da = m.create.customRule.decisionApproaches;
+ const method = da.methods.find((entry) => entry.id === approachCardId);
+ const modalSections = method?.sections;
const defaults: RightRailModalSections = {
corePrinciple: modalSections?.corePrinciple ?? "",
applicableScope: modalSections?.applicableScope ?? [],
@@ -87,13 +90,13 @@ function AddDecisionApproachModalContent({
return (
patch("corePrinciple", v)}
/>
@@ -109,12 +112,12 @@ function AddDecisionApproachModalContent({
}
/>
patch("stepByStepInstructions", v)}
/>
patch("objectionsDeadlocks", v)}
/>
@@ -135,7 +138,7 @@ function AddDecisionApproachModalContent({
export function DecisionApproachesScreen() {
const m = useMessages();
- const rr = m.create.rightRail;
+ const da = m.create.customRule.decisionApproaches;
const mdUp = useCreateFlowMdUp();
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState(
@@ -156,41 +159,53 @@ export function DecisionApproachesScreen() {
const messageBoxItems: InfoMessageBoxItem[] = useMemo(
() =>
- rr.messageBox.items.map((item) => ({
+ da.messageBox.items.map((item) => ({
id: item.id,
label: item.label,
})),
- [rr.messageBox.items],
+ [da.messageBox.items],
+ );
+
+ const { scoresBySlug, hasAnyFacets } =
+ useFacetRecommendations("decisionApproaches");
+ const rankedMethods = useMemo(
+ () => rankMethodsByScore(da.methods, scoresBySlug),
+ [da.methods, scoresBySlug],
+ );
+
+ const { compactCardIds, recommendedIds } = useMemo(
+ () => deriveCompactCards(rankedMethods, scoresBySlug, hasAnyFacets, 5),
+ [rankedMethods, scoresBySlug, hasAnyFacets],
);
const sampleCards: CardStackItem[] = useMemo(
() =>
- rr.cards.map((c) => ({
- id: c.id,
- label: c.label,
- supportText: c.supportText,
- recommended: c.recommended,
+ rankedMethods.map((entry) => ({
+ id: entry.id,
+ label: entry.label,
+ supportText: entry.supportText,
+ recommended: recommendedIds.has(entry.id),
})),
- [rr.cards],
+ [rankedMethods, recommendedIds],
);
- const cardById = useMemo(
- () => new Map(rr.cards.map((c) => [c.id, c])),
- [rr.cards],
+ const methodById = useMemo(
+ () => new Map(rankedMethods.map((entry) => [entry.id, entry])),
+ [rankedMethods],
);
const sidebarDescription = (
<>
- {rr.sidebar.descriptionBefore}
+ {da.sidebar.descriptionBefore}
{
markCreateFlowInteraction();
setExpanded(true);
}}
>
- {rr.sidebar.descriptionLinkLabel}
+ {da.sidebar.descriptionLinkLabel}
- {rr.sidebar.descriptionAfter}
+ {da.sidebar.descriptionAfter}
>
);
@@ -239,26 +254,17 @@ export function DecisionApproachesScreen() {
const modalConfig = (() => {
if (!pendingCardId) {
return {
- title: rr.confirmModal.title,
- description: rr.confirmModal.description,
- nextButtonText: rr.confirmModal.nextButtonText,
+ title: da.confirmModal.title,
+ description: da.confirmModal.description,
+ nextButtonText: da.confirmModal.nextButtonText,
};
}
- if (pendingCardId in rr.modals) {
- const modal = rr.modals[pendingCardId as keyof typeof rr.modals];
- return {
- title: modal.title,
- description: modal.description,
- nextButtonText: rr.addApproach.nextButtonText,
- };
- }
-
- const card = cardById.get(pendingCardId);
+ const method = methodById.get(pendingCardId);
return {
- title: card?.label ?? rr.confirmModal.title,
- description: card?.supportText ?? rr.confirmModal.description,
- nextButtonText: rr.addApproach.nextButtonText,
+ title: method?.label ?? da.confirmModal.title,
+ description: method?.supportText ?? da.confirmModal.description,
+ nextButtonText: da.addApproach.nextButtonText,
};
})();
@@ -268,9 +274,9 @@ export function DecisionApproachesScreen() {
lgVerticalAlign="start"
header={
diff --git a/app/(app)/create/screens/select/CommunitySizeSelectScreen.tsx b/app/(app)/create/screens/select/CommunitySizeSelectScreen.tsx
index a7d313e..0a46712 100644
--- a/app/(app)/create/screens/select/CommunitySizeSelectScreen.tsx
+++ b/app/(app)/create/screens/select/CommunitySizeSelectScreen.tsx
@@ -27,7 +27,7 @@ function selectedIdsFromOptions(options: ChipOption[]): string[] {
/** Create Community — Figma `20094:41317`, chips only (layout tokens shared with structure select). */
export function CommunitySizeSelectScreen() {
const m = useMessages();
- const cs = m.create.communitySize;
+ const cs = m.create.community.communitySize;
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
const [communitySizeOptions, setCommunitySizeOptions] = useState<
diff --git a/app/(app)/create/screens/select/CommunityStructureSelectScreen.tsx b/app/(app)/create/screens/select/CommunityStructureSelectScreen.tsx
index 8784e32..49242e6 100644
--- a/app/(app)/create/screens/select/CommunityStructureSelectScreen.tsx
+++ b/app/(app)/create/screens/select/CommunityStructureSelectScreen.tsx
@@ -106,7 +106,7 @@ function snapshotRowsToChipOptions(
/** Create Community step 3 — Figma `20094:18244` (responsive grid + column caps via `createFlowLayoutTokens`). */
export function CommunityStructureSelectScreen() {
const m = useMessages();
- const cs = m.create.communityStructure;
+ const cs = m.create.community.communityStructure;
const { markCreateFlowInteraction, updateState, state } = useCreateFlow();
const [organizationTypeOptions, setOrganizationTypeOptions] = useState<
diff --git a/app/(app)/create/screens/select/ConfirmStakeholdersScreen.tsx b/app/(app)/create/screens/select/ConfirmStakeholdersScreen.tsx
index 8a4fee2..560735d 100644
--- a/app/(app)/create/screens/select/ConfirmStakeholdersScreen.tsx
+++ b/app/(app)/create/screens/select/ConfirmStakeholdersScreen.tsx
@@ -12,7 +12,7 @@ import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowL
export function ConfirmStakeholdersScreen() {
const { markCreateFlowInteraction } = useCreateFlow();
- const t = useTranslation("create.confirmStakeholders");
+ const t = useTranslation("create.reviewAndComplete.confirmStakeholders");
const [toastDismissed, setToastDismissed] = useState(false);
const [stakeholderOptions, setStakeholderOptions] = useState(
[],
diff --git a/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx b/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx
index d8639fd..64a30e3 100644
--- a/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx
+++ b/app/(app)/create/screens/select/CoreValuesSelectScreen.tsx
@@ -99,7 +99,7 @@ function snapshotRowsToChipOptions(
/** Create Custom — Core Values (Figma `20264:68378`). Up to five selections; preset list + custom chips. */
export function CoreValuesSelectScreen() {
const m = useMessages();
- const cv = m.create.coreValues;
+ const cv = m.create.customRule.coreValues;
const presets = useMemo(
() => normalizeCoreValuePresets(cv.values as CoreValuePresetJson[]),
[cv.values],
diff --git a/app/(app)/create/screens/upload/CommunityUploadScreen.tsx b/app/(app)/create/screens/upload/CommunityUploadScreen.tsx
index b3e17fa..b1681b2 100644
--- a/app/(app)/create/screens/upload/CommunityUploadScreen.tsx
+++ b/app/(app)/create/screens/upload/CommunityUploadScreen.tsx
@@ -10,7 +10,7 @@ import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowL
/** Create Community — Figma Flow — Upload `20094:41524`. */
export function CommunityUploadScreen() {
const m = useMessages();
- const u = m.create.communityUpload;
+ const u = m.create.community.communityUpload;
const { markCreateFlowInteraction } = useCreateFlow();
const handleUploadClick = () => {
diff --git a/app/(app)/create/types.ts b/app/(app)/create/types.ts
index 68393b5..8cc2d49 100644
--- a/app/(app)/create/types.ts
+++ b/app/(app)/create/types.ts
@@ -85,13 +85,13 @@ export interface CreateFlowState {
coreValuesChipsSnapshot?: CommunityStructureChipSnapshotRow[];
/** User-authored detail text keyed by chip id (preset ids or custom UUIDs). */
coreValueDetailsByChipId?: Record;
- /** Create Custom — communication methods step (`/create/communication-methods`); card ids from `create.communication` presets. */
+ /** Create Custom — communication methods step (`/create/communication-methods`); card ids from `create.customRule.communication` presets. */
selectedCommunicationMethodIds?: string[];
- /** Create Custom — membership / join patterns (`/create/membership-methods`); card ids from `create.membership` presets. */
+ /** Create Custom — membership / join patterns (`/create/membership-methods`); card ids from `create.customRule.membership` presets. */
selectedMembershipMethodIds?: string[];
- /** Create Custom — decision approaches (`/create/decision-approaches`); card ids from `create.rightRail` presets. */
+ /** Create Custom — decision approaches (`/create/decision-approaches`); card ids from `create.customRule.decisionApproaches` presets. */
selectedDecisionApproachIds?: string[];
- /** Create Custom — conflict management (`/create/conflict-management`); card ids from `create.conflictManagement` presets. */
+ /** Create Custom — conflict management (`/create/conflict-management`); card ids from `create.customRule.conflictManagement` presets. */
selectedConflictManagementIds?: string[];
currentStep?: CreateFlowStep;
/** Section drafts; structure will tighten as steps persist real shapes. */
diff --git a/app/(app)/create/utils/createFlowScreenRegistry.ts b/app/(app)/create/utils/createFlowScreenRegistry.ts
index ff189fe..19bb251 100644
--- a/app/(app)/create/utils/createFlowScreenRegistry.ts
+++ b/app/(app)/create/utils/createFlowScreenRegistry.ts
@@ -20,7 +20,9 @@ interface CreateFlowScreenDefinition {
/** Figma node id (file Community-Rule-System), dev mode. */
figmaNodeId: string;
/**
- * Namespace for `useTranslation`, e.g. `create.communityName`.
+ * Namespace for `useTranslation`, e.g. `create.community.communityName`.
+ * Stage prefix (`community` / `customRule` / `reviewAndComplete`) matches the
+ * messages folder layout — see `messages/en/index.ts` and `docs/guides/template-recommendation-matrix.md` §1c.
* Not all screens use i18n the same way (e.g. card step uses `useMessages` elsewhere).
*/
messageNamespace: string;
@@ -40,97 +42,97 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record<
informational: {
layoutKind: "informational",
figmaNodeId: "20094-16005",
- messageNamespace: "create.informational",
+ messageNamespace: "create.community.informational",
centeredBodyBelowMd: false,
},
"community-name": {
layoutKind: "text",
figmaNodeId: "20094-18187",
- messageNamespace: "create.communityName",
+ messageNamespace: "create.community.communityName",
centeredBodyBelowMd: true,
},
"community-size": {
layoutKind: "select",
figmaNodeId: "20094-41317",
- messageNamespace: "create.communitySize",
+ messageNamespace: "create.community.communitySize",
centeredBodyBelowMd: false,
},
"community-context": {
layoutKind: "text",
figmaNodeId: "20094-41243",
- messageNamespace: "create.communityContext",
+ messageNamespace: "create.community.communityContext",
centeredBodyBelowMd: true,
},
"community-structure": {
layoutKind: "select",
figmaNodeId: "20094-18244",
- messageNamespace: "create.communityStructure",
+ messageNamespace: "create.community.communityStructure",
centeredBodyBelowMd: false,
},
"community-upload": {
layoutKind: "upload",
figmaNodeId: "20094-41524",
- messageNamespace: "create.communityUpload",
+ messageNamespace: "create.community.communityUpload",
centeredBodyBelowMd: false,
},
"community-save": {
layoutKind: "text",
figmaNodeId: "20097-14948",
- messageNamespace: "create.communitySave",
+ messageNamespace: "create.community.communitySave",
centeredBodyBelowMd: true,
},
review: {
layoutKind: "review",
figmaNodeId: "19706-12135",
- messageNamespace: "create.review",
+ messageNamespace: "create.community.review",
centeredBodyBelowMd: false,
},
"core-values": {
layoutKind: "select",
figmaNodeId: "20264-68378",
- messageNamespace: "create.coreValues",
+ messageNamespace: "create.customRule.coreValues",
centeredBodyBelowMd: false,
},
"communication-methods": {
layoutKind: "card",
figmaNodeId: "20246-15828",
- messageNamespace: "create.communication",
+ messageNamespace: "create.customRule.communication",
centeredBodyBelowMd: false,
},
"membership-methods": {
layoutKind: "card",
figmaNodeId: "20858-13947",
- messageNamespace: "create.membership",
+ messageNamespace: "create.customRule.membership",
centeredBodyBelowMd: false,
},
"decision-approaches": {
layoutKind: "right-rail",
figmaNodeId: "20523-23509",
- messageNamespace: "create.rightRail",
+ messageNamespace: "create.customRule.decisionApproaches",
centeredBodyBelowMd: false,
},
"conflict-management": {
layoutKind: "card",
figmaNodeId: "20879-15979",
- messageNamespace: "create.conflictManagement",
+ messageNamespace: "create.customRule.conflictManagement",
centeredBodyBelowMd: false,
},
"confirm-stakeholders": {
layoutKind: "select",
figmaNodeId: "21104-46594",
- messageNamespace: "create.confirmStakeholders",
+ messageNamespace: "create.reviewAndComplete.confirmStakeholders",
centeredBodyBelowMd: false,
},
"final-review": {
layoutKind: "review",
figmaNodeId: "20907-212767",
- messageNamespace: "create.finalReview",
+ messageNamespace: "create.reviewAndComplete.finalReview",
centeredBodyBelowMd: false,
},
completed: {
layoutKind: "completed",
figmaNodeId: "20907-213286",
- messageNamespace: "create.completed",
+ messageNamespace: "create.reviewAndComplete.completed",
centeredBodyBelowMd: false,
},
};
diff --git a/app/api/create-flow/methods/route.ts b/app/api/create-flow/methods/route.ts
new file mode 100644
index 0000000..12c77bc
--- /dev/null
+++ b/app/api/create-flow/methods/route.ts
@@ -0,0 +1,56 @@
+import { NextResponse, type NextRequest } from "next/server";
+import { isDatabaseConfigured } from "../../../../lib/server/env";
+import { listMethodRecommendations } from "../../../../lib/server/methodRecommendations";
+import { dbUnavailable } from "../../../../lib/server/responses";
+import {
+ SECTION_IDS,
+ type SectionId,
+ parseRequestedFacetsFromSearchParams,
+} from "../../../../lib/server/validation/methodFacetsSchemas";
+
+const SECTION_SET = new Set(SECTION_IDS);
+
+/**
+ * GET /api/create-flow/methods?section=[&facet.*=...]
+ *
+ * Returns slugs + per-method match scores for one of the four card-deck
+ * sections; the wizard renders by looking up the slug in the section's
+ * messages file (`useMessages().create.customRule..methods`).
+ *
+ * See `docs/guides/template-recommendation-matrix.md` §9.2 / §10.
+ */
+export async function GET(request: NextRequest) {
+ if (!isDatabaseConfigured()) {
+ return dbUnavailable();
+ }
+
+ const sectionParam = request.nextUrl.searchParams.get("section");
+ if (!sectionParam || !SECTION_SET.has(sectionParam)) {
+ return NextResponse.json(
+ {
+ error: {
+ code: "validation_error",
+ message: `Unknown section. Expected one of: ${SECTION_IDS.join(", ")}`,
+ },
+ },
+ { status: 400 },
+ );
+ }
+ const section = sectionParam as SectionId;
+
+ const facets = parseRequestedFacetsFromSearchParams(
+ request.nextUrl.searchParams,
+ );
+ const result = await listMethodRecommendations({ section, facets });
+ if (!result) {
+ // DB query failed; return empty so the wizard falls back to its messages
+ // deck in authoring order (§10).
+ return NextResponse.json({ section, methods: [] });
+ }
+
+ const methods = result.rankedSlugs.map((slug) => ({
+ slug,
+ matches: result.matchesBySlug[slug] ?? { score: 0, matchedFacets: [] },
+ }));
+ return NextResponse.json({ section, methods });
+}
diff --git a/app/api/drafts/me/route.ts b/app/api/drafts/me/route.ts
index 385f045..dbde8ca 100644
--- a/app/api/drafts/me/route.ts
+++ b/app/api/drafts/me/route.ts
@@ -68,3 +68,20 @@ export async function PUT(request: NextRequest) {
draft: { payload: draft.payload, updatedAt: draft.updatedAt },
});
}
+
+export async function DELETE() {
+ if (!isDatabaseConfigured()) {
+ return dbUnavailable();
+ }
+
+ const user = await getSessionUser();
+ if (!user) {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ // Idempotent: missing draft is a no-op so callers can fire-and-forget after
+ // publish / exit without worrying about prior state.
+ await prisma.ruleDraft.deleteMany({ where: { userId: user.id } });
+
+ return NextResponse.json({ ok: true });
+}
diff --git a/app/api/templates/route.ts b/app/api/templates/route.ts
index b28b7ec..16ea3cd 100644
--- a/app/api/templates/route.ts
+++ b/app/api/templates/route.ts
@@ -1,17 +1,31 @@
-import { NextResponse } from "next/server";
+import { NextResponse, type NextRequest } from "next/server";
import { isDatabaseConfigured } from "../../../lib/server/env";
-import { listRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates";
+import { listRankedRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates";
import { dbUnavailable } from "../../../lib/server/responses";
+import { parseRequestedFacetsFromSearchParams } from "../../../lib/server/validation/methodFacetsSchemas";
/**
- * Curated rule templates for recommendations (seed via Prisma Studio or a script).
+ * GET /api/templates
+ *
+ * No params → curated ordering (`featured` desc, `sortOrder` asc, `title`
+ * asc). With `facet.=` query params (repeatable per group),
+ * templates are re-ranked by composed-method match count; ties fall back to
+ * the curated order, score-0 templates remain at the end.
+ *
+ * See `docs/guides/template-recommendation-matrix.md` §9.1.
*/
-export async function GET() {
+export async function GET(request: NextRequest) {
if (!isDatabaseConfigured()) {
return dbUnavailable();
}
- const templates = await listRuleTemplatesFromDb();
+ const facets = parseRequestedFacetsFromSearchParams(
+ request.nextUrl.searchParams,
+ );
+ const { templates, scores } = await listRankedRuleTemplatesFromDb(facets);
+ const hasScores = Object.keys(scores).length > 0;
- return NextResponse.json({ templates });
+ return NextResponse.json(
+ hasScores ? { templates, scores } : { templates },
+ );
}
diff --git a/app/components/utility/CardStack/CardStack.container.tsx b/app/components/utility/CardStack/CardStack.container.tsx
index ada2ef1..bcf498a 100644
--- a/app/components/utility/CardStack/CardStack.container.tsx
+++ b/app/components/utility/CardStack/CardStack.container.tsx
@@ -26,6 +26,7 @@ const CardStackContainer = memo(
description = "",
layout = "default",
compactRecommendedLimit = 5,
+ compactCardIds,
compactDesktopLayout: compactDesktopLayoutProp = "grid",
headerLockupSize,
toggleAlignment = "center",
@@ -83,6 +84,7 @@ const CardStackContainer = memo(
description={description}
layout={layout}
compactRecommendedLimit={compactRecommendedLimit}
+ compactCardIds={compactCardIds}
compactDesktopLayout={compactDesktopLayoutProp}
headerLockupSize={headerLockupSize}
toggleAlignment={toggleAlignment}
diff --git a/app/components/utility/CardStack/CardStack.types.ts b/app/components/utility/CardStack/CardStack.types.ts
index 6c81314..e7cd602 100644
--- a/app/components/utility/CardStack/CardStack.types.ts
+++ b/app/components/utility/CardStack/CardStack.types.ts
@@ -25,6 +25,16 @@ export interface CardStackProps {
* Max recommended cards in compact (non-expanded) mode. Default 5; Figma compact stack uses 3.
*/
compactRecommendedLimit?: number;
+ /**
+ * Optional explicit list of card ids to render in the compact slot, in
+ * order. When provided, this overrides the default
+ * `cards.filter(c => c.recommended)` selection — the `recommended` flag
+ * then only controls the visual "Recommended" badge. Used by the
+ * create-flow card-deck steps so facet scores can pick the compact set
+ * (and badge only the truly matched subset). Cards whose ids are not in
+ * `cards` are silently dropped.
+ */
+ compactCardIds?: string[];
/**
* At `md+`, how compact recommended cards are laid out. `flexWrap` matches Figma Flow — Compact Card Stack (three cards in a row).
* `pyramidFive` = two rows (3 + 2) centered for five recommended cards (membership step).
@@ -50,6 +60,7 @@ export interface CardStackViewProps {
description: string;
layout: "default" | "singleStack";
compactRecommendedLimit: number;
+ compactCardIds: string[] | undefined;
compactDesktopLayout: "grid" | "flexWrap" | "pyramidFive";
headerLockupSize: HeaderLockupSizeValue | undefined;
toggleAlignment: "center" | "end";
diff --git a/app/components/utility/CardStack/CardStack.view.tsx b/app/components/utility/CardStack/CardStack.view.tsx
index 27b28ad..6ec8f7e 100644
--- a/app/components/utility/CardStack/CardStack.view.tsx
+++ b/app/components/utility/CardStack/CardStack.view.tsx
@@ -17,6 +17,7 @@ export function CardStackView({
description,
layout,
compactRecommendedLimit,
+ compactCardIds,
compactDesktopLayout,
headerLockupSize,
toggleAlignment,
@@ -24,10 +25,22 @@ export function CardStackView({
}: CardStackViewProps) {
const lockupSize = headerLockupSize ?? "L";
const isSelected = (id: string) => selectedIds.includes(id);
- // Compact: recommended only (default up to 5). Expanded: all cards.
- const compactCards = cards
- .filter((c) => c.recommended ?? false)
- .slice(0, compactRecommendedLimit);
+ // Compact: explicit `compactCardIds` (caller-driven, used by create-flow
+ // facet ranker) takes precedence over the legacy `recommended`-filter so
+ // the screen can show un-tagged cards in the compact slot when there is
+ // no facet signal yet (CR-88 §10).
+ const compactCards = (() => {
+ if (compactCardIds && compactCardIds.length > 0) {
+ const byId = new Map(cards.map((c) => [c.id, c]));
+ return compactCardIds
+ .map((id) => byId.get(id))
+ .filter((c): c is (typeof cards)[number] => c !== undefined)
+ .slice(0, compactRecommendedLimit);
+ }
+ return cards
+ .filter((c) => c.recommended ?? false)
+ .slice(0, compactRecommendedLimit);
+ })();
// Single stack: always one column; expand reveals more in same stack (scrollable)
if (layout === "singleStack") {
diff --git a/data/create/customRule/_facetGroups.json b/data/create/customRule/_facetGroups.json
new file mode 100644
index 0000000..1070d9d
--- /dev/null
+++ b/data/create/customRule/_facetGroups.json
@@ -0,0 +1,79 @@
+{
+ "size": {
+ "source": "messages/en/create/community/communitySize.json#/communitySizes",
+ "values": {
+ "oneMember": {
+ "chipId": "1"
+ },
+ "twoToFive": {
+ "chipId": "2"
+ },
+ "sixToTwelve": {
+ "chipId": "3"
+ },
+ "thirteenToOneHundred": {
+ "chipId": "4"
+ },
+ "oneHundredToOneHundredK": {
+ "chipId": "5"
+ }
+ }
+ },
+ "orgType": {
+ "source": "messages/en/create/community/communityStructure.json#/organizationTypes",
+ "values": {
+ "workersCoop": {
+ "chipId": "1"
+ },
+ "mutualAid": {
+ "chipId": "2"
+ },
+ "openSource": {
+ "chipId": "3"
+ },
+ "nonprofit": {
+ "chipId": "4"
+ },
+ "forProfit": {
+ "chipId": "5"
+ },
+ "dao": {
+ "chipId": "6"
+ }
+ }
+ },
+ "scale": {
+ "source": "messages/en/create/community/communityStructure.json#/scaleOptions",
+ "values": {
+ "local": {
+ "chipId": "1"
+ },
+ "regional": {
+ "chipId": "2"
+ },
+ "national": {
+ "chipId": "3"
+ },
+ "global": {
+ "chipId": "4"
+ }
+ }
+ },
+ "maturity": {
+ "source": "messages/en/create/community/communityStructure.json#/maturityOptions",
+ "values": {
+ "earlyStage": {
+ "chipId": "1"
+ },
+ "growthStage": {
+ "chipId": "2"
+ },
+ "established": {
+ "chipId": "3"
+ },
+ "enterprise": {
+ "chipId": "4"
+ }
+ }
+ }
+}
diff --git a/data/create/customRule/communication.json b/data/create/customRule/communication.json
new file mode 100644
index 0000000..20b70b7
--- /dev/null
+++ b/data/create/customRule/communication.json
@@ -0,0 +1,321 @@
+{
+ "discord": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": true,
+ "openSource": true,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": false
+ }
+ },
+ "discourse-forum": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": true,
+ "nonprofit": true,
+ "openSource": true,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "email-distribution-list": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": true,
+ "nonprofit": true,
+ "openSource": true,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "github-gitlab": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": true,
+ "nonprofit": false,
+ "openSource": true,
+ "mutualAid": false,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "in-person-meetings": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": false
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": true,
+ "nonprofit": true,
+ "openSource": true,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": false,
+ "national": false,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": false
+ }
+ },
+ "loomio": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": true,
+ "nonprofit": true,
+ "openSource": true,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "matrix-element": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": true,
+ "mutualAid": false,
+ "workersCoop": false
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "signal": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": true,
+ "openSource": false,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": false
+ }
+ },
+ "slack": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": false,
+ "forProfit": true,
+ "nonprofit": true,
+ "openSource": true,
+ "mutualAid": false,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "video-meetings": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": true,
+ "nonprofit": true,
+ "openSource": true,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "whatsapp": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": false
+ },
+ "orgType": {
+ "dao": false,
+ "forProfit": false,
+ "nonprofit": true,
+ "openSource": false,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": false,
+ "national": false,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": false,
+ "enterprise": false
+ }
+ }
+}
diff --git a/data/create/customRule/conflictManagement.json b/data/create/customRule/conflictManagement.json
new file mode 100644
index 0000000..6050a1c
--- /dev/null
+++ b/data/create/customRule/conflictManagement.json
@@ -0,0 +1,553 @@
+{
+ "ad-hoc-arbitration": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": false
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": true,
+ "nonprofit": true,
+ "openSource": true,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": false,
+ "enterprise": false
+ }
+ },
+ "binding-arbitration": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": false,
+ "forProfit": true,
+ "nonprofit": true,
+ "openSource": false,
+ "mutualAid": false,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "binding-contracts": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": true,
+ "nonprofit": true,
+ "openSource": true,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "circle-processes": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": false
+ },
+ "orgType": {
+ "dao": false,
+ "forProfit": false,
+ "nonprofit": true,
+ "openSource": false,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": false,
+ "enterprise": false
+ }
+ },
+ "conflict-resolution-council": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": false,
+ "sixToTwelve": false,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": true,
+ "openSource": true,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": false,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "conflict-workshops": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": true,
+ "nonprofit": true,
+ "openSource": true,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "consensus-building": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": false
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": true,
+ "openSource": false,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": false
+ }
+ },
+ "facilitated-negotiation": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": true,
+ "nonprofit": true,
+ "openSource": true,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "interest-based-bargaining": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": true,
+ "nonprofit": true,
+ "openSource": true,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "internal-tribunal": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": false,
+ "sixToTwelve": false,
+ "thirteenToOneHundred": false,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": true,
+ "nonprofit": false,
+ "openSource": true,
+ "mutualAid": false,
+ "workersCoop": false
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": false,
+ "growthStage": false,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "judicial-committees": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": false,
+ "sixToTwelve": false,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": true,
+ "nonprofit": true,
+ "openSource": true,
+ "mutualAid": false,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": false,
+ "growthStage": false,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "lottery-sortition": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": false,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": true,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "managerial-decision": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": false,
+ "forProfit": true,
+ "nonprofit": true,
+ "openSource": false,
+ "mutualAid": false,
+ "workersCoop": false
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "mediation": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": true,
+ "nonprofit": true,
+ "openSource": true,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "non-binding-arbitration": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": false,
+ "forProfit": true,
+ "nonprofit": true,
+ "openSource": false,
+ "mutualAid": false,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "peer-mediation": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": false,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": false
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": true,
+ "openSource": false,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "restorative-practices": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": false
+ },
+ "orgType": {
+ "dao": false,
+ "forProfit": false,
+ "nonprofit": true,
+ "openSource": true,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": false
+ }
+ },
+ "rotational-judging": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": false,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": true,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "supermajority-vote": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": false,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": true,
+ "openSource": true,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ }
+}
diff --git a/data/create/customRule/decisionApproaches.json b/data/create/customRule/decisionApproaches.json
new file mode 100644
index 0000000..a934a33
--- /dev/null
+++ b/data/create/customRule/decisionApproaches.json
@@ -0,0 +1,930 @@
+{
+ "advisory-committees": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": false,
+ "sixToTwelve": false,
+ "thirteenToOneHundred": false,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": false,
+ "forProfit": true,
+ "nonprofit": true,
+ "openSource": false,
+ "mutualAid": false,
+ "workersCoop": false
+ },
+ "scale": {
+ "global": false,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "algorithm-driven-decisions": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": false,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": false,
+ "mutualAid": false,
+ "workersCoop": false
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "approval-voting": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": true,
+ "mutualAid": false,
+ "workersCoop": false
+ },
+ "scale": {
+ "global": false,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "autocratic-decision-making": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": false,
+ "sixToTwelve": false,
+ "thirteenToOneHundred": false,
+ "oneHundredToOneHundredK": false
+ },
+ "orgType": {
+ "dao": false,
+ "forProfit": true,
+ "nonprofit": true,
+ "openSource": false,
+ "mutualAid": false,
+ "workersCoop": false
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "collaborative-platforms": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": false,
+ "sixToTwelve": false,
+ "thirteenToOneHundred": false,
+ "oneHundredToOneHundredK": false
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": true,
+ "mutualAid": false,
+ "workersCoop": false
+ },
+ "scale": {
+ "global": false,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "consensus-decision-making": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": false,
+ "sixToTwelve": false,
+ "thirteenToOneHundred": false,
+ "oneHundredToOneHundredK": false
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": true,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": false,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "consensus-seeking-with-delegates": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": false,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": false
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": true,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": false,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "continuous-voting": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": true,
+ "sixToTwelve": false,
+ "thirteenToOneHundred": false,
+ "oneHundredToOneHundredK": false
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": true,
+ "mutualAid": false,
+ "workersCoop": false
+ },
+ "scale": {
+ "global": false,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "cumulative-voting": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": false,
+ "sixToTwelve": false,
+ "thirteenToOneHundred": false,
+ "oneHundredToOneHundredK": false
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": true,
+ "mutualAid": false,
+ "workersCoop": false
+ },
+ "scale": {
+ "global": false,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "delegated-decision-making": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": false,
+ "sixToTwelve": false,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": true,
+ "mutualAid": false,
+ "workersCoop": false
+ },
+ "scale": {
+ "global": false,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "deliberative-polling": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": false,
+ "sixToTwelve": false,
+ "thirteenToOneHundred": false,
+ "oneHundredToOneHundredK": false
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": true,
+ "mutualAid": false,
+ "workersCoop": false
+ },
+ "scale": {
+ "global": false,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "do-ocracy": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": false,
+ "sixToTwelve": false,
+ "thirteenToOneHundred": false,
+ "oneHundredToOneHundredK": false
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": true,
+ "mutualAid": false,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": false,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "elected-board-of-directors": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": false,
+ "sixToTwelve": false,
+ "thirteenToOneHundred": false,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": true,
+ "nonprofit": true,
+ "openSource": false,
+ "mutualAid": false,
+ "workersCoop": false
+ },
+ "scale": {
+ "global": false,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "executive-committees": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": false,
+ "sixToTwelve": false,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": true,
+ "nonprofit": true,
+ "openSource": true,
+ "mutualAid": false,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "first-past-the-post": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": false,
+ "sixToTwelve": false,
+ "thirteenToOneHundred": false,
+ "oneHundredToOneHundredK": false
+ },
+ "orgType": {
+ "dao": false,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": false,
+ "mutualAid": false,
+ "workersCoop": false
+ },
+ "scale": {
+ "global": false,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "hierarchical-decision-making": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": false,
+ "sixToTwelve": false,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": false,
+ "forProfit": true,
+ "nonprofit": true,
+ "openSource": false,
+ "mutualAid": false,
+ "workersCoop": false
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "holacracy": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": false,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": false
+ },
+ "orgType": {
+ "dao": false,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": false,
+ "mutualAid": false,
+ "workersCoop": false
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "investor-filled-board-seats": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": false,
+ "sixToTwelve": false,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": false,
+ "forProfit": true,
+ "nonprofit": true,
+ "openSource": false,
+ "mutualAid": false,
+ "workersCoop": false
+ },
+ "scale": {
+ "global": false,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "lazy-consensus": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": false,
+ "oneHundredToOneHundredK": false
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": true,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": false,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": false,
+ "established": false,
+ "enterprise": false
+ }
+ },
+ "lottery-sortition": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": false,
+ "sixToTwelve": false,
+ "thirteenToOneHundred": false,
+ "oneHundredToOneHundredK": false
+ },
+ "orgType": {
+ "dao": false,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": false,
+ "mutualAid": false,
+ "workersCoop": false
+ },
+ "scale": {
+ "global": false,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "majority-rule": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": false,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": false,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": false,
+ "mutualAid": false,
+ "workersCoop": false
+ },
+ "scale": {
+ "global": false,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": false,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "modified-consensus": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": true,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": false,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "negotiated-decisions": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": false,
+ "sixToTwelve": false,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": false,
+ "forProfit": true,
+ "nonprofit": true,
+ "openSource": false,
+ "mutualAid": false,
+ "workersCoop": false
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "proof-of-work": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": true,
+ "mutualAid": false,
+ "workersCoop": false
+ },
+ "scale": {
+ "global": false,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "quadratic-voting": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": true,
+ "mutualAid": false,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "random-choice": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": true,
+ "sixToTwelve": false,
+ "thirteenToOneHundred": false,
+ "oneHundredToOneHundredK": false
+ },
+ "orgType": {
+ "dao": false,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": false,
+ "mutualAid": false,
+ "workersCoop": false
+ },
+ "scale": {
+ "global": false,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "range-voting": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": false,
+ "sixToTwelve": false,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": true,
+ "mutualAid": false,
+ "workersCoop": false
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": false
+ },
+ "maturity": {
+ "earlyStage": false,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "ranked-choice-voting": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": false,
+ "sixToTwelve": false,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": true,
+ "mutualAid": false,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": false
+ },
+ "maturity": {
+ "earlyStage": false,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "rotational-leadership": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": false,
+ "oneHundredToOneHundredK": false
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": true,
+ "mutualAid": false,
+ "workersCoop": false
+ },
+ "scale": {
+ "global": false,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "sociocracy": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": false,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": true,
+ "mutualAid": true,
+ "workersCoop": false
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "supermajority-rule": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": true,
+ "sixToTwelve": false,
+ "thirteenToOneHundred": false,
+ "oneHundredToOneHundredK": false
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": true,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": false
+ },
+ "maturity": {
+ "earlyStage": false,
+ "growthStage": false,
+ "established": false,
+ "enterprise": false
+ }
+ },
+ "weighted-voting": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": true,
+ "mutualAid": false,
+ "workersCoop": false
+ },
+ "scale": {
+ "global": false,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ }
+}
diff --git a/data/create/customRule/membership.json b/data/create/customRule/membership.json
new file mode 100644
index 0000000..8867ce3
--- /dev/null
+++ b/data/create/customRule/membership.json
@@ -0,0 +1,553 @@
+{
+ "application-review": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": true,
+ "nonprofit": true,
+ "openSource": false,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": false,
+ "growthStage": false,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "collective-interviews": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": false
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": true,
+ "openSource": false,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": false,
+ "growthStage": false,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "consensus-or-vote-based-approval": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": false,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": false
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": true,
+ "openSource": false,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": false,
+ "established": false,
+ "enterprise": false
+ }
+ },
+ "contribution-based": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": false,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": true,
+ "openSource": true,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": false
+ }
+ },
+ "hybrid-approval-process": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": false,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": true,
+ "openSource": false,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": false,
+ "growthStage": false,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "identity-verification": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": true,
+ "nonprofit": true,
+ "openSource": false,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": false,
+ "growthStage": false,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "invitation-only": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": false
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": false,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": false
+ }
+ },
+ "lottery-sortition": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": false,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": true,
+ "openSource": false,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": false
+ }
+ },
+ "membership-agreement-or-pledge": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": false,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": true,
+ "openSource": true,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": false,
+ "growthStage": false,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "mentorship": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": true,
+ "nonprofit": true,
+ "openSource": true,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "open-access": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": false
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": false,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": false,
+ "enterprise": false
+ }
+ },
+ "orientation-required": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": false,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": true,
+ "openSource": true,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "pay-to-join": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": false,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": false,
+ "mutualAid": false,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": false,
+ "enterprise": false
+ }
+ },
+ "peer-sponsorship": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": false,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": true,
+ "openSource": false,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": true,
+ "enterprise": false
+ }
+ },
+ "referral-system-with-screening": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": false,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": true,
+ "nonprofit": true,
+ "openSource": true,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": false,
+ "growthStage": false,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "skill-based-contribution": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": false,
+ "forProfit": false,
+ "nonprofit": false,
+ "openSource": true,
+ "mutualAid": false,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": true,
+ "growthStage": true,
+ "established": false,
+ "enterprise": false
+ }
+ },
+ "skill-based-evaluation": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": true,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": true,
+ "nonprofit": true,
+ "openSource": false,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": false,
+ "growthStage": false,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "trial-period-provisional-membership": {
+ "size": {
+ "oneMember": true,
+ "twoToFive": false,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": true,
+ "openSource": true,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": false,
+ "growthStage": false,
+ "established": true,
+ "enterprise": true
+ }
+ },
+ "weighted-or-tiered-membership": {
+ "size": {
+ "oneMember": false,
+ "twoToFive": false,
+ "sixToTwelve": true,
+ "thirteenToOneHundred": true,
+ "oneHundredToOneHundredK": true
+ },
+ "orgType": {
+ "dao": true,
+ "forProfit": false,
+ "nonprofit": true,
+ "openSource": true,
+ "mutualAid": true,
+ "workersCoop": true
+ },
+ "scale": {
+ "global": true,
+ "national": true,
+ "regional": true,
+ "local": true
+ },
+ "maturity": {
+ "earlyStage": false,
+ "growthStage": false,
+ "established": true,
+ "enterprise": true
+ }
+ }
+}
diff --git a/docs/guides/template-recommendation-matrix.md b/docs/guides/template-recommendation-matrix.md
index e7edf44..467f60d 100644
--- a/docs/guides/template-recommendation-matrix.md
+++ b/docs/guides/template-recommendation-matrix.md
@@ -1,217 +1,209 @@
-# Template Recommendation Matrix — Implementation Context (CR-88)
+# Recommendation Matrix — Implementation Context (CR-88)
-**Status:** Draft / context doc. Reference only — not yet implemented.
+**Status:** Implemented (CR-88). This doc remains the spec — keep code in sync with it.
**Linear:** [CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-xlsx-sheets-ingestion)
-**Roadmap:** [`docs/backend-roadmap.md`](backend-roadmap.md) §4 (`RuleTemplate`) and §13.
-**Spec ticket:** [`docs/backend-linear-tickets.md`](backend-linear-tickets.md) Ticket 16.
+**Roadmap:** [`docs/guides/backend-roadmap.md`](backend-roadmap.md) §4 (`RuleTemplate`) and §13.
+**Spec ticket:** [`docs/guides/backend-linear-tickets.md`](backend-linear-tickets.md) Ticket 16.
-This doc consolidates the **four product-authored matrix spreadsheets** with the
-**existing data model, create-flow facets, and section structure** so we have a
-single reference while implementing the importer + recommendation API.
+This doc documents the **method facet matrix** that powers two ranking
+surfaces, both consuming the same underlying data:
-> **Scope note:** No data, API, or UI surface for this feature is in production
-> yet. **Backwards compatibility is not a constraint** — we will replace the
-> hand-typed `prisma/seed.ts` `COMPOSITION_BY_SLUG` map, the existing
-> `GET /api/templates` response shape, and the static `messages/en/create/*.json`
-> card decks where it makes the design cleaner.
+1. **Create-flow card ranking** — the four card-deck wizard steps
+ (`communication-methods`, `membership-methods`, `decision-approaches`,
+ `conflict-management`) reorder their `methods[]` array based on which
+ methods match the user's selected community facets.
+2. **Template grid ranking** — the curated `RuleTemplate` rows shown on
+ the marketing home `MarketingRuleStackSection.tsx` and `templates/`
+ page get scored by how many of their composed methods match the user's
+ facets, then sorted highest-first.
+
+> **Scope note:** Card / modal copy lives in
+> `messages/en/create/customRule/*.json` as flat `methods` arrays (one
+> entry per method, with `id`, `label`, `supportText`, `recommended`,
+> and a `sections` map). The matrix layer adds **facet data** — which
+> methods match which user facets — as committed JSON in
+> `data/create/customRule/*.json`, validated by Zod and seeded into
+> `MethodFacet`. There is no spreadsheet importer, no `xlsx` runtime
+> dependency, and the spreadsheets in `~/Downloads/` were a **one-time
+> authoring artifact** — they are not part of the runtime contract.
---
-## 1. Goal (one paragraph)
+## 1. Where things live (post-reorg)
-Replace the hand-curated `prisma/seed.ts` `COMPOSITION_BY_SLUG` map with a
-**spreadsheet-authored matrix** for each rule section
-(Communication, Membership, Decision-making, Conflict management — and later
-Values), where each row is a **method/pattern card** and each column is either
-**long-form copy that populates the card UI** or a **facet flag** (✓/x or score)
-that the recommendation engine uses to filter and rank cards based on the
-user's create-flow answers (community size, organization type, location/scale,
-maturity).
+### 1a. Card / modal copy — `messages/en/create/customRule/.json`
-The same authoring contract should make it trivial for product to ship updated
-spreadsheets and have the create-flow card decks (and the home/templates page
-recommendations) update without any code changes.
+Source of truth for all displayed text. Each file holds the page chrome
+(`page`, `confirmModal`, `addPlatform`/`addApproach`, `sectionHeadings`,
+plus decision-approaches' `sidebar` / `messageBox` / `cardStack` /
+`scopeAddButtonLabel`) plus a flat `methods` array. Read via
+`useMessages().create.customRule..methods`. Never duplicated
+anywhere else; the recommendation API never returns copy.
+
+### 1b. Facet data — `data/create/customRule/.json`
+
+Mirrors the messages path one-to-one (same filename, different root) so a
+content reviewer can read the two side-by-side. Each file is a typed JSON
+object mapping each method `id` → its facet matches across the four
+canonical groups (`size`, `orgType`, `scale`, `maturity`). Validated by
+the Zod schema at seed time and in CI (see §3 — no app-boot validator).
+Lives **outside** `messages//` because facets are not localized
+— they describe the methods, not the UI text.
+
+### 1c. Messages folder reorg (prerequisite)
+
+Today every `messages/en/create/*.json` file sits flat in one folder.
+Plan: regroup into the three Figma stages from
+[`docs/create-flow.md`](../create-flow.md) §"Product stages":
+
+| New folder | Files |
+| --- | --- |
+| `messages/en/create/community/` | `informational.json`, `communityName.json`, `communityStructure.json`, `communityContext.json`, `communitySize.json`, `communityUpload.json`, `communitySave.json`, `review.json` |
+| `messages/en/create/customRule/` | `coreValues.json`, `communication.json`, `membership.json`, `decisionApproaches.json`, `conflictManagement.json` |
+| `messages/en/create/reviewAndComplete/` | `confirmStakeholders.json`, `finalReview.json`, `completed.json`, `publish.json` |
+| `messages/en/create/` (root, cross-cutting) | `footer.json`, `topNav.json`, `draftHydration.json`, `templateReview.json`, plus layout-shell strings (`select.json`, `text.json`, `upload.json`) |
+
+Touchpoints: every file move, the imports in
+[`messages/en/index.ts`](../../messages/en/index.ts), the namespace shape
+exposed via `useMessages()`, and every screen that reads
+`m.create.` becomes `m.create..`. This is a
+mechanical refactor — no behavior change.
+
+**Sequencing (explicit).** This reorg is **its own ticket** and **must
+land before** any of §6–§9 below. CR-88's facet JSON paths
+(`data/create/customRule/.json`) and `useMessages()` namespaces
+(`m.create.customRule.`) assume the reorg is already in place.
+Concretely, ship in this order:
+
+1. **Reorg PR (separate ticket).** Move every `messages/en/create/*.json`
+ into the table above, update `messages/en/index.ts`, update every
+ `useMessages().create.` callsite to
+ `useMessages().create..`, run `npx tsc --noEmit` and
+ `npx vitest run` green. No new behavior.
+2. **CR-88 PR (this doc).** Adds `data/create/customRule/`,
+ `MethodFacet`, the seed step, and the two API endpoints — all reading
+ the post-reorg paths.
+
+If the reorg slips, do **not** start CR-88 against the flat paths and
+plan to migrate later — the path mirroring between `messages/` and
+`data/` is the whole point of §1a/§1b and is fragile to retrofit.
---
-## 2. The four spreadsheets
+## 2. Facet groups (the canonical 19-column matrix)
-All four xlsx files share **the same column shape**: leading **content
-columns** + trailing **facet columns** (✓ / x cells). Sheet name is `Current`
-in every workbook.
+Four facet groups, 19 values total, identical across all four sections.
+These are the only dimensions the recommendation engine scores against.
-### 2.1 Shared facet columns (last 19, identical across the four sheets)
+| Group | Value `id`s (canonical lowercase keys) | Wizard chip labels | `CreateFlowState` field |
+| --- | --- | --- | --- |
+| `size` | `oneMember`, `twoToFive`, `sixToTwelve`, `thirteenToOneHundred`, `oneHundredToOneHundredK` | `1 member`, `2-5 members`, `6-12 members`, `13-100 members`, `100-100,000 members` | `selectedCommunitySizeIds` |
+| `orgType` | `dao`, `forProfit`, `nonprofit`, `openSource`, `mutualAid`, `workersCoop` | `DAO`, `For profit business`, `Nonprofit`, `Open source project`, `Mutual aid`, `Worker's coop` | `selectedOrganizationTypeIds` |
+| `scale` | `global`, `national`, `regional`, `local` | `Global`, `National`, `Regional`, `Local` | `selectedScaleIds` |
+| `maturity` | `earlyStage`, `growthStage`, `established`, `enterprise` | `Early stage`, `Growth stage`, `Established`, `Enterprise` | `selectedMaturityIds` |
-Order is preserved here because the columns are positional in the sheets:
+**Wizard chip ids vs facet value keys.** Wizard chips today use positional
+1..N ids inside their messages files (see `chipRowsFromLabels` in
+`app/(app)/create/screens/select/CommunityStructureSelectScreen.tsx` and
+`CommunitySizeSelectScreen.tsx`). The recommendation layer needs a
+**stable lookup** from those positional chip ids to the canonical facet
+value keys above. Live that lookup table in
+`data/create/customRule/_facetGroups.json` so every consumer sees the
+same mapping.
-| # | Column header (xlsx) | Maps to wizard step / state field | Wizard chip label (`messages/en/create/...`) |
-|---|---|---|---|
-| 1 | `1 member` | `community-size` → `selectedCommunitySizeIds` (id `"1"`) | `1 member` |
-| 2 | `2-5 members` | id `"2"` | `2-5 members` |
-| 3 | `6-12 members` | id `"3"` | `6-12 members` |
-| 4 | `13-100 members` | id `"4"` | `13-100 members` |
-| 5 | `100-100,000 members` | id `"5"` | `100-100,000 members` |
-| 6 | `Organization Type:DAO` (or `DAO` in conflict/comms/membership) | `community-structure` → `selectedOrganizationTypeIds` (id `"6"` in `organizationTypes`) | `DAO` |
-| 7 | `Organization Type:For profit business` (or `For profit business`) | id `"5"` in `organizationTypes` | `For profit business` |
-| 8 | `Organization Type:Nonprofit` (or `Nonprofit`) | id `"4"` in `organizationTypes` | `Nonprofit` |
-| 9 | `Organization Type:Open source project` (or `Open source project`) | id `"3"` | `Open source project` |
-| 10 | `Organization Type:Mutual aid` (or `Mutual aid`) | id `"2"` | `Mutual aid` |
-| 11 | `Organization Type: Worker’s coop` (or `Worker’s coop`) | id `"1"` | `Worker’s coop` |
-| 12 | `Location: Global` (or `Global`) | `community-structure` → `selectedScaleIds` (id `"4"` in `scaleOptions`) | `Global` |
-| 13 | `Location: National` (or `National`) | id `"3"` | `National` |
-| 14 | `Location: Regional` (or `Regional`) | id `"2"` | `Regional` |
-| 15 | `Location: Local` (or `Local`) | id `"1"` | `Local` |
-| 16 | `Organizational Maturity: Early stage` (or `Early stage`) | `community-structure` → `selectedMaturityIds` (id `"1"` in `maturityOptions`) | `Early stage` |
-| 17 | `Organizational Maturity: Growth stage` (or `Growth stage`) | id `"2"` | `Growth stage` |
-| 18 | `Organizational Maturity: Established` (or `Established`) | id `"3"` | `Established` |
-| 19 | `Organizational Maturity: Enterprise` (or `Enterprise`) | id `"4"` | `Enterprise` |
+Shape: a single object keyed by group, then by the canonical value id,
+with the wizard chip's positional id and source messages path. The
+chip's display label is **not** duplicated here — it stays in the
+messages file (the lookup is just position → canonical id). Order in
+each group's `values` follows §2's canonical key order, **not** the
+chip's positional order in the messages file:
-**Important normalization rules (importer must enforce):**
+```json
+{
+ "size": {
+ "source": "messages/en/create/community/communitySize.json#/communitySizes",
+ "values": {
+ "oneMember": { "chipId": "1" },
+ "twoToFive": { "chipId": "2" },
+ "sixToTwelve": { "chipId": "3" },
+ "thirteenToOneHundred": { "chipId": "4" },
+ "oneHundredToOneHundredK": { "chipId": "5" }
+ }
+ },
+ "orgType": {
+ "source": "messages/en/create/community/communityStructure.json#/organizationTypes",
+ "values": {
+ "workersCoop": { "chipId": "1" },
+ "mutualAid": { "chipId": "2" },
+ "openSource": { "chipId": "3" },
+ "nonprofit": { "chipId": "4" },
+ "forProfit": { "chipId": "5" },
+ "dao": { "chipId": "6" }
+ }
+ },
+ "scale": {
+ "source": "messages/en/create/community/communityStructure.json#/scaleOptions",
+ "values": {
+ "local": { "chipId": "1" },
+ "regional": { "chipId": "2" },
+ "national": { "chipId": "3" },
+ "global": { "chipId": "4" }
+ }
+ },
+ "maturity": {
+ "source": "messages/en/create/community/communityStructure.json#/maturityOptions",
+ "values": {
+ "earlyStage": { "chipId": "1" },
+ "growthStage": { "chipId": "2" },
+ "established": { "chipId": "3" },
+ "enterprise": { "chipId": "4" }
+ }
+ }
+}
+```
-- Decision-making prefixes columns with `Organization Type:`, `Location:`,
- `Organizational Maturity:`. The other three sheets drop the prefix. Importer
- should normalize to a single canonical key (e.g.
- `orgType.workersCoop`, `scale.local`, `maturity.earlyStage`, `size.6_12`).
-- Cell value semantics: `✓` → match, `x` (lowercase) → no match, blank → no
- match, numbers → optional weighted score (only `Decision-making.xlsx` row 32
- contains a non-symbol cell — `"Military, Corporations"` in the size column —
- see §2.4 data-quality issues).
-- Wizard chip ids are **positional 1..N** within each `messages/en/create/*`
- array (see `chipRowsFromLabels` in
- `app/(app)/create/screens/select/CommunityStructureSelectScreen.tsx` lines 49–57).
- The importer should emit a stable lookup table mapping
- `(facetGroup, label) → wizardChipId` so the recommendation engine can match
- a user's `selectedXxxIds` against the matrix without depending on label
- spelling.
-- Curly apostrophes appear in `Worker’s coop`. Compare on a normalized key,
- not on raw label.
-
-### 2.2 Communication Methods (`Communication Methods.xlsx`, sheet `Current`)
-
-Maps 1:1 to `messages/en/create/communication.json` and the
-`communication-methods` step
-(`app/(app)/create/screens/card/CommunicationMethodsScreen.tsx`).
-
-**Content columns (positions 1–5):**
-
-| Sheet column | Card field |
-|---|---|
-| `Label` | `cards[].label` and `modals[].title` |
-| `Description` | `cards[].supportText` and `modals[].description` |
-| `Core Principle & Scope` | `modals[].sections.corePrinciple` |
-| `Logistics, Admin & Norms` | `modals[].sections.logisticsAdmin` |
-| `Code of Conduct` | `modals[].sections.codeOfConduct` |
-
-`SECTION_FIELDS = ["corePrinciple", "logisticsAdmin", "codeOfConduct"]` is
-the source of truth (`CommunicationMethodsScreen.tsx`).
-
-**Card rows (11):** In-Person Meetings · Signal · Video Meetings · Loomio ·
-Matrix / Element · GitHub / GitLab · Discord · Email Distribution List · Slack
-· WhatsApp · Discourse (Forum).
-
-### 2.3 Membership / Group-Membership (`Group_Membership_Methods.xlsx`, sheet `Current`)
-
-Maps to the `membership-methods` step
-(`app/(app)/create/screens/card/MembershipMethodsScreen.tsx`) and
-`messages/en/create/membership.json`.
-
-**Content columns (positions 1–5):**
-
-| Sheet column | Card field (proposed naming) |
-|---|---|
-| `Label` | `cards[].label` / modal title |
-| `Description` | `cards[].supportText` / modal description |
-| `Eligibility & Philosophy` | modal section A (`eligibilityPhilosophy`) |
-| `Joining Process` | modal section B (`joiningProcess`) |
-| `Expectations & Removal` | modal section C (`expectationsRemoval`) |
-
-**Card rows (19):** Open Access · Orientation Required · Invitation Only ·
-Contribution Based · Mentorship · Peer Sponsorship · Consensus or Vote-Based
-Approval · Trial Period / Provisional Membership · Referral System with
-Screening · Membership Agreement or Pledge · Weighted or Tiered Membership ·
-Hybrid Approval Process · Skill-Based Contribution · Pay-to-Join · Application
-& Review · Identity Verification · Collective Interviews · Skill-Based
-Evaluation · Lottery / Sortition.
-
-> The wizard's existing `membership.json` modal section keys do not yet match
-> these. Since backwards compatibility is not a constraint, **rename the
-> wizard's section keys to match the matrix** (`eligibilityPhilosophy` /
-> `joiningProcess` / `expectationsRemoval`) when wiring this up — the existing
-> copy is placeholder.
-
-### 2.4 Decision-making (`Decision-making.xlsx`, sheet `Current`)
-
-Maps to the `decision-approaches` step
-(`app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx`) and
-`messages/en/create/rightRail.json`.
-
-**Content columns (positions 1–7):**
-
-| Sheet column | Card field (proposed naming) |
-|---|---|
-| `Label` | card title |
-| `Description` | card support text |
-| `Core Principle` | modal section A (`corePrinciple`) |
-| `Applicable Scope` | modal section B (`applicableScope`) — free-text examples, e.g. `"Daily Operations, Minor Expenditures"` |
-| `Consensus Level` | numeric 0.0–1.0 stored under `scalars.consensusLevel` (e.g. `0.51`, `0.67`, `1.0`) — drives the **Consensus axis** in any future visual sort/filter |
-| `Step-by-Step Instructions` | modal section C (`stepByStep`) |
-| `Objections & Deadlocks` | modal section D (`objectionsDeadlocks`) |
-
-**Card rows (32):** Lazy Consensus · Do-ocracy · Consensus Decision-Making ·
-Rotational Leadership · Modified Consensus · Consensus Seeking with Delegates
-· Sociocracy · Supermajority Rule · Ranked Choice Voting · Range Voting ·
-Majority Rule · Approval Voting · Weighted Voting · Cumulative Voting ·
-Quadratic Voting · Continuous Voting · Holacracy · Collaborative Platforms ·
-Deliberative Polling · Investor-Filled Board Seats · Elected Board of
-Directors · Advisory Committees · Delegated Decision-Making · Executive
-Committees · First Past the Post · Lottery/Sortition · Proof of Work · Random
-Choice · Algorithm-Driven Decisions · Autocratic Decision-Making ·
-Hierarchical Decision-Making · Negotiated Decisions.
-
-**Data-quality issues to handle in the importer (do not silently drop):**
-
-- Row 32 (`Hierarchical Decision-Making`): the `Consensus Level` cell contains
- `"Military, Corporations"` (the value clearly belongs to `Applicable Scope`,
- which itself already contains `"Military, Corporations"`). Importer should
- flag this as a validation error and require a fix in the source workbook
- rather than try to repair it.
-- Row 11 (`Range Voting`): the **last facet column** (`Maturity: Enterprise`)
- is empty in the source — treat empty as `x` (no match) **only after** the
- importer logs a warning so the author knows it wasn't intentional ✓.
-
-### 2.5 Conflict Management (`Conflict Management Methods.xlsx`, sheet `Current`)
-
-Maps to the `conflict-management` step
-(`app/(app)/create/screens/card/ConflictManagementScreen.tsx`) and
-`messages/en/create/conflictManagement.json`.
-
-**Content columns (positions 1–6):**
-
-| Sheet column | Card field (proposed naming) |
-|---|---|
-| `Title` | card title (note: not `Label` like the other three) |
-| `Description` | card support text |
-| `Core Principle` | modal section A (`corePrinciple`) |
-| `Applicable Scope` | modal section B (`applicableScope`) |
-| `Process Protocol` | modal section C (`processProtocol`) |
-| `Restoration & Fallbacks` | modal section D (`restorationFallbacks`) |
-
-**Card rows (19):** Peer Mediation · Conflict Resolution Council · Facilitated
-Negotiation · Ad Hoc Arbitration · Conflict Workshops · Supermajority Vote ·
-Interest-Based Bargaining · Restorative Practices · Mediation · Circle
-Processes · Judicial Committees · Managerial Decision · Internal Tribunal ·
-Consensus Building · Binding Arbitration · Non-Binding Arbitration · Binding
-Contracts · Lottery/Sortition · Rotational Judging.
-
-> Conflict Management sheet uses `Title` instead of `Label` and omits the
-> `Organization Type:` / `Location:` / `Organizational Maturity:` prefixes —
-> normalize both at import time.
+Validated by the same Zod schema module as the facet files
+(`lib/server/validation/methodFacetsSchemas.ts`). The parity test in §12
+asserts every `chipId` matches an actual position in the referenced
+messages file (off-by-one fails loudly). Adding a chip in messages
+without updating this file → schema error.
---
-## 3. Existing data model & wizard surface area
+## 3. Method inventory per section
-### 3.1 `RuleTemplate` (today)
+Every method's slug must exist as a `methods[].id` in the corresponding
+messages file *and* as a key in the corresponding facet file. Parity is
+enforced at two points (no app-boot hook):
+
+- **`prisma db seed`** — `loadSectionFacets()` runs the Zod schema
+ against each `data/create/customRule/.json` file before
+ upserting; any orphan slug or unknown facet value fails the seed.
+- **`tests/unit/methodFacets.test.ts`** — runs in CI on every PR;
+ re-asserts the same parity statically (no DB needed) so authoring
+ errors are caught before the seed ever runs.
+
+There is intentionally **no Next.js app-boot validator** — schema
+failures should surface in CI/seed, not at request time.
+
+| Section | Screen | Messages file | Facet file | Method count |
+| --- | --- | --- | --- | --- |
+| `communication` | `app/(app)/create/screens/card/CommunicationMethodsScreen.tsx` | `messages/en/create/customRule/communication.json` | `data/create/customRule/communication.json` | 11 |
+| `membership` | `app/(app)/create/screens/card/MembershipMethodsScreen.tsx` | `messages/en/create/customRule/membership.json` | `data/create/customRule/membership.json` | 19 |
+| `decisionApproaches` | `app/(app)/create/screens/right-rail/DecisionApproachesScreen.tsx` | `messages/en/create/customRule/decisionApproaches.json` | `data/create/customRule/decisionApproaches.json` | 32 |
+| `conflictManagement` | `app/(app)/create/screens/card/ConflictManagementScreen.tsx` | `messages/en/create/customRule/conflictManagement.json` | `data/create/customRule/conflictManagement.json` | 19 |
+
+`section` keys above are the canonical lowercase camelCase tokens used in
+the DB, the API query string, and the Zod schemas. The four xlsx
+authoring artifacts in `~/Downloads/` (see "Source workbooks" appendix
+below) are the human reference for which methods match which facets;
+they do not ship.
+
+---
+
+## 4. Existing data model & wizard surface area
+
+### 4.1 `RuleTemplate` (today, unchanged)
```64:73:prisma/schema.prisma
model RuleTemplate {
@@ -228,30 +220,16 @@ model RuleTemplate {
`body` JSON is the rendered rule document
(`{ sections: [{ categoryName, entries: [{ title, body }] }, ...] }`),
-authored today by the `bodyFromXlsxComposition()` helper in
-`prisma/seed.ts` from a hand-typed `COMPOSITION_BY_SLUG` map.
+authored today by `bodyFromXlsxComposition()` in `prisma/seed.ts` from
+the hand-typed `COMPOSITION_BY_SLUG` map. **Stays hand-curated** — the
+matrix doesn't change how templates are authored, only how they are
+ranked. Section ordering (Values → Communication → Membership →
+Decision-making → Conflict management) is set by `governancePatternBody`
+and is canonical.
-**Section ordering (canonical):** Values → Communication → Membership →
-Decision-making → Conflict management. Final-review and `governancePatternBody`
-both rely on this exact order and casing.
+### 4.2 Wizard facets captured today (`CreateFlowState`)
-```16:60:prisma/seed.ts
-function governancePatternBody(coreValues: string): Prisma.InputJsonValue {
- return {
- sections: [
- { categoryName: "Values", entries: [{ title: "Core stance", body: coreValues }] },
- { categoryName: "Communication", entries: [...] },
- { categoryName: "Membership", entries: [...] },
- { categoryName: "Decision-making", entries: [...] },
- { categoryName: "Conflict management", entries: [...] },
- ],
- };
-}
-```
-
-### 3.2 Wizard facets captured today (`CreateFlowState`)
-
-```83:95:app/(app)/create/types.ts
+```ts
selectedCommunitySizeIds?: string[];
selectedOrganizationTypeIds?: string[];
selectedScaleIds?: string[];
@@ -263,312 +241,345 @@ selectedDecisionApproachIds?: string[];
selectedConflictManagementIds?: string[];
```
-The first four are exactly the four **facet groups** in the matrix sheets. The
-last four are the user's chosen **cards per section**, which the recommendation
-flow can either pre-select (when picked from a template) or feed back into
-ranking.
+The first four are exactly the four facet groups in §2. The last four
+are the user's chosen methods per section (used to pre-rank or to feed
+recommendations on later steps). `createFlowStateSchema` in
+`lib/server/validation/createFlowSchemas.ts` is the canonical Zod schema
+— recommendation-side schemas should import the facet-id arrays from
+there rather than redefine them.
-These same fields are validated server-side by `createFlowStateSchema` in
-`lib/server/validation/createFlowSchemas.ts` (lines 47–106) — the recommend
-endpoint should reuse that schema (or a strict subset) instead of redefining
-the facet shape.
+### 4.3 Wizard step order
-### 3.3 Wizard step order
-
-Source of truth is `app/(app)/create/utils/flowSteps.ts` (`FLOW_STEP_ORDER`). The
-relevant slice is:
+Source of truth: `app/(app)/create/utils/flowSteps.ts` (`FLOW_STEP_ORDER`).
+Relevant slice for the matrix:
```
review → core-values → communication-methods → membership-methods →
decision-approaches → conflict-management → confirm-stakeholders → final-review
```
-`docs/create-flow.md`'s step table is **stale**; trust `flowSteps.ts`.
-
-### 3.4 Where templates already surface in the UI
+### 4.4 Where templates surface in the UI (in scope for ranking)
| Surface | File |
-|---|---|
+| --- | --- |
| Marketing home "Popular templates" | `app/(marketing)/_components/MarketingRuleStackSection.tsx` |
| Templates index | `app/(marketing)/templates/page.tsx` |
| Template preview (by slug) | `app/(app)/create/review-template/[slug]/page.tsx` |
| "Use without changes" → publish | `app/(app)/create/CreateFlowLayoutClient.tsx` `handleUseTemplateWithoutChanges` |
-| API list | `app/api/templates/route.ts` (GET only, no params) |
+| API list | `app/api/templates/route.ts` (GET only, no params today) |
-There is currently **no** recommendation logic, no facet filtering, and the
-`/create/informational?template=` query param is a known no-op (see
-`CreateFlowLayoutClient.tsx` lines 479–482).
+Template ranking adds optional facet query params to `/api/templates`;
+the no-facets path keeps today's curated ordering. The
+`/create/informational?template=` query-param prefill is a known
+no-op (`CreateFlowLayoutClient.tsx`); fixing it is **out of scope**.
---
-## 4. Repo conventions to follow (don't reinvent)
+## 5. Repo conventions to follow (don't reinvent)
-These are the patterns the implementation must match. References point at the
-canonical example for each.
+References point at the canonical example for each.
-### 4.1 API routes (`app/api/**/route.ts`)
+### 5.1 API routes (`app/api/**/route.ts`)
-`app/api/drafts/me/route.ts` is the reference — every new route in this
-feature must match this exact shape:
+`app/api/drafts/me/route.ts` is the reference. Every new route in this
+feature must match:
-1. `if (!isDatabaseConfigured()) return dbUnavailable();` — always first.
+1. `if (!isDatabaseConfigured()) return dbUnavailable();` — always first
(`lib/server/env.ts`, `lib/server/responses.ts`).
2. For auth'd routes: `const user = await getSessionUser();` then
- `return NextResponse.json({ error: "Unauthorized" }, { status: 401 });` if
- missing. (Recommendation read endpoints can stay unauthenticated.)
-3. For request bodies: `readLimitedJson(request)` →
- `.safeParse(parsed.value)` → `jsonFromZodError(validated.error)` on
- failure. (`lib/server/validation/requestBody.ts`,
+ `NextResponse.json({ error: "Unauthorized" }, { status: 401 })` if
+ missing. Recommendation read endpoints stay unauthenticated.
+3. Request bodies: `readLimitedJson(request)` →
+ `.safeParse(parsed.value)` → `jsonFromZodError(validated.error)`
+ on failure (`lib/server/validation/requestBody.ts`,
`lib/server/validation/zodHttp.ts`).
-4. Success: `NextResponse.json({ : data })` — flat object with one or two
- named keys, no `success: true` envelope.
+4. Success: `NextResponse.json({ : data })` — flat object, no
+ `success: true` envelope.
5. Errors: structured `{ error: { code, message } }` (Zod path) or simple
- `{ error: "..." }` (auth path). Match what's already in the repo.
-6. Server-side query helpers swallow Prisma failures and return `[]`/`null`
- (see `listRuleTemplatesFromDb` in `lib/server/ruleTemplates.ts` lines 9–30).
- Routes do **not** wrap helper calls in `try/catch`.
+ `{ error: "..." }` (auth path).
+6. Server-side query helpers swallow Prisma failures and return
+ `[]`/`null` (see `listRuleTemplatesFromDb` in
+ `lib/server/ruleTemplates.ts`). Routes do **not** wrap helper calls
+ in `try/catch`.
-### 4.2 Zod schemas live in `lib/server/validation/`
+### 5.2 Zod schemas live in `lib/server/validation/`
-- One file per feature area (e.g. `createFlowSchemas.ts`, future
- `templateRecommendationSchemas.ts`).
+- One file per feature area
+ (`lib/server/validation/methodFacetsSchemas.ts` for this feature).
- Export the schema **and** the inferred type
(`export type X = z.infer`).
-- Wrap any free-form JSON blobs with `assertPlainJsonValue` /
- `DEFAULT_PLAIN_JSON_LIMITS` (`lib/server/validation/plainJson.ts`) so the
- size/depth bounds match the rest of the API.
-- Reuse `FLOW_STEP_ORDER` and existing array bounds where they overlap (see
- the `selectedXxxMethodIds` arrays in `createFlowStateSchema`).
+- Reuse facet-id arrays from `createFlowStateSchema` rather than
+ redeclaring them.
-### 4.3 Prisma access
+### 5.3 Prisma access
- Singleton: `import { prisma } from "lib/server/db";` — never
- `new PrismaClient()` from app code. (Standalone scripts under `scripts/` /
- `prisma/` may instantiate their own, matching `prisma/seed.ts` lines
- 363–403.)
-- Server-only "fetch/list" helpers live under `lib/server/.ts`,
+ `new PrismaClient()` from app code.
+- Server-only fetch/list helpers live under `lib/server/.ts`,
return DTOs (not raw Prisma rows), and degrade gracefully
(`isDatabaseConfigured()` short-circuit, `try/catch` → empty result).
-- No `$transaction` patterns exist yet; **introduce one** for the importer
- (write `TemplateMethod` + `TemplateMethodFacet` rows atomically).
+- Use `prisma.$transaction` for the seed step that swaps facet rows
+ (delete-then-create per `(section, slug)` pair atomically).
-### 4.4 DTO style
+### 5.4 DTO style
-- Hand-written `type` aliases that mirror a Prisma `select` clause, co-located
- with the consumer (see `RuleTemplateDto` in
- `lib/create/fetchTemplates.ts` lines 5–14).
-- For a feature with both client and server consumers, put the type in
- `lib//types.ts` and import from both sides.
+Hand-written `type` aliases that mirror a Prisma `select` clause,
+co-located with the consumer (see `RuleTemplateDto` in
+`lib/create/fetchTemplates.ts`).
-### 4.5 Standalone scripts
+### 5.5 Tests
-- Use `tsx` (already a dev dep; entry point `package.json` `prisma.seed`
- field).
-- Layout matches `prisma/seed.ts`: `async function main()`, log a one-line
- success summary, `console.error(e); process.exit(1)` on failure,
- `await prisma.$disconnect()` in `finally`.
-- Add an entry to `package.json` `scripts` (e.g.
- `"templates:import": "tsx scripts/import-templates-xlsx.ts"`).
-- No shared dotenv loader — rely on env from the shell / Next runtime.
-- Support a `--dry-run` flag that validates + diffs without writing.
+- Vitest under `tests/unit/*.test.ts` for schemas and pure functions
+ (see `tests/unit/createFlowValidation.test.ts`).
+- Test the facet JSON files themselves with a parity test
+ (`tests/unit/methodFacets.test.ts`) that loads each `data/` file
+ alongside its messages file and asserts every `methods[].id` has a
+ facet entry and vice-versa.
+- API routes are not unit-tested today; cover behavior indirectly via
+ schema tests.
-### 4.6 Tests
+### 5.6 Logging
-- Vitest under `tests/unit/*.test.ts` for parsers / validators / pure
- functions (see `tests/unit/createFlowValidation.test.ts`).
-- API routes are not unit-tested today; cover route behavior indirectly with a
- `tests/unit/templateRecommendationSchemas.test.ts` (Zod) plus a fixture
- workbook + importer test under `tests/unit/importTemplatesXlsx.test.ts`.
-- E2E for the wizard (if needed) goes under `tests/e2e/*.spec.ts` — not
- required for CR-88 acceptance.
-- Test utilities: `tests/utils/test-utils.tsx` (`renderWithProviders`); MSW
- server in `tests/msw/server.ts`. No Prisma mock helper exists; importer test
- should use a fixture workbook and stub the `prisma` client at the import
- site.
+Use `logger` from `lib/logger.ts` for any server-side info/warn/error in
+scripts and route helpers. No `apiError` helper exists; do not introduce
+one.
-### 4.7 Logging
+### 5.7 i18n stays the source of truth for copy
-- Use `logger` from `lib/logger.ts` for any server-side info/warn/error in
- scripts and route helpers (matches `app/api/auth/magic-link/request/route.ts`
- lines 14–15, 35–45). No `apiError` helper exists; do not introduce one.
+Card decks and modal copy live in
+`messages/en/create/customRule/.json` (post-reorg) and are read
+via `useMessages().create.customRule.` (`messages/en/index.ts`,
+`app/contexts/MessagesContext.tsx`). The matrix never puts copy in the
+DB. The recommendation API returns slugs and scores only — never copy —
+so Storybook stories, MSW handlers, and Vitest specs keep importing the
+message JSON statically and don't need an API mock for content.
-### 4.8 New deps
+### 5.8 Component-prop normalization rule
-- `xlsx` (SheetJS) is **not** currently in `package.json`. Add it as a
- **prod** dep only if the importer is invoked from app code; if the importer
- is script-only, `devDependency` is fine. CR-88's plan calls for a
- build/CLI-time importer, so `devDependencies` is the right home.
-
-### 4.9 i18n / `messages/` constraint
-
-- Card decks and modal copy are currently keyed in
- `messages/en/.json` and read via
- `useMessages().create.` (`app/contexts/MessagesContext.tsx`,
- `messages/en/index.ts`).
-- Only `en` is wired today, so we **don't** have a translation backlog
- blocking us. The wiring step (§7) replaces `messages/en/create/{communication,
- membership,rightRail,conflictManagement}.json` card/modal payloads with
- values served by `GET /api/template-methods` (still keyed by the same
- message namespace shape so future i18n can layer on if needed). Header
- strings, button labels, and other purely-static UI copy stay in
- `messages/en/*`.
-
-### 4.10 `.cursorrules` scope
-
-- The repo's `.cursorrules` PascalCase / lowercase normalization rule applies
- to **React component props only**. It does **not** apply to API query
- params, request bodies, or DB columns. The recommendation API uses lowercase
- facet keys throughout (`orgType`, `scale`, `maturity`, `size`).
+The repo's lowercase prop normalization rule
+(`.cursor/rules/component-props.mdc`) applies to **React component props
+only**. It does **not** apply to API query params, DB columns, JSON
+keys, or messages keys. Conventions used by this feature: facet group
+ids and facet values are camelCase (`orgType`, `workersCoop`); messages
+keys are camelCase (`stepByStepInstructions`, `restorationFallbacks`);
+method slugs are kebab-case (`peer-mediation`, `lazy-consensus`).
---
-## 5. Authoring contract (informs §6 storage + §7 importer)
+## 6. Authoring contract (the JSON shape)
-The four spreadsheets together imply this row schema (per matrix workbook):
+Each `data/create/customRule/.json` file is a single object
+keyed by method slug. Each entry has the four facet groups, each holding
+booleans (or numeric weights, optional) per canonical value id.
```ts
-type MatrixRow = {
- /** Stable slug derived from `Label`/`Title` (kebab-case, lowercase, ascii).
- * Used as the card id everywhere downstream. */
- id: string;
+type FacetMatch = boolean | { match: boolean; weight?: number };
- /** Section this row belongs to. One of: communication, membership,
- * decisionMaking, conflictManagement. (values is not yet sheet-driven.) */
- section: "communication" | "membership" | "decisionMaking" | "conflictManagement";
-
- /** Card-facing copy. Keys differ per section; importer normalizes. */
- card: {
- label: string;
- description: string;
- /** Section-specific long-form fields (3–4 per section). */
- modalSections: Record;
- };
-
- /** Optional numeric scalar fields (e.g. decisionMaking `Consensus Level`). */
- scalars?: Record;
-
- /** Facet matches (✓ → true, x/blank → false). Keys are canonical facet ids. */
- facets: {
- size: Record<"1" | "2_5" | "6_12" | "13_100" | "100_100k", boolean>;
- orgType: Record<"dao" | "forProfit" | "nonprofit" | "openSource" | "mutualAid" | "workersCoop", boolean>;
- scale: Record<"global" | "national" | "regional" | "local", boolean>;
- maturity: Record<"earlyStage" | "growthStage" | "established" | "enterprise", boolean>;
- };
-};
+type SectionFacets = Record<
+ string, // method slug, e.g. "peer-mediation"
+ {
+ size: Record<"oneMember" | "twoToFive" | "sixToTwelve" | "thirteenToOneHundred" | "oneHundredToOneHundredK", FacetMatch>;
+ orgType: Record<"dao" | "forProfit" | "nonprofit" | "openSource" | "mutualAid" | "workersCoop", FacetMatch>;
+ scale: Record<"global" | "national" | "regional" | "local", FacetMatch>;
+ maturity: Record<"earlyStage" | "growthStage" | "established" | "enterprise", FacetMatch>;
+ }
+>;
```
-A sibling **manifest** documents the per-section section-key mapping and
-column header → canonical facet/scalar key mapping, so the importer can be
-stable across header rewording.
+Example (illustrative, not a real entry):
+
+```json
+{
+ "peer-mediation": {
+ "size": { "oneMember": false, "twoToFive": true, "sixToTwelve": true, "thirteenToOneHundred": true, "oneHundredToOneHundredK": false },
+ "orgType": { "dao": false, "forProfit": false, "nonprofit": true, "openSource": true, "mutualAid": true, "workersCoop": true },
+ "scale": { "global": false, "national": false, "regional": true, "local": true },
+ "maturity": { "earlyStage": true, "growthStage": true, "established": true, "enterprise": false }
+ }
+}
+```
+
+Bulk shorthand: a field that's omitted defaults to `false`, so files
+only need to enumerate matching facets when authors prefer the compact
+form. The Zod schema (run at seed time and in the parity test, per §3)
+fills defaults and enforces:
+
+- Every key in the file matches a `methods[].id` in the corresponding
+ messages file (no orphans either way — fail loudly).
+- Every facet group is present for every method (or fully omitted, in
+ which case it defaults to "all false").
+- Facet values are exactly the canonical ids in §2 (typos fail).
+
+### One-time transcription from the workbooks
+
+The four `.xlsx` files in `~/Downloads/` (see appendix) carry the facet
+matches as the trailing 19 columns after the descriptive copy columns
+(slug, label, supportText, applicableScope, consensusLevel, etc.). The
+implementing agent should:
+
+1. Inspect one workbook with `openpyxl` to confirm the column order
+ matches §2 (`size` × 5, `orgType` × 6, `scale` × 4, `maturity` × 4 =
+ 19 facet columns). The copy ingest already confirmed the leading
+ columns; the trailing 19 are the only new bit.
+2. Write a one-shot Python script (mirror of the throwaway
+ `/tmp/ingest_methods.py` used for the messages ingest — **do not
+ commit**) that:
+ - Reads each workbook's `Current` sheet,
+ - Slugifies the first column the same way the messages ingest did
+ (kebab-case, ASCII-folded, lowercase) so keys match
+ `methods[].id` exactly,
+ - Treats `✓` (and `1`, `true`, `yes` — case-insensitive) as `true` and
+ everything else (`x`, blank, `-`, `0`, `false`, `no`, `✗`) as `false`.
+ The workbooks intentionally use `✓` to mark a match and `x` to mark a
+ non-match (cross-out) — both are filled cells but only `✓` counts as
+ a positive recommendation,
+ - Emits one `data/create/customRule/.json` per workbook in
+ the §6 shape, sorted by method slug,
+ - Emits `data/create/customRule/_facetGroups.json` from the column
+ header → canonical-id mapping above (§2).
+3. Hand-review the diff against the workbook for the first ~3 methods
+ per section, then commit the JSON files.
+4. Run `tests/unit/methodFacets.test.ts` (§12) to confirm parity with
+ the messages files, and `prisma db seed` to confirm the schema +
+ transaction work.
+
+The script is throwaway. Future facet edits happen by hand in the JSON
+files. The workbooks stay at `~/Downloads/` and are **not** committed —
+the post-ingest JSON files (both `messages/` and `data/`) are the
+historical record.
---
-## 6. Storage (decided: normalized tables)
+## 7. Storage
-We are introducing two new Prisma models. Hand-typed `COMPOSITION_BY_SLUG` in
-`prisma/seed.ts` is replaced by template rows that **reference** method slugs.
+Facet data lives in the JSON files above (the source of truth) and is
+**hydrated into the DB at seed time** so the API can do efficient joins
+when ranking templates by composed-method match. The JSON is canonical —
+the DB is a derived index.
```prisma
-model TemplateMethod {
- id String @id @default(cuid())
- section String // communication | membership | decisionMaking | conflictManagement
- slug String
- label String
- description String
- modalSections Json // { corePrinciple: "...", logisticsAdmin: "...", ... }
- scalars Json? // { consensusLevel: 0.51 }
- sortOrder Int @default(0)
- facets TemplateMethodFacet[]
- @@unique([section, slug])
+model MethodFacet {
+ id String @id @default(cuid())
+ section String // communication | membership | decisionApproaches | conflictManagement
+ slug String // matches the `id` of an entry in the messages file's `methods` array
+ group String // size | orgType | scale | maturity
+ value String // e.g. "workersCoop"
+ matches Boolean // ✓ → true, blank/explicit-false → false
+ weight Float? // optional numeric override for future scoring tweaks
+ @@unique([section, slug, group, value])
@@index([section])
-}
-
-model TemplateMethodFacet {
- id String @id @default(cuid())
- methodId String
- group String // size | orgType | scale | maturity
- value String // e.g. "workersCoop"
- matches Boolean // ✓ → true, x/blank → false
- weight Float? // optional numeric override for future scoring
- method TemplateMethod @relation(fields: [methodId], references: [id], onDelete: Cascade)
- @@unique([methodId, group, value])
@@index([group, value, matches])
}
```
-`RuleTemplate.body` continues to express a **chosen composition** of methods
-(one or more per section). Curated templates in `prisma/seed.ts` become
-references to `TemplateMethod.slug` instead of literal copy strings — when
-copy changes in the spreadsheet, every template that references that slug
-inherits the new copy.
+Why this shape:
-A follow-up (out of scope for CR-88) may add a `RuleTemplateMethodLink` join
-table if templates need ordering or per-template overrides; the current `body`
-JSON shape is sufficient for the first ship.
+- **JSON is the source of truth** — diffs are reviewable in PRs, no
+ binary artifacts, no importer to maintain. `prisma db seed` (or the
+ facet-only equivalent script) reads the JSON and upserts rows.
+- **DB enables joins** — template ranking joins `MethodFacet` against
+ the methods listed in `RuleTemplate.body` to compute per-template
+ match scores. In-memory ranking would also work but the join is
+ cleaner and matches existing patterns.
+- **Slug is the contract** — every JSON key matches a `methods[].id` in
+ messages; boot-time validation enforces this both ways.
+- **No runtime spreadsheet dependency** — `xlsx` / SheetJS is **not**
+ added to `package.json`. The original `~/Downloads/*.xlsx` files
+ authored both the messages content (already ingested) and the facet
+ matches (one-time transcription into the JSON files described in §6).
+ Future facet edits happen directly in the JSON.
---
-## 7. Importer (`scripts/import-templates-xlsx.ts`)
+## 8. Seed / sync
-Phased plan that the implementation agent can follow top-to-bottom. Mirrors
-the structure of `prisma/seed.ts` (singleton client, `main()` +
-`finally { $disconnect }`, `process.exit(1)` on failure).
+`prisma/seed.ts` (or a small co-located helper at
+`prisma/seed/methodFacets.ts`) does:
-1. **Read `.xlsx`** with [`xlsx`](https://www.npmjs.com/package/xlsx) (SheetJS,
- add as devDependency) from a configurable input dir (default
- `data/template-matrix/`). The four workbooks live there as committed
- artifacts, not in `Downloads/`.
-2. **Schema-validate per section** with Zod schemas that live in
- `lib/server/validation/templateRecommendationSchemas.ts` so the API and
- importer share the row shape: required column headers, allowed cell
- symbols (`✓`, `x`, blank, decimal for `Consensus Level`).
-3. **Normalize**: kebab-case slug from label, strip
- `Organization Type:` / `Location:` / `Organizational Maturity:` prefixes,
- collapse whitespace, normalize curly quotes.
-4. **Cross-sheet validation**: facet columns must match the canonical 19-column
- set; unknown columns fail loudly via the importer (use `logger.error`).
-5. **Diff & upsert** inside `prisma.$transaction([...])`: upsert
- `TemplateMethod` rows by `(section, slug)`; delete + recreate
- `TemplateMethodFacet` rows for each method.
-6. **Emit a JSON snapshot** to `prisma/data/template-matrix.json` so
- `prisma/seed.ts` can replay imports when the source workbooks aren't
- available (e.g. CI seed without the spreadsheet checked in).
-7. **Flags**: `--dry-run` (validate + diff, no writes), `--allow-warnings`
- (don't fail on the row-32 / row-11 issues in §2.4 while authors are
- iterating).
-8. **Tests** in `tests/unit/importTemplatesXlsx.test.ts`: a fixture workbook
- with two rows per section asserts both validation errors (unknown column,
- bad symbol, miscategorized cell) and successful normalization. Reuse
- Vitest patterns from `tests/unit/createFlowValidation.test.ts`.
+```ts
+async function seedMethodFacets() {
+ const sections: Section[] = [
+ "communication",
+ "membership",
+ "decisionApproaches",
+ "conflictManagement",
+ ];
-Per Ticket 16 and the roadmap, **prefer batch `.xlsx` import** over a live
-Google Sheets API in production. Authors export to `.xlsx` and a maintainer
-runs `npm run templates:import` (or CI does on a `data/template-matrix/` change).
+ for (const section of sections) {
+ const facets = await loadSectionFacets(section); // reads + Zod-validates the JSON
+ await prisma.$transaction([
+ prisma.methodFacet.deleteMany({ where: { section } }),
+ prisma.methodFacet.createMany({
+ data: flattenSectionFacets(section, facets),
+ }),
+ ]);
+ }
+}
+```
+
+- **Validation runs first.** `loadSectionFacets` reads the JSON and runs
+ the Zod schema against it; any orphan slug, missing facet group, or
+ unknown facet value fails the seed.
+- **Per-section atomic swap.** Delete-then-create inside one
+ transaction so the DB is never partially populated.
+- **No flags, no `--dry-run`, no fixture workbooks.** The seed is
+ idempotent and cheap (~80 methods × 19 facets = ~1500 rows total);
+ re-running on every prisma seed is fine.
+- **No app-boot validator.** Authoring errors surface in CI (parity
+ test, §3) or at `prisma db seed` time — never at request time.
---
-## 8. APIs
+## 9. APIs
-Two read endpoints. Both follow §4.1 conventions exactly: `dbUnavailable()`
-guard → server helper from `lib/server/templateMethods.ts` →
-`NextResponse.json({ ... })`.
+Both endpoints follow §5.1 conventions. **Neither returns copy** — copy
+lives in messages and is read client-side via `useMessages()`.
-### 8.1 `GET /api/templates` (rewrite)
+### 9.1 `GET /api/templates` (rewrite)
-Query params (all optional):
+Optional facet query params:
-- `facet.size=` (repeatable)
-- `facet.orgType=` (repeatable)
-- `facet.scale=` (repeatable)
-- `facet.maturity=` (repeatable)
+- `facet.size=` (repeatable)
+- `facet.orgType=`
+- `facet.scale=`
+- `facet.maturity=`
Behavior:
-- No params → existing curated ordering (`featured`, `sortOrder`, `title`),
- no scoring.
-- With facets → score each template by counting matching facets across the
- methods referenced in its `body`; return ranked `templates` plus an
- optional `scores` map.
+- No params → existing curated ordering (`featured`, `sortOrder`,
+ `title`), no scoring.
+- With facets → score each template by joining its `body`-referenced
+ method slugs against `MethodFacet` and **summing matches**, then
+ sort highest-first.
+
+**Scoring algorithm (simple count, v1).**
+
+For each template, build the set of `(section, slug)` pairs from
+`RuleTemplate.body` (the existing curated composition). For each
+`(section, slug)` pair, count how many of the requested
+`facet.=` query params match an existing
+`MethodFacet { matches: true }` row. The template's score is the
+**sum across all its methods**:
+
+```
+score(template)
+ = Σ over (section, slug) in template.body:
+ Σ over (group, value) in requested facets:
+ 1 if MethodFacet exists with matches=true for (section, slug, group, value)
+ else 0
+```
+
+Notes:
+- A template with five matching methods scores ~5× a template with one
+ matching method, which is the desired bias toward whole-stack fit.
+- `weight` in `MethodFacet` is **ignored** by v1 — it's only there so
+ authors can store nuance for a future weighted-rank pass without a
+ migration.
+- Ties broken by today's curated ordering (`featured` desc → `sortOrder`
+ asc → `title` asc) so no-facets and zero-match cases produce identical
+ output to the existing endpoint.
+- Templates with `score = 0` are still returned, ranked last by the
+ curated tie-break (recommendations rank, never hide; see §10).
Response:
@@ -579,150 +590,236 @@ Response:
}
```
-Param parsing helper lives next to `listRuleTemplatesFromDb` in
-`lib/server/ruleTemplates.ts` (e.g. `parseTemplateFacetsFromSearchParams`).
+`scores[slug].matchedFacets` is the deduped list of
+`":::"` keys that contributed, useful for
+debugging and for an eventual "Why this template?" UI tooltip.
-### 8.2 `GET /api/template-methods?section=[&facet.*=...]`
+### 9.2 `GET /api/create-flow/methods?section=[&facet.*=...]`
-Powers the four card-deck wizard steps and the section-level recommendation
-view. Response:
+Powers the four card-deck wizard steps. Returns slugs + per-method match
+scores only — wizard renders by looking up entries in
+`useMessages().create.customRule..methods` (via the
+`methodById` map each screen builds).
+
+Response:
```ts
{
- section: "communication" | "membership" | "decisionMaking" | "conflictManagement",
+ section: "communication" | "membership" | "decisionApproaches" | "conflictManagement",
methods: Array<{
slug: string;
- label: string;
- description: string;
- modalSections: Record;
- scalars?: Record;
- /** Per-method facet match against the requested facets (omitted when no facets passed). */
- matches?: { score: number; matchedFacets: string[] };
+ matches: { score: number; matchedFacets: string[] };
}>
}
```
-Server helper: `listTemplateMethodsFromDb({ section, facets })` in
-`lib/server/templateMethods.ts`. Same swallow-and-return-`[]` failure mode as
-`listRuleTemplatesFromDb`.
+**Scoring algorithm.** Same simple count as §9.1, scoped to a single
+method:
-### 8.3 `POST /api/templates/recommend` (follow-up, optional)
+```
+score(method)
+ = Σ over (group, value) in requested facets:
+ 1 if MethodFacet exists with matches=true for (section, method.slug, group, value)
+ else 0
+```
-If product wants to send the full `CreateFlowState` (not just facet ids), the
-body schema **reuses** `createFlowStateSchema` from
-`lib/server/validation/createFlowSchemas.ts`. Same scoring engine, just a
-richer input. Skip until §8.1 + §8.2 ship.
+Methods are returned **ranked by `matches.score` desc**, then by the
+on-disk order from the messages file (so the deck stays stable when no
+facets are passed and zero-match methods preserve authoring order). The
+wizard never **hides** rows — see §10.
-**Empty / partial facets:** never error. Fall back to today's ordering and
-return all rows.
+Server helper: `listMethodRecommendations({ section, facets })` in
+`lib/server/methodRecommendations.ts`. Same swallow-and-return-`[]`
+failure mode as `listRuleTemplatesFromDb`. When the DB is unavailable
+(or facets are empty), the wizard falls back to the messages deck in
+its on-disk order.
+
+### 9.3 `POST /api/templates/recommend` (follow-up, optional)
+
+If product wants to send the full `CreateFlowState` instead of just
+facet ids, body schema reuses `createFlowStateSchema`. Skip until §9.1
++ §9.2 ship.
+
+**Empty / partial facets:** never error. Fall back to today's ordering
+and return all rows.
---
-## 9. Wizard wiring (UI follow-on, not strictly part of CR-88)
+## 10. Wizard wiring (UI follow-on)
Once the API exists:
-- `communication-methods` / `membership-methods` / `decision-approaches` /
- `conflict-management` screens each call
- `GET /api/template-methods?section=...&facet.*=...`. The card label and
- modal copy come from the API response, not from
- `messages/en/create/.json`. Static JSON in those four files is
- pruned to the page-level strings (header titles, button labels, modal
- chrome) only.
-- Selecting a template on the marketing home or `templates/` page can prefill
- the create flow's `selected*MethodIds` from the template's composition (this
- closes the `?template=` no-op gap noted in
- `CreateFlowLayoutClient.tsx`).
-- Recommendations should never **hide** options from the user — ranking only.
- Authors expect to see "all 32 decision-making patterns" with the ✓-matching
- ones surfaced first.
+- `communication-methods` / `membership-methods` / `decision-approaches`
+ / `conflict-management` screens each call
+ `GET /api/create-flow/methods?section=...&facet.*=...` to get the
+ ranking. Card label, description, and modal copy continue to come
+ from `useMessages().create.customRule..methods` (a flat
+ array — each screen already builds a `methodById` lookup map and
+ iterates the array; no per-section `_CARD_ORDER` constants exist).
+ The screen reorders the array by the API's ranked slug list before
+ rendering.
+- API failure or empty facets → render the messages deck in its on-disk
+ order. No regression from today.
+- Selecting a template on the marketing home or `templates/` page can
+ prefill the create flow's `selected*MethodIds` from the template's
+ composition (closes the `?template=` no-op gap noted in
+ `CreateFlowLayoutClient.tsx`). Out of scope for CR-88.
+- Recommendations **never hide** options — ranking only. Authors expect
+ to see "all 32 decision-making patterns" with the matching ones
+ surfaced first.
---
-## 10. Open questions for product before coding
+## 11. Resolved decisions (no open questions)
-1. **Should `Values` also be sheet-driven?** Today it's free-text only and
- not in any of the four matrices. Roadmap implies eventual parity.
-2. **Scoring vs filtering**: do we want to **hide** non-✓ rows when a facet
- is set, or only **rank** them? Recommend ranking with a soft cutoff.
-3. **Per-template featured composition vs library-wide**: should
- `RuleTemplate` rows continue to exist as named compositions
- ("Consensus", "Elected Board", etc.), or become derived from a
- "this is the best mix for nonprofit + 13–100 + early stage" scoring? Doc
- today assumes the former — templates remain curated.
-4. **Authoring source of truth**: are the `Downloads/*.xlsx` files committed
- to `data/template-matrix/` going forward, or do they live in a Drive folder
- pulled by the importer at build time? Recommend committing.
-5. **Data validation strictness**: the current Decision-making sheet has a
- miscategorized cell (row 32, see §2.4). Importer should fail by default,
- with a `--allow-warnings` flag for in-progress edits.
+- ~~Where card copy lives~~ → `messages/en/create/customRule/*.json`,
+ flat `methods` array per file. Done.
+- ~~Card / modal split in messages files~~ → collapsed into a single
+ `methods` array; modal title/description derive from each entry's
+ `label`/`supportText`. Done.
+- ~~`rightRail.json` rename~~ → file is now
+ `messages/en/create/decisionApproaches.json`; namespace is
+ `m.create.decisionApproaches` (and will become
+ `m.create.customRule.decisionApproaches` after the §1c folder reorg).
+ Done.
+- ~~Facet authoring format~~ → typed JSON files committed under
+ `data/create/customRule/`, validated by Zod. No spreadsheets at
+ runtime, no `xlsx` dep, no importer.
+- ~~Where facet data lives at runtime~~ → JSON is canonical; DB is
+ hydrated at seed time for join-friendly queries.
+- ~~Decision-making `Consensus Level` scale~~ → integer 0-100 in
+ messages; the original spreadsheet's 0.0-1.0 floats were converted
+ during the one-time content ingest.
+- ~~Membership section key naming~~ → `eligibility` /
+ `joiningProcess` / `expectations` (matches wizard).
+- ~~Scoring vs filtering~~ → ranking only; never hide rows (§10).
+- ~~`RuleTemplate` rows~~ → stay hand-curated in `prisma/seed.ts`
+ `COMPOSITION_BY_SLUG`. The matrix just adds ranking; it doesn't
+ regenerate templates.
+- ~~Values matrix~~ → out of scope. Values are baked into each
+ curated template (and authored in `coreValues.json` for the
+ open-ended wizard step). No facet matrix needed; if a template is
+ recommended, its values come along statically.
+- ~~Ranking algorithm~~ → simple count (sum of `MethodFacet { matches:
+ true }` rows touched by the requested facets); per-method for §9.2
+ and per-template (sum across composed methods) for §9.1. `weight` is
+ reserved for a future v2; ignored by v1.
+- ~~Boot-time validation~~ → none. Parity is enforced by the seed step
+ (§8) and the parity test in CI (§3, §12). No `next dev` startup hook.
+- ~~Messages folder reorg sequencing~~ → ships as **its own ticket
+ before** CR-88 (§1c). CR-88 assumes the post-reorg paths.
+- ~~Spreadsheet handoff~~ → the four `~/Downloads/*.xlsx` files are
+ passed to the implementing agent alongside this doc. They are **not**
+ committed; the post-ingest `messages/en/create/customRule/*.json`
+ and `data/create/customRule/*.json` files are the historical record.
---
-## 11. Test plan (acceptance for CR-88)
+## 12. Test plan (acceptance for CR-88)
-- [ ] `scripts/import-templates-xlsx.ts` runs end-to-end on the four committed
- workbooks with no errors and produces the expected DB diff (or JSON
- snapshot).
-- [ ] Editing a row in the source workbook and re-running the importer changes
- the rank order returned by `GET /api/templates?facet.orgType=4`
- (the `Nonprofit` chip id) without any manual Studio edit.
-- [ ] `tests/unit/importTemplatesXlsx.test.ts` rejects each documented
- validation failure (unknown column, bad symbol, miscategorized row).
-- [ ] `tests/unit/templateRecommendationSchemas.test.ts` exercises the Zod
- schemas the importer and API share.
-- [ ] Manual smoke on the four wizard card-deck steps: facet-narrowed
- ordering surfaces matching cards first; facetless GET returns the
- full curated list.
-- [ ] No regression in existing template surfaces (marketing home, templates
- index, review-template preview).
+- [ ] `prisma db seed` populates `MethodFacet` from the four
+ `data/create/customRule/.json` files with no errors,
+ producing the expected row count
+ (`(11 + 19 + 32 + 19) × 19 = 1539` rows max, fewer if authors
+ use the omit-default shorthand).
+- [ ] `tests/unit/methodFacets.test.ts` asserts every method slug in
+ each facet file matches a `methods[].id` in the corresponding
+ messages file (and vice-versa) — no orphans either way. Also
+ asserts every `chipId` in `_facetGroups.json` resolves to a real
+ position in the referenced messages file (off-by-one fails).
+- [ ] `tests/unit/methodFacetsSchemas.test.ts` exercises the Zod schema
+ (rejects unknown facet values, unknown groups, unknown sections,
+ malformed booleans).
+- [ ] `tests/unit/methodRecommendations.test.ts` exercises the scoring
+ function directly with a fixture set: a method matching 2 of 3
+ requested facets scores `2`; a template composing two methods
+ that each match `2` and `3` requested facets scores `5`; ties
+ fall back to curated `(featured, sortOrder, title)` order.
+- [ ] `GET /api/create-flow/methods?section=conflictManagement&facet.orgType=nonprofit`
+ returns all 19 methods, ranked, with the `nonprofit`-matching
+ methods scoring higher than non-matching ones; zero-match
+ methods preserve their on-disk authoring order.
+- [ ] `GET /api/templates?facet.orgType=nonprofit&facet.size=sixToTwelve`
+ returns templates re-ordered by composed-method match count, with
+ score-0 templates still present at the end in curated order.
+- [ ] No-facets `GET /api/templates` matches today's curated ordering
+ (no regression for the existing marketing/templates surfaces).
+- [ ] DB-down smoke: with `DATABASE_URL` unset, the four wizard
+ card-deck steps still render the full deck from messages (no
+ 5xx, no broken cards).
+- [ ] Editing a `data/create/customRule/.json` entry and
+ re-running `prisma db seed` changes the rank order returned by
+ both endpoints without any code change.
---
-## 12. Source files referenced
+## 13. Source files referenced
-- `prisma/schema.prisma` — `RuleTemplate` model (lines 64–73).
-- `prisma/seed.ts` — current curated composition + xlsx-shaped helpers
- (lines 1–404).
-- `app/api/templates/route.ts` — existing GET endpoint (to be rewritten).
-- `app/api/drafts/me/route.ts` — reference route shape (`dbUnavailable` →
- `getSessionUser` → `readLimitedJson` → `safeParse` → `jsonFromZodError`).
-- `lib/server/db.ts` — Prisma singleton (lines 1–18).
-- `lib/server/responses.ts` — `dbUnavailable()` (lines 1–8).
-- `lib/server/ruleTemplates.ts` — `listRuleTemplatesFromDb` (lines 9–30).
-- `lib/server/validation/createFlowSchemas.ts` — schema to reuse for
- `POST /api/templates/recommend` (lines 47–106).
-- `lib/server/validation/requestBody.ts` — `readLimitedJson` (lines 13–48).
-- `lib/server/validation/zodHttp.ts` — `jsonFromZodError` (lines 4–17).
-- `lib/server/validation/plainJson.ts` — `assertPlainJsonValue` /
- `DEFAULT_PLAIN_JSON_LIMITS`.
+- `prisma/schema.prisma` — `RuleTemplate` model (unchanged); add
+ `MethodFacet` model (§7).
+- `prisma/seed.ts` — current curated composition; add `seedMethodFacets`
+ helper (§8).
+- `app/api/templates/route.ts` — existing GET endpoint (rewrite with
+ optional facet params).
+- `app/api/drafts/me/route.ts` — reference route shape.
+- `lib/server/db.ts` — Prisma singleton.
+- `lib/server/responses.ts` — `dbUnavailable()`.
+- `lib/server/ruleTemplates.ts` — `listRuleTemplatesFromDb` (extend with
+ facet param + scoring helper).
+- `lib/server/methodRecommendations.ts` — **new**; helper for §9.2.
+- `lib/server/validation/methodFacetsSchemas.ts` — **new**; Zod schema
+ for the JSON facet files and the API request shapes.
+- `lib/server/validation/createFlowSchemas.ts` — reuse facet-id arrays
+ rather than redeclaring them.
+- `lib/server/validation/requestBody.ts` — `readLimitedJson`.
+- `lib/server/validation/zodHttp.ts` — `jsonFromZodError`.
- `lib/logger.ts` — server-side `logger`.
- `app/(app)/create/types.ts` — `CreateFlowState` and facet fields.
- `app/(app)/create/utils/flowSteps.ts` — canonical step order.
-- `app/(app)/create/utils/createFlowScreenRegistry.ts` — screen layout per step.
-- `app/(app)/create/screens/select/CommunityStructureSelectScreen.tsx` — chip-id
- derivation pattern (positional `String(i+1)`).
-- `app/(app)/create/screens/card/CommunicationMethodsScreen.tsx` — section-field
- contract (`SECTION_FIELDS`).
-- `messages/en/create/{communitySize,communityStructure,communication,membership,rightRail,conflictManagement}.json` —
- current static card / chip copy that the matrix supersedes.
-- `lib/templates/governanceTemplateCatalog.ts`,
- `lib/templates/templateGridPresentation.ts`,
- `lib/create/fetchTemplates.ts` — current presentation/DTO layer.
+- `app/(app)/create/utils/createFlowScreenRegistry.ts` — screen
+ metadata.
+- `app/(app)/create/screens/select/CommunityStructureSelectScreen.tsx`
+ — chip-id derivation pattern (positional `String(i+1)`).
+- `app/(app)/create/screens/card/CommunicationMethodsScreen.tsx` (and
+ the three sibling screens) — already iterate `methods[]` via
+ `methodById`; the API ranking layer plugs in here.
+- `messages/en/create/customRule/{communication,membership,decisionApproaches,conflictManagement}.json`
+ — flat `methods` arrays (post-reorg paths). Source of truth for copy;
+ the matrix never edits these.
+- `messages/en/create/{community,reviewAndComplete}/*.json` — the other
+ two stages (post-reorg); not consumed by the matrix but listed for
+ context on the §1c reorg.
+- `data/create/customRule/{communication,membership,decisionApproaches,conflictManagement}.json`
+ — **new**; facet matches per method.
+- `data/create/customRule/_facetGroups.json` — **new**; canonical facet
+ group/value ids and the wizard-chip-id ↔ facet-value-id mapping.
- `tests/unit/createFlowValidation.test.ts` — Vitest pattern for new
- schema/importer tests.
-- Roadmap: `docs/backend-roadmap.md` §4 (lines 83–85), §13.
-- Spec: `docs/backend-linear-tickets.md` Ticket 16 (lines 280–304).
+ schema/parity tests.
+- Roadmap: `docs/guides/backend-roadmap.md` §4, §13.
+- Spec: `docs/guides/backend-linear-tickets.md` Ticket 16.
-## 13. Source workbooks
+---
-| File | Sheet | Rows | Cols | Section |
-|---|---|---|---|---|
-| `Communication Methods.xlsx` | `Current` | 11 cards | 24 | `communication` |
-| `Group_Membership_Methods.xlsx` | `Current` | 19 cards | 24 | `membership` |
-| `Decision-making.xlsx` | `Current` | 32 cards | 26 | `decisionMaking` |
-| `Conflict Management Methods.xlsx` | `Current` | 19 cards | 25 | `conflictManagement` |
+## Appendix — Source workbooks (one-time authoring artifact)
-Counts include the header row. Decision-making has 26 columns because of two
-extra content fields (`Consensus Level`, `Step-by-Step Instructions` vs the
-4-section pattern of the others).
+These four spreadsheets are **handed to the implementing agent
+alongside this doc**. They were used once to seed the messages content
+(already done) and will be used once more to transcribe the facet
+matches into `data/create/customRule/*.json` per §6's "One-time
+transcription" steps. They are **not** committed to the repo, **not**
+part of the runtime contract, and **not** referenced by any code path.
+
+| File (in `~/Downloads/`) | Sheet | Rows | Section |
+| --- | --- | --- | --- |
+| `Communication Methods.xlsx` | `Current` | 11 | `communication` |
+| `Group_Membership_Methods.xlsx` | `Current` | 19 | `membership` |
+| `Decision-making.xlsx` | `Current` | 32 | `decisionApproaches` |
+| `Conflict Management Methods.xlsx` | `Current` | 19 | `conflictManagement` |
+
+Each workbook's leading columns hold the descriptive copy already
+ingested into `messages/en/create/customRule/.json`; the
+trailing 19 columns hold the facet matches that need to land in
+`data/create/customRule/.json`. After CR-88 lands, future
+facet edits happen directly in the JSON files — the workbooks are
+historical reference only, and the committed JSON (in both `messages/`
+and `data/`) is the canonical record.
diff --git a/lib/create/api.ts b/lib/create/api.ts
index 6d1ce85..c04f94f 100644
--- a/lib/create/api.ts
+++ b/lib/create/api.ts
@@ -118,6 +118,23 @@ async function errorBodyMessage(res: Response): Promise {
return "Save failed";
}
+/**
+ * Wipe the signed-in user's saved draft. Fire-and-forget: any non-2xx (including
+ * the sync-flag-off `503` and the unauthenticated `401`) is swallowed because
+ * callers only invoke this on already-published / explicitly-discarded flows
+ * where a leftover server draft is acceptable.
+ */
+export async function deleteServerDraft(): Promise {
+ try {
+ await fetch("/api/drafts/me", {
+ method: "DELETE",
+ credentials: "include",
+ });
+ } catch {
+ /* ignore — server draft cleanup is best-effort */
+ }
+}
+
export async function saveDraftToServer(
state: CreateFlowState,
): Promise {
diff --git a/lib/server/methodRecommendations.ts b/lib/server/methodRecommendations.ts
new file mode 100644
index 0000000..d4b91c9
--- /dev/null
+++ b/lib/server/methodRecommendations.ts
@@ -0,0 +1,171 @@
+import { prisma } from "./db";
+import { isDatabaseConfigured } from "./env";
+import {
+ type RequestedFacets,
+ type SectionId,
+ flattenRequestedFacets,
+} from "./validation/methodFacetsSchemas";
+
+/**
+ * Per-method ranking output (CR-88, §9.2).
+ *
+ * `score` = number of requested `(group, value)` pairs that this method's
+ * `MethodFacet { matches: true }` rows cover. `matchedFacets` is the
+ * deduped list of `":"` keys that contributed — useful for
+ * an eventual "Why this method?" UI tooltip.
+ */
+export type MethodRanking = {
+ slug: string;
+ matches: { score: number; matchedFacets: string[] };
+};
+
+export type ListMethodRecommendationsResult = {
+ /** Ordered slug list, ranked highest-`score`-first; absent slugs scored `0`. */
+ rankedSlugs: string[];
+ /** Per-slug match data; missing entries should be treated as `score = 0`. */
+ matchesBySlug: Record;
+};
+
+/**
+ * Returns the per-method match scores for `section`, given `facets`.
+ * Returns `null` so callers can fall back to messages-file order when DB
+ * is unavailable or the query fails.
+ *
+ * Notes:
+ * - Empty facets ⇒ `rankedSlugs: []`, `matchesBySlug: {}` (caller falls back
+ * to authoring order).
+ * - Sort is `score` desc only — re-stabilising into authoring order is the
+ * caller's job (the wizard already iterates the on-disk `methods[]` array).
+ */
+export async function listMethodRecommendations(args: {
+ section: SectionId;
+ facets: RequestedFacets;
+}): Promise {
+ if (!isDatabaseConfigured()) return null;
+
+ const requested = flattenRequestedFacets(args.facets);
+ if (requested.length === 0) {
+ return { rankedSlugs: [], matchesBySlug: {} };
+ }
+
+ try {
+ const rows = await prisma.methodFacet.findMany({
+ where: {
+ section: args.section,
+ matches: true,
+ OR: requested.map(({ group, value }) => ({ group, value })),
+ },
+ select: { slug: true, group: true, value: true },
+ });
+
+ const matchesBySlug: Record = {};
+ for (const row of rows) {
+ const key = `${row.group}:${row.value}`;
+ const entry =
+ matchesBySlug[row.slug] ??
+ (matchesBySlug[row.slug] = { score: 0, matchedFacets: [] });
+ if (!entry.matchedFacets.includes(key)) {
+ entry.matchedFacets.push(key);
+ entry.score += 1;
+ }
+ }
+
+ const rankedSlugs = Object.entries(matchesBySlug)
+ .sort(([, a], [, b]) => b.score - a.score)
+ .map(([slug]) => slug);
+
+ return { rankedSlugs, matchesBySlug };
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Score every template by joining its composed `(section, slug)` pairs
+ * against `MethodFacet`. Returns a per-slug score map keyed by template slug
+ * and a per-template breakdown of which method-level matches contributed.
+ *
+ * `templateMethods` enumerates the method slugs each template composes;
+ * derived from `RuleTemplate.body` by the caller.
+ */
+export type TemplateRanking = {
+ templateSlug: string;
+ score: number;
+ matchedFacets: string[];
+};
+
+export async function scoreTemplatesByFacets(args: {
+ templateMethods: ReadonlyArray<{
+ templateSlug: string;
+ methods: ReadonlyArray<{ section: SectionId; slug: string }>;
+ }>;
+ facets: RequestedFacets;
+}): Promise {
+ if (!isDatabaseConfigured()) return null;
+ const requested = flattenRequestedFacets(args.facets);
+ if (requested.length === 0) {
+ return args.templateMethods.map((t) => ({
+ templateSlug: t.templateSlug,
+ score: 0,
+ matchedFacets: [],
+ }));
+ }
+
+ // Collect distinct (section, slug) pairs across all templates so we make
+ // exactly one query.
+ const sectionSlugSet = new Set();
+ for (const t of args.templateMethods) {
+ for (const m of t.methods) {
+ sectionSlugSet.add(`${m.section}:${m.slug}`);
+ }
+ }
+ const sectionSlugPairs = Array.from(sectionSlugSet).map((key) => {
+ const [section, slug] = key.split(":");
+ return { section, slug };
+ });
+ if (sectionSlugPairs.length === 0) {
+ return args.templateMethods.map((t) => ({
+ templateSlug: t.templateSlug,
+ score: 0,
+ matchedFacets: [],
+ }));
+ }
+
+ try {
+ const rows = await prisma.methodFacet.findMany({
+ where: {
+ matches: true,
+ AND: [
+ { OR: sectionSlugPairs },
+ { OR: requested.map(({ group, value }) => ({ group, value })) },
+ ],
+ },
+ select: { section: true, slug: true, group: true, value: true },
+ });
+
+ // Build a lookup: (section,slug) -> Set of ":" matches.
+ const matchesByMethod = new Map>();
+ for (const row of rows) {
+ const k = `${row.section}:${row.slug}`;
+ const set = matchesByMethod.get(k) ?? new Set();
+ set.add(`${row.group}:${row.value}`);
+ matchesByMethod.set(k, set);
+ }
+
+ return args.templateMethods.map((t) => {
+ let score = 0;
+ const matched: string[] = [];
+ for (const m of t.methods) {
+ const set = matchesByMethod.get(`${m.section}:${m.slug}`);
+ if (!set) continue;
+ for (const key of set) {
+ score += 1;
+ matched.push(`${m.section}:${m.slug}:${key}`);
+ }
+ }
+ return { templateSlug: t.templateSlug, score, matchedFacets: matched };
+ });
+ } catch {
+ return null;
+ }
+}
diff --git a/lib/server/ruleTemplates.ts b/lib/server/ruleTemplates.ts
index ba2d8f5..3d74c76 100644
--- a/lib/server/ruleTemplates.ts
+++ b/lib/server/ruleTemplates.ts
@@ -1,6 +1,27 @@
import type { RuleTemplateDto } from "../create/fetchTemplates";
import { prisma } from "./db";
import { isDatabaseConfigured } from "./env";
+import { scoreTemplatesByFacets } from "./methodRecommendations";
+import { templateMethodsFromBody } from "./templateMethods";
+import type { RequestedFacets } from "./validation/methodFacetsSchemas";
+import { flattenRequestedFacets } from "./validation/methodFacetsSchemas";
+
+const TEMPLATE_SELECT = {
+ id: true,
+ slug: true,
+ title: true,
+ category: true,
+ description: true,
+ body: true,
+ sortOrder: true,
+ featured: true,
+} as const;
+
+const CURATED_ORDER_BY = [
+ { featured: "desc" as const },
+ { sortOrder: "asc" as const },
+ { title: "asc" as const },
+];
/**
* Curated templates for public list UIs (same query as GET /api/templates).
@@ -12,19 +33,82 @@ export async function listRuleTemplatesFromDb(): Promise {
}
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,
- },
+ orderBy: CURATED_ORDER_BY,
+ select: TEMPLATE_SELECT,
});
} catch {
return [];
}
}
+
+export type TemplateScore = {
+ score: number;
+ matchedFacets: string[];
+};
+
+export type RankedTemplatesResult = {
+ templates: RuleTemplateDto[];
+ /** Per-template-slug score map; absent slugs scored `0`. */
+ scores: Record;
+};
+
+/**
+ * Curated templates ranked by how many of `facets` each composed method
+ * matches (§9.1). When `facets` is empty, returns the curated ordering with
+ * an empty `scores` map (caller can omit it from the API response).
+ *
+ * Ties (and zero-score templates) fall back to the curated
+ * `(featured, sortOrder, title)` order so no-facets and zero-match cases
+ * produce identical output to `listRuleTemplatesFromDb`.
+ */
+export async function listRankedRuleTemplatesFromDb(
+ facets: RequestedFacets,
+): Promise {
+ if (!isDatabaseConfigured()) {
+ return { templates: [], scores: {} };
+ }
+ const requested = flattenRequestedFacets(facets);
+ if (requested.length === 0) {
+ const templates = await listRuleTemplatesFromDb();
+ return { templates, scores: {} };
+ }
+
+ let templates: RuleTemplateDto[];
+ try {
+ templates = await prisma.ruleTemplate.findMany({
+ orderBy: CURATED_ORDER_BY,
+ select: TEMPLATE_SELECT,
+ });
+ } catch {
+ return { templates: [], scores: {} };
+ }
+
+ const templateMethods = templates.map((t) => ({
+ templateSlug: t.slug,
+ methods: templateMethodsFromBody(t.body),
+ }));
+
+ const ranked = await scoreTemplatesByFacets({ templateMethods, facets });
+ if (!ranked) {
+ return { templates, scores: {} };
+ }
+
+ const scores: Record = {};
+ for (const r of ranked) {
+ scores[r.templateSlug] = {
+ score: r.score,
+ matchedFacets: r.matchedFacets,
+ };
+ }
+
+ // Stable sort: scoreDesc, then preserve curated index order.
+ const indexBySlug = new Map(templates.map((t, i) => [t.slug, i]));
+ const sorted = [...templates].sort((a, b) => {
+ const sa = scores[a.slug]?.score ?? 0;
+ const sb = scores[b.slug]?.score ?? 0;
+ if (sa !== sb) return sb - sa;
+ return (indexBySlug.get(a.slug) ?? 0) - (indexBySlug.get(b.slug) ?? 0);
+ });
+
+ return { templates: sorted, scores };
+}
diff --git a/lib/server/templateMethods.ts b/lib/server/templateMethods.ts
new file mode 100644
index 0000000..39634dc
--- /dev/null
+++ b/lib/server/templateMethods.ts
@@ -0,0 +1,76 @@
+import type { SectionId } from "./validation/methodFacetsSchemas";
+
+/**
+ * Extracts the `(section, slug)` pairs that a curated `RuleTemplate.body`
+ * composes. Used by `/api/templates` to score templates by facet match
+ * (CR-88, §9.1).
+ *
+ * `body.sections[].categoryName` is mapped to the canonical recommendation
+ * `section` id; `entries[].title` is slugified the same way the messages
+ * ingest produced `methods[].id` (kebab-case, ASCII-folded, lowercase) so
+ * the slugs line up with `MethodFacet.slug`.
+ *
+ * "Values" entries are intentionally skipped — values are out of scope for
+ * the facet matrix (§11).
+ */
+
+const CATEGORY_NAME_TO_SECTION: Record = {
+ Communication: "communication",
+ Membership: "membership",
+ "Decision-making": "decisionApproaches",
+ "Conflict management": "conflictManagement",
+};
+
+export function methodSlugFromTitle(title: string): string {
+ // Match the slugify rules of the one-time messages ingest: NFKD-normalize,
+ // strip diacritics, drop apostrophes/brackets, collapse non-alphanumerics
+ // to single hyphens, trim leading/trailing hyphens.
+ const folded = title.normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
+ const stripped = folded
+ .toLowerCase()
+ .replace(/['’`()\[\]]/g, "")
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-+|-+$/g, "");
+ return stripped;
+}
+
+type RuleTemplateBodySection = {
+ categoryName?: unknown;
+ entries?: unknown;
+};
+type RuleTemplateBody = { sections?: unknown };
+
+export type TemplateMethodRef = { section: SectionId; slug: string };
+
+export function templateMethodsFromBody(
+ body: unknown,
+): TemplateMethodRef[] {
+ if (!body || typeof body !== "object") return [];
+ const sections = (body as RuleTemplateBody).sections;
+ if (!Array.isArray(sections)) return [];
+
+ const out: TemplateMethodRef[] = [];
+ const seen = new Set();
+ for (const raw of sections) {
+ if (!raw || typeof raw !== "object") continue;
+ const sec = raw as RuleTemplateBodySection;
+ const categoryName =
+ typeof sec.categoryName === "string" ? sec.categoryName : null;
+ if (!categoryName) continue;
+ const section = CATEGORY_NAME_TO_SECTION[categoryName];
+ if (!section) continue; // Values, or any future category we don't score.
+ if (!Array.isArray(sec.entries)) continue;
+ for (const entry of sec.entries) {
+ if (!entry || typeof entry !== "object") continue;
+ const title = (entry as { title?: unknown }).title;
+ if (typeof title !== "string" || title.trim() === "") continue;
+ const slug = methodSlugFromTitle(title);
+ if (!slug) continue;
+ const key = `${section}:${slug}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ out.push({ section, slug });
+ }
+ }
+ return out;
+}
diff --git a/lib/server/validation/methodFacetsSchemas.ts b/lib/server/validation/methodFacetsSchemas.ts
new file mode 100644
index 0000000..5686499
--- /dev/null
+++ b/lib/server/validation/methodFacetsSchemas.ts
@@ -0,0 +1,273 @@
+import { z } from "zod";
+
+/**
+ * Zod schemas for the recommendation matrix (CR-88).
+ *
+ * Source of truth at runtime is `data/create/customRule/.json` plus
+ * `data/create/customRule/_facetGroups.json`. These schemas validate those
+ * files at seed time and in the parity test (`tests/unit/methodFacets.test.ts`).
+ * They are also reused by the API request shapes for `/api/templates` and
+ * `/api/create-flow/methods` so a single set of canonical ids drives both
+ * the on-disk JSON and the query-string contract.
+ *
+ * See `docs/guides/template-recommendation-matrix.md` §2 (canonical 19
+ * facet values), §6 (JSON shape), §7 (`MethodFacet` schema), §9 (API).
+ */
+
+export const SECTION_IDS = [
+ "communication",
+ "membership",
+ "decisionApproaches",
+ "conflictManagement",
+] as const;
+export type SectionId = (typeof SECTION_IDS)[number];
+export const sectionIdSchema = z.enum(SECTION_IDS);
+
+export const FACET_GROUP_IDS = [
+ "size",
+ "orgType",
+ "scale",
+ "maturity",
+] as const;
+export type FacetGroupId = (typeof FACET_GROUP_IDS)[number];
+export const facetGroupIdSchema = z.enum(FACET_GROUP_IDS);
+
+export const SIZE_VALUE_IDS = [
+ "oneMember",
+ "twoToFive",
+ "sixToTwelve",
+ "thirteenToOneHundred",
+ "oneHundredToOneHundredK",
+] as const;
+export const ORG_TYPE_VALUE_IDS = [
+ "dao",
+ "forProfit",
+ "nonprofit",
+ "openSource",
+ "mutualAid",
+ "workersCoop",
+] as const;
+export const SCALE_VALUE_IDS = [
+ "global",
+ "national",
+ "regional",
+ "local",
+] as const;
+export const MATURITY_VALUE_IDS = [
+ "earlyStage",
+ "growthStage",
+ "established",
+ "enterprise",
+] as const;
+
+export type SizeValueId = (typeof SIZE_VALUE_IDS)[number];
+export type OrgTypeValueId = (typeof ORG_TYPE_VALUE_IDS)[number];
+export type ScaleValueId = (typeof SCALE_VALUE_IDS)[number];
+export type MaturityValueId = (typeof MATURITY_VALUE_IDS)[number];
+
+export const FACET_VALUE_IDS_BY_GROUP: Record<
+ FacetGroupId,
+ readonly string[]
+> = {
+ size: SIZE_VALUE_IDS,
+ orgType: ORG_TYPE_VALUE_IDS,
+ scale: SCALE_VALUE_IDS,
+ maturity: MATURITY_VALUE_IDS,
+};
+
+const sizeValueIdSchema = z.enum(SIZE_VALUE_IDS);
+const orgTypeValueIdSchema = z.enum(ORG_TYPE_VALUE_IDS);
+const scaleValueIdSchema = z.enum(SCALE_VALUE_IDS);
+const maturityValueIdSchema = z.enum(MATURITY_VALUE_IDS);
+
+/**
+ * Per-cell shape: bare boolean, or an object with optional `weight`.
+ * The object form is reserved for a future weighted-rank pass (v1 ignores
+ * `weight`; see §9.1 "Notes").
+ */
+const facetMatchSchema = z.union([
+ z.boolean(),
+ z
+ .object({
+ match: z.boolean(),
+ weight: z.number().finite().optional(),
+ })
+ .strict(),
+]);
+export type FacetMatch = z.infer;
+
+/**
+ * Builds a Zod object schema for a facet group where every canonical value id
+ * is optional. Omitted keys default to `false` (see §6 "Bulk shorthand").
+ */
+function partialGroupSchema(
+ values: Values,
+) {
+ const enumSchema = z.enum(values);
+ return z.record(enumSchema, facetMatchSchema);
+}
+
+const sizeFacetsSchema = partialGroupSchema(SIZE_VALUE_IDS);
+const orgTypeFacetsSchema = partialGroupSchema(ORG_TYPE_VALUE_IDS);
+const scaleFacetsSchema = partialGroupSchema(SCALE_VALUE_IDS);
+const maturityFacetsSchema = partialGroupSchema(MATURITY_VALUE_IDS);
+
+/**
+ * Per-method facet entry. All four groups are optional; an omitted group
+ * defaults to "all false" at seed time (see `flattenSectionFacets` in
+ * `prisma/seed/methodFacets.ts`).
+ */
+export const methodFacetsSchema = z
+ .object({
+ size: sizeFacetsSchema.optional(),
+ orgType: orgTypeFacetsSchema.optional(),
+ scale: scaleFacetsSchema.optional(),
+ maturity: maturityFacetsSchema.optional(),
+ })
+ .strict();
+export type MethodFacets = z.infer;
+
+/**
+ * Whole-section file shape: object keyed by method slug
+ * (`messages/en/create/customRule/.json#/methods[].id`).
+ */
+export const sectionFacetsSchema = z.record(z.string(), methodFacetsSchema);
+export type SectionFacetsFile = z.infer;
+
+/**
+ * `_facetGroups.json` shape: positional chip id ↔ canonical facet value id
+ * mapping (see §2). Validated alongside the section files so chip drift in a
+ * messages file fails CI loudly.
+ */
+const facetGroupValueEntrySchema = z
+ .object({
+ chipId: z.string().min(1),
+ })
+ .strict();
+
+const facetGroupBlockSchema = z
+ .object({
+ source: z.string().min(1),
+ values: z.record(z.string(), facetGroupValueEntrySchema),
+ })
+ .strict();
+
+export const facetGroupsFileSchema = z
+ .object({
+ size: facetGroupBlockSchema,
+ orgType: facetGroupBlockSchema,
+ scale: facetGroupBlockSchema,
+ maturity: facetGroupBlockSchema,
+ })
+ .strict()
+ .superRefine((data, ctx) => {
+ for (const group of FACET_GROUP_IDS) {
+ const expected = new Set(FACET_VALUE_IDS_BY_GROUP[group]);
+ const actual = new Set(Object.keys(data[group].values));
+ for (const v of expected) {
+ if (!actual.has(v)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: [group, "values"],
+ message: `Missing canonical value ${v}`,
+ });
+ }
+ }
+ for (const v of actual) {
+ if (!expected.has(v)) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: [group, "values", v],
+ message: `Unknown facet value ${v} for group ${group}`,
+ });
+ }
+ }
+ }
+ });
+export type FacetGroupsFile = z.infer;
+
+/**
+ * Resolve a `FacetMatch` value to its boolean (the shape can be either a bare
+ * boolean or `{ match, weight? }`). Used by both the seed flattener and the
+ * scoring helpers.
+ */
+export function resolveFacetMatch(
+ v: FacetMatch | undefined,
+): { match: boolean; weight: number | null } {
+ if (v === undefined) return { match: false, weight: null };
+ if (typeof v === "boolean") return { match: v, weight: null };
+ return { match: v.match, weight: v.weight ?? null };
+}
+
+// ---------------------------------------------------------------------------
+// API request shapes (used by /api/templates and /api/create-flow/methods)
+// ---------------------------------------------------------------------------
+
+/**
+ * Generic facet-id-array shape, scoped per group. URLSearchParams produces
+ * either a single string or repeated values; both flatten to `string[]`.
+ */
+export const requestedFacetsSchema = z
+ .object({
+ size: z.array(sizeValueIdSchema).max(SIZE_VALUE_IDS.length).optional(),
+ orgType: z
+ .array(orgTypeValueIdSchema)
+ .max(ORG_TYPE_VALUE_IDS.length)
+ .optional(),
+ scale: z.array(scaleValueIdSchema).max(SCALE_VALUE_IDS.length).optional(),
+ maturity: z
+ .array(maturityValueIdSchema)
+ .max(MATURITY_VALUE_IDS.length)
+ .optional(),
+ })
+ .strict();
+export type RequestedFacets = z.infer;
+
+/** Flattened `(group, value)` tuple for scoring. */
+export type RequestedFacetPair = { group: FacetGroupId; value: string };
+
+export function flattenRequestedFacets(
+ facets: RequestedFacets,
+): RequestedFacetPair[] {
+ const out: RequestedFacetPair[] = [];
+ for (const group of FACET_GROUP_IDS) {
+ const values = facets[group];
+ if (!values) continue;
+ for (const value of values) {
+ out.push({ group, value });
+ }
+ }
+ return out;
+}
+
+/**
+ * Parse `?facet.size=oneMember&facet.orgType=dao&facet.orgType=nonprofit` into
+ * a typed `RequestedFacets`. Unknown groups and unknown values are dropped
+ * silently — the API "never errors on partial facets" (§9.3).
+ */
+export function parseRequestedFacetsFromSearchParams(
+ search: URLSearchParams,
+): RequestedFacets {
+ const collected: Record> = {
+ size: new Set(),
+ orgType: new Set(),
+ scale: new Set(),
+ maturity: new Set(),
+ };
+ for (const [rawKey, rawVal] of search.entries()) {
+ if (!rawKey.startsWith("facet.")) continue;
+ const group = rawKey.slice("facet.".length) as FacetGroupId;
+ if (!FACET_GROUP_IDS.includes(group)) continue;
+ const allowed = new Set(FACET_VALUE_IDS_BY_GROUP[group]);
+ if (allowed.has(rawVal)) {
+ collected[group].add(rawVal);
+ }
+ }
+ const out: RequestedFacets = {};
+ for (const group of FACET_GROUP_IDS) {
+ if (collected[group].size > 0) {
+ out[group] = Array.from(collected[group]) as never;
+ }
+ }
+ return out;
+}
diff --git a/messages/en/create/communication.json b/messages/en/create/communication.json
deleted file mode 100644
index cc915ba..0000000
--- a/messages/en/create/communication.json
+++ /dev/null
@@ -1,120 +0,0 @@
-{
- "_comment": "Create flow – communication step: page, cards, and add-platform modals",
- "page": {
- "compactTitle": "How should this community communicate with each-other?",
- "compactDescriptionBefore": "You can select multiple methods for different needs or ",
- "compactDescriptionLinkLabel": "add",
- "compactDescriptionAfter": " your own",
- "expandedTitle": "What method should this community use to communicate with eachother?",
- "expandedDescription": "You can select multiple methods for different needs or add your own",
- "seeAllLink": "See all communication approaches"
- },
- "confirmModal": {
- "title": "Confirm selection",
- "description": "Confirm to select this option.",
- "nextButtonText": "Confirm"
- },
- "addPlatform": {
- "nextButtonText": "Add Platform"
- },
- "sectionHeadings": {
- "corePrinciple": "Core Principle & Scope",
- "logisticsAdmin": "Logistics, Admin & Norms",
- "codeOfConduct": "Code of Conduct"
- },
- "cards": {
- "in-person-meetings": {
- "label": "In-Person Meetings",
- "supportText": "Physical gatherings for high-bandwidth communication and relationship building."
- },
- "signal": {
- "label": "Signal",
- "supportText": "Encrypted messaging for high-security, private coordination."
- },
- "video-meetings": {
- "label": "Video Meetings",
- "supportText": "Synchronous video calls for remote face-to-face interaction."
- },
- "4": {
- "label": "Label",
- "supportText": "Collaborative work to reach a resolution that all parties can agree upon."
- },
- "5": {
- "label": "Label",
- "supportText": "Structured sessions where parties collaboratively resolve disputes."
- },
- "6": {
- "label": "Label",
- "supportText": "Members vote to resolve a dispute democratically."
- },
- "7": {
- "label": "Label",
- "supportText": "Invite-only"
- }
- },
- "modals": {
- "in-person-meetings": {
- "title": "In-Person Meetings",
- "description": "Physical gatherings for high-bandwidth communication and relationship building.",
- "sections": {
- "corePrinciple": "We value the highest bandwidth of communication, physical presence, to build trust that digital tools cannot match. Consequently, we reserve this high-trust space for annual retreats, strategic planning, and high-stakes interpersonal repair where body language is essential.",
- "logisticsAdmin": "Logistics focus on physical accessibility, venue security, and travel equity. Organizers control entry via keys or door staff. Culturally, participants are expected to maintain mission focus and adhere strictly to the itinerary to respect everyone's time. Side conversations or distracting behaviors that derail the agenda are discouraged.",
- "codeOfConduct": "We aspire to operate within these principles. We don't need to see eye to eye on everything, but we believe the world can be improved by collective action. Aspire to do no harm to members of this community. Violence or physical intimidation will not be tolerated. We have a zero-tolerance policy for racism, sexism, and bigotry."
- }
- },
- "signal": {
- "title": "Signal",
- "description": "End-to-end encrypted messaging ideal for small, security-minded groups",
- "sections": {
- "corePrinciple": "We use Signal for all operational communication. To keep our workspace organized, official channels are prepended with an emoji (e.g., 🤓). Public channels are open to all volunteers, while Core Channels are restricted to coordinators. All Core Members are designated as admins to share the technical workload.",
- "logisticsAdmin": "We encourage direct messages to build friendship, but all operational logistics must happen in group channels. To respect everyone's time, use \"Emoji Reactions\" (👍, ♥️) to acknowledge messages rather than typing \"thanks,\" which triggers notifications for everyone. Text is a poor medium for nuance: if a conversation needs more context, move it to a call or in person.",
- "codeOfConduct": "This space relies on collective responsibility. Posting content that attracts unwanted legal attention or exposes members' real-world identities without consent is prohibited. We aspire to do no harm by practicing strict operational security. Intentionally leaking information violates our safety. We have a zero-tolerance policy for harassment or abuse."
- }
- },
- "video-meetings": {
- "title": "Video Meetings",
- "description": "Synchronous video calls for remote face-to-face interaction.",
- "sections": {
- "corePrinciple": "We prioritize synchronous connection to read facial expressions without the barrier of travel, using this tool for weekly syncs and quick consensus checks that benefit from real-time debate before moving to a vote.",
- "logisticsAdmin": "The host manages technical security via waiting rooms to prevent intrusion. Culturally, the focus is on maximizing the value of synchronous time. Norms include muting when not speaking, using the \"Raise Hand\" feature to queue, and utilizing the chat box for non-interruptive side comments. Distractions should be minimized.",
- "codeOfConduct": "We have a zero-tolerance policy for racism, sexism, and bigotry, whether spoken or shared in the chat. We aspire to do no harm. \"Zoom-bombing\" or broadcasting graphic content is prohibited. Willfully spreading obviously false information will not be tolerated. Do not discuss sensitive data that could attract legal or security risk."
- }
- },
- "4": {
- "title": "Label",
- "description": "Collaborative work to reach a resolution that all parties can agree upon.",
- "sections": {
- "corePrinciple": "",
- "logisticsAdmin": "",
- "codeOfConduct": ""
- }
- },
- "5": {
- "title": "Label",
- "description": "Structured sessions where parties collaboratively resolve disputes.",
- "sections": {
- "corePrinciple": "",
- "logisticsAdmin": "",
- "codeOfConduct": ""
- }
- },
- "6": {
- "title": "Label",
- "description": "Members vote to resolve a dispute democratically.",
- "sections": {
- "corePrinciple": "",
- "logisticsAdmin": "",
- "codeOfConduct": ""
- }
- },
- "7": {
- "title": "Label",
- "description": "Invite-only",
- "sections": {
- "corePrinciple": "",
- "logisticsAdmin": "",
- "codeOfConduct": ""
- }
- }
- }
-}
diff --git a/messages/en/create/communityContext.json b/messages/en/create/community/communityContext.json
similarity index 100%
rename from messages/en/create/communityContext.json
rename to messages/en/create/community/communityContext.json
diff --git a/messages/en/create/communityName.json b/messages/en/create/community/communityName.json
similarity index 100%
rename from messages/en/create/communityName.json
rename to messages/en/create/community/communityName.json
diff --git a/messages/en/create/communitySave.json b/messages/en/create/community/communitySave.json
similarity index 100%
rename from messages/en/create/communitySave.json
rename to messages/en/create/community/communitySave.json
diff --git a/messages/en/create/communitySize.json b/messages/en/create/community/communitySize.json
similarity index 100%
rename from messages/en/create/communitySize.json
rename to messages/en/create/community/communitySize.json
diff --git a/messages/en/create/communityStructure.json b/messages/en/create/community/communityStructure.json
similarity index 100%
rename from messages/en/create/communityStructure.json
rename to messages/en/create/community/communityStructure.json
diff --git a/messages/en/create/communityUpload.json b/messages/en/create/community/communityUpload.json
similarity index 100%
rename from messages/en/create/communityUpload.json
rename to messages/en/create/community/communityUpload.json
diff --git a/messages/en/create/informational.json b/messages/en/create/community/informational.json
similarity index 100%
rename from messages/en/create/informational.json
rename to messages/en/create/community/informational.json
diff --git a/messages/en/create/review.json b/messages/en/create/community/review.json
similarity index 100%
rename from messages/en/create/review.json
rename to messages/en/create/community/review.json
diff --git a/messages/en/create/conflictManagement.json b/messages/en/create/conflictManagement.json
deleted file mode 100644
index b8b7e86..0000000
--- a/messages/en/create/conflictManagement.json
+++ /dev/null
@@ -1,143 +0,0 @@
-{
- "_comment": "Create flow — conflict management (Figma Flow — Compact Card Stack `20879:15979`)",
- "page": {
- "compactTitle": "How should conflicts be managed\nin your group?",
- "compactDescriptionBefore": "You can also combine or ",
- "compactDescriptionLinkLabel": "add",
- "compactDescriptionAfter": " new approaches to the list",
- "expandedTitle": "How should conflicts be managed in your group?",
- "expandedDescription": "You can also combine or add new approaches to the list",
- "seeAllLink": "See all conflict management approaches"
- },
- "confirmModal": {
- "title": "Confirm selection",
- "description": "Confirm to select this option.",
- "nextButtonText": "Confirm"
- },
- "addApproach": {
- "nextButtonText": "Add Approach"
- },
- "sectionHeadings": {
- "corePrinciple": "Core Principle",
- "applicableScope": "Applicable Scope",
- "processProtocol": "Process Protocol",
- "restorationFallbacks": "Restoration & Fallbacks"
- },
- "scopeAddButtonLabel": "Add Applicable Scope",
- "cards": {
- "peer-mediation": {
- "label": "Peer Mediation",
- "supportText": "Trained members within the organization mediate disputes among peers."
- },
- "conflict-resolution-council": {
- "label": "Conflict Resolution Council",
- "supportText": "Senior members with institutional knowledge provide guidance or decisions."
- },
- "facilitated-negotiation": {
- "label": "Facilitated Negotiation",
- "supportText": "A neutral facilitator helps guide the negotiation process."
- },
- "ad-hoc-arbitration": {
- "label": "Ad Hoc Arbitration",
- "supportText": "Arbitrators are chosen specifically for a particular case."
- },
- "conflict-workshops": {
- "label": "Conflict Workshops",
- "supportText": "Structured sessions where parties collaboratively resolve disputes and improve future interactions."
- },
- "6": {
- "label": "Label",
- "supportText": "Additional conflict management approach."
- },
- "7": {
- "label": "Label",
- "supportText": "Additional conflict management approach."
- },
- "8": {
- "label": "Label",
- "supportText": "Additional conflict management approach."
- }
- },
- "modals": {
- "peer-mediation": {
- "title": "Peer Mediation",
- "description": "Trained members within the organization mediate disputes among peers.",
- "sections": {
- "corePrinciple": "We democratize conflict skills. Instead of relying on professional outsiders, trained peers help their colleagues resolve disputes, reinforcing the idea that we take care of each other.",
- "applicableScope": ["Low-level friction", "Misunderstandings"],
- "processProtocol": "A volunteer peer (who is not a manager) invites the disputants to a private chat. Using a simple script, they ask questions like 'Tell us your side,' 'Tell us what you need,' and 'What can you agree to?'. The peer keeps the conversation focused on future interactions rather than past grievances. The disputants retain full control over the resolution.",
- "restorationFallbacks": "The goal is a verbal agreement to try a new way of interacting. If the peer mediator determines the issue is too complex or involves serious harassment, they are required to refer the case to professional Mediation or a Judicial Committee."
- }
- },
- "conflict-resolution-council": {
- "title": "Conflict Resolution Council",
- "description": "Senior members with institutional knowledge provide guidance or decisions.",
- "sections": {
- "corePrinciple": "",
- "applicableScope": [],
- "processProtocol": "",
- "restorationFallbacks": ""
- }
- },
- "facilitated-negotiation": {
- "title": "Facilitated Negotiation",
- "description": "A neutral facilitator helps guide the negotiation process.",
- "sections": {
- "corePrinciple": "",
- "applicableScope": [],
- "processProtocol": "",
- "restorationFallbacks": ""
- }
- },
- "ad-hoc-arbitration": {
- "title": "Ad Hoc Arbitration",
- "description": "Arbitrators are chosen specifically for a particular case.",
- "sections": {
- "corePrinciple": "",
- "applicableScope": [],
- "processProtocol": "",
- "restorationFallbacks": ""
- }
- },
- "conflict-workshops": {
- "title": "Conflict Workshops",
- "description": "Structured sessions where parties collaboratively resolve disputes and improve future interactions.",
- "sections": {
- "corePrinciple": "",
- "applicableScope": [],
- "processProtocol": "",
- "restorationFallbacks": ""
- }
- },
- "6": {
- "title": "Label",
- "description": "Additional conflict management approach.",
- "sections": {
- "corePrinciple": "",
- "applicableScope": [],
- "processProtocol": "",
- "restorationFallbacks": ""
- }
- },
- "7": {
- "title": "Label",
- "description": "Additional conflict management approach.",
- "sections": {
- "corePrinciple": "",
- "applicableScope": [],
- "processProtocol": "",
- "restorationFallbacks": ""
- }
- },
- "8": {
- "title": "Label",
- "description": "Additional conflict management approach.",
- "sections": {
- "corePrinciple": "",
- "applicableScope": [],
- "processProtocol": "",
- "restorationFallbacks": ""
- }
- }
- }
-}
diff --git a/messages/en/create/customRule/communication.json b/messages/en/create/customRule/communication.json
new file mode 100644
index 0000000..61097ba
--- /dev/null
+++ b/messages/en/create/customRule/communication.json
@@ -0,0 +1,137 @@
+{
+ "_comment": "Create flow – communication step: page, cards, and add-platform modals",
+ "page": {
+ "compactTitle": "How should this community communicate with each-other?",
+ "compactDescriptionBefore": "You can select multiple methods for different needs or ",
+ "compactDescriptionLinkLabel": "add",
+ "compactDescriptionAfter": " your own",
+ "expandedTitle": "What method should this community use to communicate with eachother?",
+ "expandedDescription": "You can select multiple methods for different needs or add your own",
+ "seeAllLink": "See all communication approaches"
+ },
+ "confirmModal": {
+ "title": "Confirm selection",
+ "description": "Confirm to select this option.",
+ "nextButtonText": "Confirm"
+ },
+ "addPlatform": {
+ "nextButtonText": "Add Platform"
+ },
+ "sectionHeadings": {
+ "corePrinciple": "Core Principle & Scope",
+ "logisticsAdmin": "Logistics, Admin & Norms",
+ "codeOfConduct": "Code of Conduct"
+ },
+ "methods": [
+ {
+ "id": "in-person-meetings",
+ "label": "In-Person Meetings",
+ "supportText": "Physical gatherings for high-bandwidth communication and relationship building.",
+ "sections": {
+ "corePrinciple": "We value the highest bandwidth of communication, physical presence, to build trust that digital tools cannot match. Consequently, we reserve this high-trust space for annual retreats, strategic planning, and high-stakes interpersonal repair where body language is essential.",
+ "logisticsAdmin": "Logistics focus on physical accessibility, venue security, and travel equity. Organizers control entry via keys or door staff. Culturally, participants are expected to maintain mission focus and adhere strictly to the itinerary to respect everyone's time. Side conversations or distracting behaviors that derail the agenda are discouraged. We prioritize efficiency and shared attention over multitasking.",
+ "codeOfConduct": "We aspire to operate within these principles. We don't need to see eye to eye on everything, but we believe the world can be improved by collective action. Aspire to do no harm to members of this community. Violence or physical intimidation will not be tolerated. We have a zero-tolerance policy for racism, sexism, and bigotry. Do not engage in activities at the venue that attract unwanted legal attention or endanger the group's physical safety."
+ }
+ },
+ {
+ "id": "signal",
+ "label": "Signal",
+ "supportText": "Encrypted messaging for high-security, private coordination.",
+ "sections": {
+ "corePrinciple": "We prioritize privacy and security above all else, accepting limited features to guarantee our communications cannot be surveilled. This acts as our \"Tactical\" layer for high-risk coordination, direct action planning, and medical/legal support where data hygiene is critical.",
+ "logisticsAdmin": "Admins are technical stewards, not status holders; they manage entry/removal and must not change settings without explicit cause. Disappearing messages are mandatory (standard 4-week timer) to prevent data liability. Norms focus on \"Attention Economy\": use emoji reactions instead of \"thanks\" messages to avoid push notification spam. Only join channels where you can actively contribute.",
+ "codeOfConduct": "This space relies on collective responsibility. Posting content that attracts unwanted legal attention or exposes members' real-world identities without consent is prohibited. We aspire to do no harm by practicing strict operational security. Intentionally leaking information violates our safety. We have a zero-tolerance policy for racism, sexism, and bigotry. Willfully spreading false information that endangers the group is grounds for removal."
+ }
+ },
+ {
+ "id": "video-meetings",
+ "label": "Video Meetings",
+ "supportText": "Synchronous video calls for remote face-to-face interaction.",
+ "sections": {
+ "corePrinciple": "We prioritize synchronous connection to read facial expressions without the barrier of travel, using this tool for weekly syncs and quick consensus checks that benefit from real-time debate before moving to a vote.",
+ "logisticsAdmin": "The host manages technical security via waiting rooms to prevent intrusion. Culturally, the focus is on maximizing the value of synchronous time. Norms include muting when not speaking, using the \"Raise Hand\" feature to queue, and utilizing the chat box for non-interruptive side comments. Distractions should be minimized to ensure the meeting stays on track and ends on time.",
+ "codeOfConduct": "We have a zero-tolerance policy for racism, sexism, and bigotry, whether spoken or shared in the chat. We aspire to do no harm. \"Zoom-bombing\" or broadcasting graphic content is prohibited. Willfully spreading obviously false information will not be tolerated. Do not discuss sensitive data that could attract unwanted legal scrutiny if the recording were subpoenaed. The Host will remove anyone threatening the group's safety."
+ }
+ },
+ {
+ "id": "loomio",
+ "label": "Loomio",
+ "supportText": "Decision-making platform for proposals, voting, and consensus.",
+ "sections": {
+ "corePrinciple": "We focus specifically on decision-making, separating \"chatter\" from \"signal\" to count every voice. This is our \"Ballot Box\" layer, used strictly for formal proposals, board elections, and consensus checks, distinct from discussion layers.",
+ "logisticsAdmin": "Groups are invite-only to protect voting integrity. Polls have set deadlines. Norms dictate that users must read the full proposal before voting, use the \"Abstain\" option if uninformed, and provide constructive feedback for any \"No\" vote.",
+ "codeOfConduct": "Our core unity is tied to collective action. Therefore, any attempt to fraudulently manipulate votes violates our principles. We have zero tolerance for hate speech in proposal comments. Disagreement should be expressed through the voting tools, not through harmful attacks. Do not use proposals to provoke unwanted legal attention. Aspire to do no harm to the democratic process."
+ }
+ },
+ {
+ "id": "matrix-element",
+ "label": "Matrix / Element",
+ "supportText": "Decentralized, encrypted chat protocol for sovereignty and interoperability.",
+ "sections": {
+ "corePrinciple": "We value data sovereignty and federation, owning our infrastructure to ensure we cannot be deplatformed. This serves as our \"Sovereign Archive\" for internal governance and cross-org collaboration, replacing corporate tools for groups requiring total data ownership.",
+ "logisticsAdmin": "Infrastructure is self-hosted with users managing their own encryption keys. Bridges connect to other networks. Culturally, this is an asynchronous space, so users should not expect immediate replies. Norms include verifying your own devices to prevent encryption errors and using the \"Reply\" function heavily.",
+ "codeOfConduct": "Users must not post content that attracts unwanted legal scrutiny to the server or its operators. We share a core belief that the world can be improved by collective action, so we do not tolerate behavior that sabotages that goal. We have zero tolerance for hate speech or harassment. Aspire to do no harm. While we value free expression, doxxing or targeted abuse will result in a ban from our room."
+ }
+ },
+ {
+ "id": "github-gitlab",
+ "label": "GitHub / GitLab",
+ "supportText": "Version control platforms for code collaboration and issue tracking.",
+ "sections": {
+ "corePrinciple": "We view \"code as law,\" believing the history of a decision is as important as the outcome. This platform serves as our \"System of Record\" for code changes, text-based bylaw amendments, and technical governance where an immutable audit trail is required.",
+ "logisticsAdmin": "Access is strictly managed via repository permissions and branch protection rules. History is immutable. Culturally, users are expected to write descriptive commit messages and use Pull Request templates to explain the \"why.\" Reviews should be professional and constructive, focusing on the technical merits rather than the personality of the author.",
+ "codeOfConduct": "Avoid rude, inconsiderate, or harmful behaviors in code reviews. We do not tolerate the exposure of private identities or the insertion of malicious code. We aspire to operate within these principles and maintain this repo as a space where hate isn't welcome. Do not upload proprietary IP or content that attracts unwanted legal attention to the project. Willfully spreading false technical information to sabotage the project is grounds for a ban."
+ }
+ },
+ {
+ "id": "discord",
+ "label": "Discord",
+ "supportText": "Real-time chat and voice server organized by topic channels.",
+ "sections": {
+ "corePrinciple": "We create a vibrant, persistent community hub for presence and social connection. It functions as the \"Community Living Room\" for general socializing and working group coordination, feeding into more formal tools like Loomio for decisions.",
+ "logisticsAdmin": "Access is hierarchically managed via Roles and server ownership is tied to an organizational account. Bots automate logging. Norms dictate that users check Pinned Messages before asking questions, keep topics in their designated channels, and use \"Threads\" for deep dives. Voice channels are casual drop-in spaces.",
+ "codeOfConduct": "We have a zero-tolerance policy for racism, sexism, and bigotry. We aspire to do no harm. Avoid rude or inconsiderate behavior. Do not post content that attracts unwanted legal attention to the server, such as pirated material or illicit threats. Exposing private identities is prohibited. Maintaining a space where hate isn't welcome is everyone's responsibility, not just moderators'."
+ }
+ },
+ {
+ "id": "email-distribution-list",
+ "label": "Email Distribution List",
+ "supportText": "Asynchronous announcements and formal threading via email.",
+ "sections": {
+ "corePrinciple": "We prioritize universality and reliability, using the lowest common denominator tool to ensure everyone is included. This functions as our \"Broadcast\" layer for official announcements and legal notices, ensuring members off chat apps still receive critical information.",
+ "logisticsAdmin": "Lists are split into \"Announce\" and \"Discuss.\" Admins approve subscriptions manually. Culturally, users are expected to respect the inbox. Reply directly to the sender for personal matters, avoid \"Reply All\" storms, update the Subject Line if the topic shifts, and format emails clearly.",
+ "codeOfConduct": "We aspire to do no harm in our communications. Using the list to spread obviously false information, racist tropes, or to intentionally target members for harassment is prohibited. Do not use this list to distribute content that attracts unwanted legal attention to the organization. We don't need to see eye to eye, but rude behaviors that degrade our collective dialogue will result in moderation."
+ }
+ },
+ {
+ "id": "slack",
+ "label": "Slack",
+ "supportText": "Structured workplace chat for teams and organizations.",
+ "sections": {
+ "corePrinciple": "We focus on structured productivity, treating communication like a digital office to maximize output. This is our \"Operations\" layer used for daily project management and file sharing, distinct from the governance or social layers.",
+ "logisticsAdmin": "The workspace is paid to retain history. Admins provision accounts for staff and guests. Culturally, users should use public channels by default to share knowledge, strictly observe \"Do Not Disturb\" hours to respect work-life balance, and use \"Threads\" to prevent channel flooding.",
+ "codeOfConduct": "Intentionally harming members via bullying, sexual harassment, or retaliation is prohibited. We maintain a zero-tolerance policy for bigotry in both public channels and DMs. Aspire to be professional. Do not use company channels for content that attracts unwanted legal attention or liability. We believe collective action improves the world, and toxic behavior undermines that work."
+ }
+ },
+ {
+ "id": "whatsapp",
+ "label": "WhatsApp",
+ "supportText": "Ubiquitous mobile chat for quick, informal coordination.",
+ "sections": {
+ "corePrinciple": "We lower the barrier to entry, meeting people where they are with a tool that requires zero training. This acts as the \"Field\" layer for neighborhood pods, urgent mutual aid, specifically for logistics that require immediate mobile notifications.",
+ "logisticsAdmin": "Organized as a \"Community\" with nested groups. Safety relies on vetting members before adding them as phone numbers are exposed. Norms include strict \"Quiet Hours\", keeping the main feed clear for logistics, and moving social chatter to \"Off-topic\" groups.",
+ "codeOfConduct": "Harvesting members' phone numbers for harassment is a severe violation. Willfully spreading misleading information that endangers the community is grounds for removal. We aspire to do no harm. Racism, sexism, and abusive language are never acceptable. Do not post content that attracts unwanted legal attention. Maintaining a safe group is our collective responsibility."
+ }
+ },
+ {
+ "id": "discourse-forum",
+ "label": "Discourse (Forum)",
+ "supportText": "Long-form, threaded discussion board for deep asynchronous conversation.",
+ "sections": {
+ "corePrinciple": "We value depth and deliberation, moving complex discussions away from chat to ensure they are thoughtful. This functions as our \"Library\" layer for policy development and proposal shaping, where rough ideas are refined before going to a vote.",
+ "logisticsAdmin": "Accounts are tied to a membership database. Trust Levels unlock features automatically. Culturally, users are expected to search before posting, write descriptive titles, and cite sources. Emotional argumentation is discouraged in favor of logic. Off-topic replies are moved by moderators.",
+ "codeOfConduct": "We aspire to minimize avoidant strategies like content warnings, trusting members to engage with reality constructively. However, intentional harm, rude behavior, and personal attacks are prohibited. Zero tolerance for racism, sexism, and bigotry. Do not post content that attracts unwanted legal attention. Constructive disagreement is welcome. Willfully false information is not."
+ }
+ }
+ ]
+}
diff --git a/messages/en/create/customRule/conflictManagement.json b/messages/en/create/customRule/conflictManagement.json
new file mode 100644
index 0000000..5f467a5
--- /dev/null
+++ b/messages/en/create/customRule/conflictManagement.json
@@ -0,0 +1,305 @@
+{
+ "_comment": "Create flow — conflict management (Figma Flow — Compact Card Stack `20879:15979`)",
+ "page": {
+ "compactTitle": "How should conflicts be managed\nin your group?",
+ "compactDescriptionBefore": "You can also combine or ",
+ "compactDescriptionLinkLabel": "add",
+ "compactDescriptionAfter": " new approaches to the list",
+ "expandedTitle": "How should conflicts be managed in your group?",
+ "expandedDescription": "You can also combine or add new approaches to the list",
+ "seeAllLink": "See all conflict management approaches"
+ },
+ "confirmModal": {
+ "title": "Confirm selection",
+ "description": "Confirm to select this option.",
+ "nextButtonText": "Confirm"
+ },
+ "addApproach": {
+ "nextButtonText": "Add Approach"
+ },
+ "sectionHeadings": {
+ "corePrinciple": "Core Principle",
+ "applicableScope": "Applicable Scope",
+ "processProtocol": "Process Protocol",
+ "restorationFallbacks": "Restoration & Fallbacks"
+ },
+ "scopeAddButtonLabel": "Add Applicable Scope",
+ "methods": [
+ {
+ "id": "peer-mediation",
+ "label": "Peer Mediation",
+ "supportText": "Trained members within the organization mediate disputes among peers.",
+ "sections": {
+ "corePrinciple": "We democratize conflict skills. Instead of relying on professional outsiders, trained peers help their colleagues resolve disputes, reinforcing the idea that we take care of each other.",
+ "applicableScope": [
+ "Low-level friction",
+ "misunderstandings",
+ "and minor grievances between peers."
+ ],
+ "processProtocol": "A volunteer peer (who is not a manager) invites the disputants to a private chat. Using a simple script, they ask questions like 'Tell us your side,' 'Tell us what you need,' and 'What can you agree to?'. The peer keeps the conversation focused on future interactions rather than past grievances. The disputants retain full control over the resolution.",
+ "restorationFallbacks": "The goal is a verbal agreement to try a new way of interacting. If the peer mediator determines the issue is too complex or involves serious harassment, they are required to refer the case to professional Mediation or a Judicial Committee."
+ }
+ },
+ {
+ "id": "conflict-resolution-council",
+ "label": "Conflict Resolution Council",
+ "supportText": "Senior members with institutional knowledge provide guidance or decisions.",
+ "sections": {
+ "corePrinciple": "We rely on the wisdom of experienced members to guide us back to our values. This body acts as the 'keepers of the culture,' providing high-context advice to resolve friction.",
+ "applicableScope": [
+ "Smoldering cultural tensions or gray-area cases where the Code of Conduct isn't clearly violated but trust is eroding."
+ ],
+ "processProtocol": "Disputants submit a request to the standing Council, which reviews the submission and may interview witnesses to understand the cultural context. The Council deliberates privately, referencing the community's values and history, before issuing a formal 'Opinion' or recommendation. While the Council holds significant influence (approx. 50% 'weight'), their recommendation is not strictly binding; it relies on social pressure and respect for the elders to encourage adoption.",
+ "restorationFallbacks": "The artifact is a formal 'Council Opinion' that clarifies how community values apply to the situation. If the parties ignore this guidance, the Council may recommend formal disciplinary action or a community vote to enforce the standard."
+ }
+ },
+ {
+ "id": "facilitated-negotiation",
+ "label": "Facilitated Negotiation",
+ "supportText": "A neutral facilitator helps guide the negotiation process.",
+ "sections": {
+ "corePrinciple": "Neutral support helps parties navigate emotional barriers to reach their own agreement. We recognize that sometimes communication breaks down not because of the issue, but because of the dynamic between people.",
+ "applicableScope": [
+ "Heated interpersonal disputes where direct communication has failed but the parties are still willing to talk."
+ ],
+ "processProtocol": "A neutral facilitator joins the discussion to set strict ground rules, such as 'no interrupting' and 'speak from I'. The facilitator manages the emotional temperature while parties take turns sharing their perspectives. By reframing toxic language—turning accusations like 'You're a liar' into statements of impact like 'I feel untrusted'—the facilitator helps the disputants focus on the substantive issues. The parties themselves retain the power to co-create and finalize the solution.",
+ "restorationFallbacks": "The process concludes with a signed or verbal Memorandum of Understanding outlining the agreed-upon behaviors. If this fails to hold, the next step is often formal Mediation or Non-Binding Arbitration to bring in more structured guidance."
+ }
+ },
+ {
+ "id": "ad-hoc-arbitration",
+ "label": "Ad Hoc Arbitration",
+ "supportText": "Arbitrators are chosen specifically for a particular case.",
+ "sections": {
+ "corePrinciple": "We value speed and specific expertise. For technical or niche disputes, we appoint a temporary judge who knows the subject matter better than a general tribunal.",
+ "applicableScope": [
+ "Technical disputes (e.g.",
+ "code architecture)",
+ "artistic differences",
+ "or specific one-off grievances."
+ ],
+ "processProtocol": "Parties agree that they are deadlocked and mutually select a single expert they both trust to act as the arbitrator. They sign an agreement beforehand to abide by this person's decision, effectively handing over 100% of the decision power. Each side presents their technical arguments, and the arbitrator reviews the evidence to issue a final ruling on the specific issue.",
+ "restorationFallbacks": "The arbitrator's ruling is final and binding for that specific issue, allowing work to proceed. Importantly, this specific ruling does not create a binding precedent for future, unrelated conflicts."
+ }
+ },
+ {
+ "id": "conflict-workshops",
+ "label": "Conflict Workshops",
+ "supportText": "Structured sessions where parties collaboratively resolve disputes and improve future interactions.",
+ "sections": {
+ "corePrinciple": "We view conflict competence as a skill to be learned. By practicing in a low-stakes environment, we immunize the community against toxic fighting when high-stakes issues arise.",
+ "applicableScope": [
+ "Preventative care during 'peacetime' or as a mandatory reset when the community vibe feels toxic."
+ ],
+ "processProtocol": "A trainer assesses the community's conflict style and gathers the group for a structured session. Participants engage in role-playing exercises, such as 'The Angry Neighbor', to practice active listening and Non-Violent Communication (NVC). The group debriefs what worked and commits to using new tools. This is a capacity-building exercise where no binding decisions are made.",
+ "restorationFallbacks": "The outcome is increased capacity and a shared vocabulary for handling tension. While there is no ruling, a member's refusal to participate in mandatory workshops may be documented as a lack of commitment to the group's health."
+ }
+ },
+ {
+ "id": "supermajority-vote",
+ "label": "Supermajority Vote",
+ "supportText": "Members vote to resolve a dispute democratically.",
+ "sections": {
+ "corePrinciple": "We use the weight of the community to resolve binary impasses. When a decision must be made and consensus is impossible, a decisive vote allows the group to move on.",
+ "applicableScope": [
+ "Final deadlock on policy decisions or removal of a member after other methods have failed."
+ ],
+ "processProtocol": "The conflict is crystallized into a clear, binary proposal (e.g., 'Should we remove member X?'). A debate period allows arguments for and against to be presented. A vote is then taken, typically via secret ballot to protect relationships. For the decision to be binding, the 'Yes' votes must exceed a high threshold—usually 75% (or 2/3rds, depending on bylaws)—effectively transferring 100% of the decision power to the voting body.",
+ "restorationFallbacks": "This produces a binding, final decision. The minority is expected to 'disagree and commit' to the result. Continued resistance or sabotage after a vote is considered a violation of community norms and is often grounds for leaving the group."
+ }
+ },
+ {
+ "id": "interest-based-bargaining",
+ "label": "Interest-Based Bargaining",
+ "supportText": "Focuses on underlying interests rather than fixed positions to find win-win solutions.",
+ "sections": {
+ "corePrinciple": "We separate the people from the problem. By focusing on why someone wants something (interests) rather than what they demand (positions), we can often find 'win-win' scenarios.",
+ "applicableScope": [
+ "Resource allocation disputes",
+ "scheduling conflicts",
+ "or disagreements over project direction."
+ ],
+ "processProtocol": "Parties list their specific demands ('Positions') and then peel back the layers by asking 'Why do you want this?' for each one. This uncovers the underlying 'Interest'—for example, 'I want the 5pm slot' becomes 'I need to pick up my kids'. The parties identify shared interests and brainstorm solutions that satisfy these core needs. They retain full authority to accept or reject the final trade-off.",
+ "restorationFallbacks": "The result is a trade-off agreement or contract. If parties remain stuck in their positions and cannot find a win-win, the process typically escalates to facilitated negotiation or mediation."
+ }
+ },
+ {
+ "id": "restorative-practices",
+ "label": "Restorative Practices",
+ "supportText": "Dialogue-focused methods for understanding and repairing harm.",
+ "sections": {
+ "corePrinciple": "We view conflict as a wound in the community, not a legal infraction. Our goal is accountability and repair: the offender must understand the impact of their actions and work to make it right.",
+ "applicableScope": [
+ "Harassment",
+ "Code of Conduct violations",
+ "or interpersonal harm where the offender admits responsibility."
+ ],
+ "processProtocol": "A facilitator holds pre-conference meetings with both parties to ensure readiness. A joint dialogue is then convened where the harmed party shares the impact of the actions ('When you did X, I felt Y'). The offender practices deep listening and reflects back what they heard without defending themselves. Together, they co-create a 'Repair Plan' to address the harm, retaining ownership of the solution.",
+ "restorationFallbacks": "The artifact is a signed 'Repair Plan' detailing specific actions (apologies, education, community service). If the offender refuses to follow through on the plan they helped create, the process converts to a punitive one, such as a Tribunal hearing or expulsion."
+ }
+ },
+ {
+ "id": "mediation",
+ "label": "Mediation",
+ "supportText": "A neutral third party assists parties in reaching a voluntary agreement.",
+ "sections": {
+ "corePrinciple": "We empower disputants to solve their own problems with structure. A mediator manages the process, but the parties own the outcome, ensuring they actually buy into the solution.",
+ "applicableScope": [
+ "Deep-seated interpersonal conflicts",
+ "co-founder disputes",
+ "or recurring friction between working groups."
+ ],
+ "processProtocol": "The mediator conducts separate 'intake' calls to hear each side's story privately before convening a joint session. During the session, the mediator uses 'looping' to ensure parties feel heard and may call a 'Caucus' (private meeting) if emotions run high. The mediator guides the parties to generate options and stress-test them, but the final decision power remains 0% with the mediator and 100% with the parties.",
+ "restorationFallbacks": "The outcome is a written Mediation Agreement. If mediation fails due to impasse, the parties must explicitly decide whether to 'agree to disagree' and live with the conflict, or escalate to Binding Arbitration to force a resolution."
+ }
+ },
+ {
+ "id": "circle-processes",
+ "label": "Circle Processes",
+ "supportText": "A structured format for open dialogue with equal input from all involved.",
+ "sections": {
+ "corePrinciple": "We prioritize equality and connection. By sitting in a circle and using a talking piece, we dismantle hierarchies and force deep listening, allowing the emotional truth of a conflict to surface.",
+ "applicableScope": [
+ "Community-wide trauma",
+ "grief processing",
+ "or when a conflict has rippled out to affect the whole group."
+ ],
+ "processProtocol": "Participants gather in a circle with a centerpiece, and a 'Keeper' opens the session with a poem or quote to set the tone. A talking piece is introduced, granting the holder sole permission to speak. The group moves through rounds answering specific questions like 'What is the hardest part of this for you?', ensuring every voice is heard equally. No decision is forced; the power resides in the collective understanding generated by the circle.",
+ "restorationFallbacks": "The goal is a collective sense of understanding or a 'Group Covenant' describing how we want to be together. If specific harm is identified that requires repair, the circle may spin off into a separate Restorative Justice process."
+ }
+ },
+ {
+ "id": "judicial-committees",
+ "label": "Judicial Committees",
+ "supportText": "A standing committee responsible for adjudicating disputes.",
+ "sections": {
+ "corePrinciple": "We ensure consistency and due process. By having a standing body, we remove bias and ensure that every conflict is judged against the same set of bylaws.",
+ "applicableScope": [
+ "Interpretation of bylaws",
+ "contested elections",
+ "or allegations of abuse of power by leadership."
+ ],
+ "processProtocol": "A formal complaint is filed with the Committee, which is composed of elected or appointed members serving fixed terms. The Committee holds a formal hearing where both sides present evidence and call witnesses. The proceedings are recorded. The Committee then deliberates in closed session to determine if the rules were violated, holding 100% of the decision power.",
+ "restorationFallbacks": "The Committee issues a written 'Judgment' that is binding. This may include penalties like censure, removal from office, or expulsion. The decision is recorded in the organization's case law to guide future rulings."
+ }
+ },
+ {
+ "id": "managerial-decision",
+ "label": "Managerial Decision",
+ "supportText": "A manager or leader makes a binding decision.",
+ "sections": {
+ "corePrinciple": "We prioritize efficiency and clear lines of authority. In a hierarchy, the person with responsibility for the outcome must have the authority to resolve the blockers.",
+ "applicableScope": [
+ "Operational disagreements",
+ "performance issues",
+ "or swift resolution of low-stakes conflicts."
+ ],
+ "processProtocol": "The manager hears both sides of the conflict (often in 1:1 meetings) and consults organizational policy. They then make a unilateral decision based on what is best for the business or the team's goals. The manager holds 100% of the decision power.",
+ "restorationFallbacks": "The decision is communicated as a directive. Compliance is mandatory as a condition of employment. Failure to comply is treated as insubordination."
+ }
+ },
+ {
+ "id": "internal-tribunal",
+ "label": "Internal Tribunal",
+ "supportText": "A formal hearing body within the organization.",
+ "sections": {
+ "corePrinciple": "We provide rigorous due process for the most serious accusations. To protect members from unjust expulsion, we simulate a legal trial to ensure high standards of evidence.",
+ "applicableScope": [
+ "High-stakes violations that could result in permanent expulsion",
+ "blacklisting",
+ "or significant financial penalty."
+ ],
+ "processProtocol": "A panel of judges (often distinct from the leadership team) is convened. A 'Prosecutor' presents the case against the accused, and a 'Defender' advocates for them. Formal rules of evidence apply. The Tribunal weighs the facts against the organization's 'Constitution' or supreme laws.",
+ "restorationFallbacks": "The Tribunal issues a Verdict. If guilty, the sentence is executed immediately. Appeals are only allowed on procedural grounds (e.g., if the Tribunal failed to follow its own rules), not on the facts of the case."
+ }
+ },
+ {
+ "id": "consensus-building",
+ "label": "Consensus Building",
+ "supportText": "Collaborative work to reach a resolution that all parties can agree upon.",
+ "sections": {
+ "corePrinciple": "We believe that sustainable solutions come from the parties themselves finding common ground. Unlike a top-down ruling, a consensus agreement ensures that all stakeholders feel heard and invested in the outcome.",
+ "applicableScope": [
+ "Best for complex",
+ "multi-stakeholder issues where relationships must be preserved and no single rule was broken."
+ ],
+ "processProtocol": "The process begins by convening all stakeholders in a shared space to establish shared goals and ground rules. Each party is invited to state their underlying needs rather than just their surface demands. The group then brainstorms multiple options for mutual gain without immediate judgment. Through dialogue, these options are refined until a single solution emerges that every stakeholder can support, maintaining full decision-making power within the group itself.",
+ "restorationFallbacks": "The result is a shared agreement ratified by all parties. If consensus cannot be reached, the group may define a specific fallback mechanism, such as escalating to a Supermajority Vote (requiring 75% agreement) or bringing in an external Facilitator to unblock the dialogue."
+ }
+ },
+ {
+ "id": "binding-arbitration",
+ "label": "Binding Arbitration",
+ "supportText": "An external arbitrator makes a binding decision.",
+ "sections": {
+ "corePrinciple": "We need finality and legal certainty. By outsourcing the decision to a professional judge, we remove internal bias and ensure the ruling will hold up in court.",
+ "applicableScope": [
+ "Commercial disputes",
+ "contract breaches between entities",
+ "or employment termination disputes."
+ ],
+ "processProtocol": "The organization contracts a professional arbitration firm. The process follows strict legal procedures similar to a court trial but is private. The arbitrator hears evidence and legal arguments from both sides' lawyers. The arbitrator holds 100% of the decision power.",
+ "restorationFallbacks": "The award is final, binding, and enforceable in real-world courts. There is typically no right of appeal. This provides total closure to the dispute."
+ }
+ },
+ {
+ "id": "non-binding-arbitration",
+ "label": "Non-Binding Arbitration",
+ "supportText": "An arbitrator gives a recommendation that is not binding.",
+ "sections": {
+ "corePrinciple": "We want an expert opinion to break a deadlock, but we aren't ready to hand over full control. A neutral expert's 'advisory opinion' can often shame or persuade parties into agreement.",
+ "applicableScope": [
+ "Complex technical or valuation disputes where parties need a reality check on their positions."
+ ],
+ "processProtocol": "Similar to binding arbitration, a neutral expert hears the case. However, instead of a ruling, they issue a 'Recommendation' explaining how a court would likely rule or what is fair. The parties then take this recommendation back to the negotiation table.",
+ "restorationFallbacks": "The parties use the recommendation as a baseline for a final settlement. If they still cannot agree, they preserve the right to go to court or binding arbitration."
+ }
+ },
+ {
+ "id": "binding-contracts",
+ "label": "Binding Contracts",
+ "supportText": "Legal agreements that define resolution methods.",
+ "sections": {
+ "corePrinciple": "We prioritize predictability and liability protection. By defining the rules of engagement in advance, we prevent ambiguity when things go wrong.",
+ "applicableScope": [
+ "Vendor relationships",
+ "employment terms",
+ "intellectual property ownership",
+ "and liability waivers."
+ ],
+ "processProtocol": "The process happens before conflict arises. Parties negotiate terms (using lawyers if necessary) and sign a document. When a conflict occurs, the contract is referenced. If the contract says 'X happens,' then X happens automatically without further debate.",
+ "restorationFallbacks": "The contract itself is the resolution mechanism. Breach of contract triggers specific penalties defined in the document (e.g., termination, fines)."
+ }
+ },
+ {
+ "id": "lottery-sortition",
+ "label": "Lottery/Sortition",
+ "supportText": "Random selection used to resolve low-stakes disputes or select juries.",
+ "sections": {
+ "corePrinciple": "We believe that when two valid options exist and neither is 'wrong,' a random decision is the fairest way to break the tie.",
+ "applicableScope": [
+ "Allocating scarce resources (e.g.",
+ "who gets the office)",
+ "scheduling conflicts",
+ "or low-stakes ties."
+ ],
+ "processProtocol": "The group agrees that the issue is not worth further debate and commits to a random method (coin flip, drawing straws, RNG). The random event occurs, and the result is accepted immediately. This transfers 100% of the decision power to chance.",
+ "restorationFallbacks": "The resolution is immediate. Because the process is seen as inherently unbiased, no repair is usually needed. Refusal to accept the result is treated as acting in bad faith."
+ }
+ },
+ {
+ "id": "rotational-judging",
+ "label": "Rotational Judging",
+ "supportText": "A rotating set of members is assigned to handle conflicts.",
+ "sections": {
+ "corePrinciple": "We distribute the power of judgment. By taking turns being the 'judge,' members learn empathy for the difficulty of making decisions and prevent a permanent ruling class.",
+ "applicableScope": [
+ "Minor Code of Conduct infractions",
+ "disputes over shared space or noise."
+ ],
+ "processProtocol": "A 'Jury Duty' roster is created including all eligible members. When a dispute arises, the next three members on the list are summoned to hear the case briefly. They issue a ruling based on common sense and community norms, holding 100% of the decision power for that specific instance. Afterward, they return to the pool.",
+ "restorationFallbacks": "The ruling is binding for that instance. This builds community capacity for governance. Repeated poor judgments by rotational judges may trigger a review of the 'Jury Duty' training process."
+ }
+ }
+ ]
+}
diff --git a/messages/en/create/coreValues.json b/messages/en/create/customRule/coreValues.json
similarity index 100%
rename from messages/en/create/coreValues.json
rename to messages/en/create/customRule/coreValues.json
diff --git a/messages/en/create/customRule/decisionApproaches.json b/messages/en/create/customRule/decisionApproaches.json
new file mode 100644
index 0000000..6e755b0
--- /dev/null
+++ b/messages/en/create/customRule/decisionApproaches.json
@@ -0,0 +1,535 @@
+{
+ "_comment": "Create flow — decision approaches (Figma Flow — Right Rail `20523:23509`)",
+ "sidebar": {
+ "title": "How should this community make difficult decisions?",
+ "descriptionBefore": "Select as many as you need to describe how your group makes decisions. You can also ",
+ "descriptionLinkLabel": "add",
+ "descriptionAfter": " new decision making approaches or interact with the categories below to filter."
+ },
+ "messageBox": {
+ "title": "Consider defining approaches to steward key resources:",
+ "items": [
+ {
+ "id": "amend",
+ "label": "Amend your CommunityRule"
+ },
+ {
+ "id": "finances",
+ "label": "Steward finances"
+ },
+ {
+ "id": "project",
+ "label": "Project level decisions"
+ },
+ {
+ "id": "discipline",
+ "label": "Discipline and member termination"
+ }
+ ]
+ },
+ "cardStack": {
+ "toggleSeeAll": "See all decision approaches",
+ "toggleShowLess": "Show less"
+ },
+ "confirmModal": {
+ "title": "Confirm selection",
+ "description": "Confirm to select this option.",
+ "nextButtonText": "Confirm"
+ },
+ "addApproach": {
+ "nextButtonText": "Add Approach"
+ },
+ "sectionHeadings": {
+ "corePrinciple": "Core Principle",
+ "applicableScope": "Applicable Scope",
+ "stepByStepInstructions": "Step-by-Step Instructions",
+ "consensusLevel": "Consensus Level",
+ "objectionsDeadlocks": "Objections & Deadlocks"
+ },
+ "scopeAddButtonLabel": "Add Applicable Scope",
+ "methods": [
+ {
+ "id": "lazy-consensus",
+ "label": "Lazy Consensus",
+ "supportText": "A decision is assumed approved unless objections are raised within a specified timeframe.",
+ "sections": {
+ "corePrinciple": "We prioritize momentum and trust over bureaucracy. By assuming good faith, we avoid bottlenecks; silence is interpreted as consent to keep the work moving.",
+ "applicableScope": [
+ "Daily Operations",
+ "Minor Expenditures"
+ ],
+ "consensusLevel": 100,
+ "stepByStepInstructions": "Post your proposal to the relevant channel with a specific deadline, such as 'Merging in 72 hours.' If the deadline passes without any objections, you are authorized to proceed. Explicit support is welcome but not required.",
+ "objectionsDeadlocks": "Any member can pause the process by raising a 'Block' or 'Concern' before the deadline. The proposer is then required to pause execution and engage in a dialogue to resolve the concern. If the disagreement cannot be resolved asynchronously, the proposal is escalated to a synchronous meeting or a higher governance tier for a final decision."
+ }
+ },
+ {
+ "id": "do-ocracy",
+ "label": "Do-ocracy",
+ "supportText": "Decisions are made by those who take initiative and carry out the work.",
+ "sections": {
+ "corePrinciple": "Action is valued over permission. We believe that decision-making power should reside with the individuals who are actually doing the work.",
+ "applicableScope": [
+ "Implementation Details",
+ "Volunteer Tasks",
+ "Low-Risk Experiments"
+ ],
+ "consensusLevel": 0,
+ "stepByStepInstructions": "Identify a task that needs to be done and simply start doing it. While it is good practice to announce your intentions to avoid duplicated effort, you do not need to wait for approval. Report back once the task is complete.",
+ "objectionsDeadlocks": "Objections are handled through a retroactive review process (Retrospective). If an action is deemed harmful after the fact, the group discusses it to establish new safety guidelines for the future. In extreme cases, the action may be reversed, but the doer is rarely punished for acting in good faith."
+ }
+ },
+ {
+ "id": "consensus-decision-making",
+ "label": "Consensus Decision-Making",
+ "supportText": "All members must agree. Best for important decisions in small groups. Does not work well for low stakes decsions. ",
+ "sections": {
+ "corePrinciple": "We strive for deep unity and alignment. We only move forward when every single member can live with the decision, ensuring no minority voice is ignored.",
+ "applicableScope": [
+ "Core Values",
+ "Constitutional Changes",
+ "High-Stakes Strategy"
+ ],
+ "consensusLevel": 100,
+ "stepByStepInstructions": "The facilitator presents the proposal and opens the floor for clarifying questions. After a period of discussion and modification to address concerns, the facilitator calls for consensus by asking, 'Does anyone block?' If no blocks are raised, the decision is adopted.",
+ "objectionsDeadlocks": "A 'Block' acts as a veto and stops the proposal entirely. Blocks must be justified based on the group's core principles, not personal preference. If the group cannot resolve the block through modification, the proposal is shelved, and the status quo remains in effect until a new solution is proposed."
+ }
+ },
+ {
+ "id": "rotational-leadership",
+ "label": "Rotational Leadership",
+ "supportText": "Decision-making responsibilities rotate among members.",
+ "sections": {
+ "corePrinciple": "We share the burden of leadership to build skills across the group and prevent power from accumulating in the hands of a single individual.",
+ "applicableScope": [
+ "Facilitation",
+ "Meeting Chair",
+ "Administrative Roles"
+ ],
+ "consensusLevel": 0,
+ "stepByStepInstructions": "The group defines the role and a set term length, such as one month. A roster of eligible members is created, and at the end of each term, the responsibilities automatically pass to the next person on the list without a vote.",
+ "objectionsDeadlocks": "If a selected member is unable to fulfill their term due to capacity, they are responsible for finding a substitute to swap shifts with them. If a member is deemed unfit by the group, a special governance meeting is called to skip their turn or remove them from the roster."
+ }
+ },
+ {
+ "id": "modified-consensus",
+ "label": "Modified Consensus",
+ "supportText": "Attempts to reach full agreement first, but falls back to voting if consensus isn’t possible.",
+ "sections": {
+ "corePrinciple": "We prefer unanimous agreement but refuse to be paralyzed by it. If full consensus cannot be reached, we have a fallback mechanism to ensure progress.",
+ "applicableScope": [
+ "General Governance",
+ "Policy Changes"
+ ],
+ "consensusLevel": 100,
+ "stepByStepInstructions": "The group first attempts to reach standard consensus. If consensus is blocked after a set number of attempts or a specific time period, the proposal moves to a vote. This fallback vote requires a Supermajority to pass.",
+ "objectionsDeadlocks": "When a deadlock occurs in the consensus phase, the process shifts to a Supermajority vote (e.g., 75%) to resolve it. The dissenting minority's views are explicitly recorded in the official minutes to ensure their perspective is preserved, even though the decision proceeds against their wishes."
+ }
+ },
+ {
+ "id": "consensus-seeking-with-delegates",
+ "label": "Consensus Seeking with Delegates",
+ "supportText": "Members provide input, and delegates refine decisions to seek broad agreement.",
+ "sections": {
+ "corePrinciple": "We scale our decision-making by using representation. Small, trusted groups can deliberate more effectively than large crowds.",
+ "applicableScope": [
+ "Federated Networks",
+ "Large Co-ops"
+ ],
+ "consensusLevel": 100,
+ "stepByStepInstructions": "The general membership discusses the issue in small pods and elects a delegate to represent their views. These delegates then meet in a circle to deliberate and reach consensus on a final decision on behalf of their pods.",
+ "objectionsDeadlocks": "If delegates cannot reach agreement, they must return to their home pods to explain the impasse and receive updated instructions. If a delegate repeatedly fails to represent their pod's will, the pod may recall them and elect a new representative to restart the negotiation."
+ }
+ },
+ {
+ "id": "sociocracy",
+ "label": "Sociocracy",
+ "supportText": "Decisions are made in small, interconnected circles with feedback loops connecting the organization.",
+ "sections": {
+ "corePrinciple": "We govern by dynamic consent. No valid objection is ignored, but no one can stop a decision without a reasoned argument explaining how it harms our aim.",
+ "applicableScope": [
+ "Organizational Structure",
+ "Policy Making"
+ ],
+ "consensusLevel": 100,
+ "stepByStepInstructions": "After a proposal is presented and clarifying questions are answered, the group does a 'reaction round' to share feelings. This is followed by a 'consent round' where the facilitator asks if anyone has a paramount objection. If none exist, the proposal is adopted.",
+ "objectionsDeadlocks": "An objection is only valid if it is 'Paramount'—meaning the objector argues that the proposal interferes with the group's ability to achieve its aim. The group is then obligated to modify the proposal to resolve the objection. If the objection cannot be integrated, the proposal is rejected."
+ }
+ },
+ {
+ "id": "supermajority-rule",
+ "label": "Supermajority Rule",
+ "supportText": "A higher threshold (e.g., 2/3 or 3/4) must be met for a decision to pass. Can be a great fallback for when consensus fails.",
+ "sections": {
+ "corePrinciple": "Broad agreement is necessary for significant changes. We require more than a simple majority to prevent the 'tyranny of the 51%' from dominating the minority.",
+ "applicableScope": [
+ "Bylaw Amendments",
+ "Removing Members"
+ ],
+ "consensusLevel": 67,
+ "stepByStepInstructions": "The proposal is debated until the final text is locked. A vote is then taken, and if the number of 'Yeas' exceeds the pre-set threshold (usually 67% or 75%), the motion passes.",
+ "objectionsDeadlocks": "If the vote achieves a majority but fails to reach the Supermajority threshold, the motion fails and the status quo is maintained. This ensures that the constitution or bylaws remain stable and are not changed without overwhelming support."
+ }
+ },
+ {
+ "id": "ranked-choice-voting",
+ "label": "Ranked Choice Voting",
+ "supportText": "Members rank options by preference, and votes are redistributed until one option has a majority.",
+ "sections": {
+ "corePrinciple": "We seek to maximize overall preference. We want the option that the broadest number of people support, rather than just the one with the loudest base.",
+ "applicableScope": [
+ "Elections",
+ "Multi-Option Selection"
+ ],
+ "consensusLevel": 51,
+ "stepByStepInstructions": "Voters rank all options by preference (1st, 2nd, 3rd). If no option wins a majority of 1st choices, the lowest-ranked option is eliminated, and their votes are redistributed to the voters' second choices. This continues until a winner emerges.",
+ "objectionsDeadlocks": "In the extremely rare event of a mathematical tie in the final round, the winner is decided by looking at which candidate had the most First Choice votes in the initial round. If that is also tied, a random selection method (like a coin toss) is used as the final tie-breaker."
+ }
+ },
+ {
+ "id": "range-voting",
+ "label": "Range Voting",
+ "supportText": "Members score each option, and the option with the highest total or average score wins.",
+ "sections": {
+ "corePrinciple": "We want to capture the nuance of opinion. By measuring the intensity of preference, we can identify options that are broadly acceptable rather than polarizing.",
+ "applicableScope": [
+ "Prioritization",
+ "Budget Allocation"
+ ],
+ "consensusLevel": 0,
+ "stepByStepInstructions": "Voters assign a score to each option (e.g., from 0 to 10). The scores for each option are summed up or averaged, and the option with the highest total score is selected as the winner.",
+ "objectionsDeadlocks": "If two options receive the exact same total score, the tie is broken by selecting the option that received the highest number of perfect scores (e.g., the most '10s'). This prioritizes the option that generates the most enthusiastic support."
+ }
+ },
+ {
+ "id": "majority-rule",
+ "label": "Majority Rule",
+ "supportText": "A decision is approved if it receives more than 50% of the votes.",
+ "sections": {
+ "corePrinciple": "We value efficiency and equality. Every member's vote counts equally, and we accept that the side with the most votes wins.",
+ "applicableScope": [
+ "General Elections",
+ "Low-Risk Referendums"
+ ],
+ "consensusLevel": 51,
+ "stepByStepInstructions": "After a period of debate, a motion is made to 'call the question.' A simple up/down vote is taken, and if more than 50% of the members vote in favor, the decision is approved.",
+ "objectionsDeadlocks": "In the event of a 50/50 tie, the motion fails by default, and the status quo prevails. Alternatively, a designated Chairperson may cast a tie-breaking vote if this power was granted in the bylaws."
+ }
+ },
+ {
+ "id": "approval-voting",
+ "label": "Approval Voting",
+ "supportText": "Members vote for all options they find acceptable; the option with the most approvals wins.",
+ "sections": {
+ "corePrinciple": "We focus on satisfaction and flexibility. Instead of picking just one favorite, members identify all the options they would be willing to accept.",
+ "applicableScope": [
+ "Scheduling",
+ "Selecting Venues",
+ "Shortlisting"
+ ],
+ "consensusLevel": 0,
+ "stepByStepInstructions": "All options are presented to the group. Voters select every option they approve of or can live with. The option that receives the most checks is declared the winner.",
+ "objectionsDeadlocks": "Ties are frequent in Approval Voting. If a tie occurs, the group holds a runoff vote featuring only the tied options. If the runoff is also tied, the decision may be made by random selection."
+ }
+ },
+ {
+ "id": "weighted-voting",
+ "label": "Weighted Voting",
+ "supportText": "Votes carry different weights based on criteria like financial contribution or seniority.",
+ "sections": {
+ "corePrinciple": "We recognize stakeholder equity. Those who have invested more risk, money, or time should have a proportionally greater say in the outcome.",
+ "applicableScope": [
+ "Financial Decisions (Condos",
+ "DAOs)"
+ ],
+ "consensusLevel": 51,
+ "stepByStepInstructions": "Voting power is assigned to members based on specific criteria, such as tokens held or years active. When a vote is cast, it is multiplied by the member's weight, and the side with the majority of the weighted stake wins.",
+ "objectionsDeadlocks": "Disputes regarding vote weight must be resolved by auditing the underlying ledger (e.g., financial records or blockchain) before the vote proceeds. If the ledger is contested, the vote is postponed until an external audit is completed."
+ }
+ },
+ {
+ "id": "cumulative-voting",
+ "label": "Cumulative Voting",
+ "supportText": "Members distribute a set number of votes across one or more options, often to express intensity of preference.",
+ "sections": {
+ "corePrinciple": "We want to protect minority representation. Minority groups can pool their influence to ensure they elect at least one representative.",
+ "applicableScope": [
+ "Board Elections"
+ ],
+ "consensusLevel": 0,
+ "stepByStepInstructions": "Voters are given a number of votes equal to the open seats. They can distribute these votes however they wish, including stacking all of them on a single candidate. The candidates with the highest totals win.",
+ "objectionsDeadlocks": "Deadlocks are rare, but if two candidates tie for the final seat, a runoff election is held between just those two candidates. The voting pool remains the same, but voters only cast ballots for the specific seat in question."
+ }
+ },
+ {
+ "id": "quadratic-voting",
+ "label": "Quadratic Voting",
+ "supportText": "Members use credits to vote, with the cost increasing quadratically for multiple votes on the same option.",
+ "sections": {
+ "corePrinciple": "We measure how much you care, not just what you want. This system uses 'costly signaling' to allow members to express intense preference on specific issues.",
+ "applicableScope": [
+ "Resource Allocation",
+ "DAOs"
+ ],
+ "consensusLevel": 0,
+ "stepByStepInstructions": "Users are allocated a budget of 'Credits.' They can buy votes for an option, but the cost increases quadratically (e.g., 1 vote costs 1 credit, but 2 votes costs 4 credits). The option with the highest calculated support wins.",
+ "objectionsDeadlocks": "The primary risk is a 'Sybil Attack' (one person pretending to be multiple people to avoid the quadratic cost). Identity verification must be enforced strictly. If an attack is suspected, the vote is frozen pending a security review."
+ }
+ },
+ {
+ "id": "continuous-voting",
+ "label": "Continuous Voting",
+ "supportText": "Members can change their votes over time, with decisions finalized when a threshold is reached.",
+ "sections": {
+ "corePrinciple": "We believe governance should be fluid. Decisions shouldn't be locked in for years; support for a policy can be given or withdrawn at any time.",
+ "applicableScope": [
+ "DAOs",
+ "Policy Settings"
+ ],
+ "consensusLevel": 51,
+ "stepByStepInstructions": "Proposals remain open indefinitely. Members can stake their votes on them at any time. If a proposal maintains enough support to stay above the passing threshold for a set duration (e.g., 24 hours), it executes.",
+ "objectionsDeadlocks": "To prevent 'Flash Attacks' (sudden massive voting to pass a malicious proposal), a mandatory time delay or 'Grace Period' is enforced before execution. This gives dissenting members time to react or withdraw their assets ('Rage Quit') if they strongly disagree."
+ }
+ },
+ {
+ "id": "holacracy",
+ "label": "Holacracy",
+ "supportText": "Decision-making authority is distributed across self-organizing teams.",
+ "sections": {
+ "corePrinciple": "We distribute authority to roles, not people. This system focuses on rapid evolution through 'Integrative Decision Making' within self-organizing circles.",
+ "applicableScope": [
+ "Operational Management"
+ ],
+ "consensusLevel": 100,
+ "stepByStepInstructions": "A proposal is made to change a role or policy. The group tests valid objections, which must be based on data or safety. The proposer then integrates these objections into an amended proposal, which is adopted once no valid objections remain.",
+ "objectionsDeadlocks": "Objections must pass a strict validity test: 'Is this unsafe to try?' or 'Does this move us backward?' If an objector cannot prove the proposal is unsafe, the objection is discarded as a personal preference, and the proposal proceeds."
+ }
+ },
+ {
+ "id": "collaborative-platforms",
+ "label": "Collaborative Platforms",
+ "supportText": "Structured discussions on tools like Loomio or Polis are used to make group decisions.",
+ "sections": {
+ "corePrinciple": "We use technology to bridge time zones and enable asynchronous deliberation. This allows remote teams to participate fully without synchronous meetings.",
+ "applicableScope": [
+ "Remote Teams",
+ "Online Communities"
+ ],
+ "consensusLevel": 51,
+ "stepByStepInstructions": "Discussion happens asynchronously on a platform like Loomio or Polis. A poll is opened for a specific window of time, and the platform automatically closes the poll and tallies the results based on the pre-set settings.",
+ "objectionsDeadlocks": "If technical issues prevent participation, the administrator is empowered to extend the voting deadline by 24 hours. If engagement is too low to meet quorum, the vote is declared void and must be re-launched with better promotion."
+ }
+ },
+ {
+ "id": "deliberative-polling",
+ "label": "Deliberative Polling",
+ "supportText": "Members discuss and reflect on an issue before voting, informed by deliberation.",
+ "sections": {
+ "corePrinciple": "We value informed democracy over raw opinion. We believe that people make better decisions when they are provided with experts, data, and time to reflect.",
+ "applicableScope": [
+ "Civic Juries",
+ "Policy Research"
+ ],
+ "consensusLevel": 0,
+ "stepByStepInstructions": "A random sample of the population is selected. They are provided with briefing materials and access to experts. After a period of facilitated small-group discussion, they are polled to see how their informed opinions have shifted.",
+ "objectionsDeadlocks": "The result is usually advisory/informational. If the results are ambiguous or contradictory, the organizers may commission a second round of deliberation with a fresh sample to verify the findings."
+ }
+ },
+ {
+ "id": "investor-filled-board-seats",
+ "label": "Investor-Filled Board Seats",
+ "supportText": "Key decisions are made by a board with seats allocated to investors or stakeholders.",
+ "sections": {
+ "corePrinciple": "We operate on fiduciary duty. Since capital controls the direction of the organization, those who provide the funding have the right to steer the ship.",
+ "applicableScope": [
+ "Startups",
+ "For-Profits"
+ ],
+ "consensusLevel": 51,
+ "stepByStepInstructions": "Investors appoint directors to the board as a condition of their funding. The board meets quarterly to vote on strategic decisions, and these votes are binding on the CEO and the organization.",
+ "objectionsDeadlocks": "Deadlocks between Founder-Directors and Investor-Directors are resolved via the legal clauses in the Shareholder Agreement (e.g., specific veto rights or arbitration). If the conflict is irreparable, investors may force a sale of the company."
+ }
+ },
+ {
+ "id": "elected-board-of-directors",
+ "label": "Elected Board of Directors",
+ "supportText": "Members elect representatives to make decisions on their behalf.",
+ "sections": {
+ "corePrinciple": "We believe in representative democracy. It is inefficient for the entire membership to decide every detail, so we delegate authority to a trusted few.",
+ "applicableScope": [
+ "Nonprofits",
+ "Co-ops"
+ ],
+ "consensusLevel": 51,
+ "stepByStepInstructions": "The general membership votes annually to elect Directors. These Directors then meet regularly to make operational and strategic decisions. Members retain the power to recall Directors if they lose trust.",
+ "objectionsDeadlocks": "If the membership disagrees with a Board decision, they can launch a petition to trigger a Special General Meeting. At this meeting, members can vote to overturn the specific policy or, in extreme cases, recall the entire Board."
+ }
+ },
+ {
+ "id": "advisory-committees",
+ "label": "Advisory Committees",
+ "supportText": "Smaller groups provide recommendations, which the broader organization typically follows.",
+ "sections": {
+ "corePrinciple": "We value expertise without granting it absolute authority. These bodies provide specialized guidance to help leaders make better informed choices.",
+ "applicableScope": [
+ "Technical Review",
+ "Ethics Boards"
+ ],
+ "consensusLevel": 0,
+ "stepByStepInstructions": "The committee reviews a specific problem or proposal in depth. They issue a formal, non-binding report with recommendations. The final decision-maker then chooses whether to adopt or ignore this advice.",
+ "objectionsDeadlocks": "While the decision-maker can ignore the advice, doing so repeatedly may lead to the resignation of the committee members. To prevent this, the decision-maker is often required to write a formal response explaining why they chose to diverge from the recommendation."
+ }
+ },
+ {
+ "id": "delegated-decision-making",
+ "label": "Delegated Decision-Making",
+ "supportText": "Specific individuals or groups are entrusted with decision-making authority.",
+ "sections": {
+ "corePrinciple": "We operate on trust and speed. We explicitly empower individuals to own specific domains so the group doesn't become a bottleneck.",
+ "applicableScope": [
+ "Project Management",
+ "Sub-committees"
+ ],
+ "consensusLevel": 0,
+ "stepByStepInstructions": "The group explicitly grants authority to a specific person or role for a defined domain. That person makes decisions autonomously, and the group reviews the outcomes periodically rather than micromanaging the process.",
+ "objectionsDeadlocks": "If the delegate makes a decision the group dislikes, the group cannot retroactively undo it (unless unsafe). Instead, the remedy is to revoke the delegation for future decisions or assign the role to a different person."
+ }
+ },
+ {
+ "id": "executive-committees",
+ "label": "Executive Committees",
+ "supportText": "A subset of leaders or senior members makes critical decisions.",
+ "sections": {
+ "corePrinciple": "We need agility for crisis management. A small, high-context group can react faster and handle sensitive information better than a large board.",
+ "applicableScope": [
+ "Emergencies",
+ "Confidential HR"
+ ],
+ "consensusLevel": 51,
+ "stepByStepInstructions": "The full board delegates specific powers to a smaller Executive Committee. This committee meets frequently to handle urgent matters and reports their actions back to the full board for ratification.",
+ "objectionsDeadlocks": "The Executive Committee's power is limited by the bylaws. If they overstep their authority, the Full Board can vote to annul their decision. Persistent overreach typically results in the dissolution or restructuring of the committee."
+ }
+ },
+ {
+ "id": "first-past-the-post",
+ "label": "First Past the Post",
+ "supportText": "The option with the most votes wins, regardless of whether it achieves a majority.",
+ "sections": {
+ "corePrinciple": "We prioritize simplicity. The goal is to identify a clear winner quickly, even if they don't have the support of the majority.",
+ "applicableScope": [
+ "Simple Elections"
+ ],
+ "consensusLevel": 0,
+ "stepByStepInstructions": "Voters cast a single vote for one option. All votes are counted, and the candidate with the highest number of votes wins, regardless of whether they achieved more than 50% of the total.",
+ "objectionsDeadlocks": "In the event of a tie for first place, a runoff election is held between the tied candidates. To prevent vote-splitting from distorting results in the future, the group may decide to switch to Ranked Choice Voting for subsequent elections."
+ }
+ },
+ {
+ "id": "lottery-sortition",
+ "label": "Lottery/Sortition",
+ "supportText": "Members or leaders are randomly selected to make decisions, promoting fairness.",
+ "sections": {
+ "corePrinciple": "We seek fairness and anti-corruption. By removing ego and campaigning from the process, we ensure that decisions are made by a truly representative sample.",
+ "applicableScope": [
+ "Juries",
+ "selecting speakers",
+ "assigning unpleasant tasks"
+ ],
+ "consensusLevel": 0,
+ "stepByStepInstructions": "A pool of eligible candidates is defined. A randomizer (like dice or an algorithm) is used to select a person or option. The result of the random draw is binding.",
+ "objectionsDeadlocks": "If a selected individual refuses to serve, the random draw is performed again immediately. If refusal becomes a pattern, the group must examine whether the incentives for the role are adequate."
+ }
+ },
+ {
+ "id": "proof-of-work",
+ "label": "Proof of Work",
+ "supportText": "Decision weight is tied to demonstrable effort or contributions, common in blockchain systems.",
+ "sections": {
+ "corePrinciple": "We require skin in the game. Influence should match effort, so those who contribute the most resources or labor have the right to decide.",
+ "applicableScope": [
+ "Crypto",
+ "Meritocratic Communities"
+ ],
+ "consensusLevel": 51,
+ "stepByStepInstructions": "Participants perform a verifiable task or contribute computational power. Once the work is verified, they earn the right to append a block or make a decision, with the longest chain of work becoming the truth.",
+ "objectionsDeadlocks": "Disagreements result in a 'Fork,' where the community splits into two separate groups, each following a different chain of truth. This is the ultimate deadlock breaker: the groups simply separate and go their own ways."
+ }
+ },
+ {
+ "id": "random-choice",
+ "label": "Random Choice",
+ "supportText": "Decisions are made by chance, such as a coin toss or drawing lots, to avoid bias.",
+ "sections": {
+ "corePrinciple": "We need to break paralysis. When options are equal and the stakes are low, spending time debating is wasteful.",
+ "applicableScope": [
+ "Low stakes ties",
+ "Restaurant choice"
+ ],
+ "consensusLevel": 0,
+ "stepByStepInstructions": "When the group is deadlocked on trivial options, simply flip a coin or draw straws. The group agrees beforehand to immediately obey the result of the chance event.",
+ "objectionsDeadlocks": "Refusal to accept the coin flip is considered bad faith. Since this method is only used for low-stakes issues, persistent arguing after the flip is grounds for a conduct warning."
+ }
+ },
+ {
+ "id": "algorithm-driven-decisions",
+ "label": "Algorithm-Driven Decisions",
+ "supportText": "Automated systems analyze data and propose or enforce decisions.",
+ "sections": {
+ "corePrinciple": "We value objectivity. We prefer 'rules over rulers,' relying on pre-agreed logic to distribute resources rather than human bias.",
+ "applicableScope": [
+ "Budget distribution",
+ "Scheduling"
+ ],
+ "consensusLevel": 0,
+ "stepByStepInstructions": "The group agrees on a set of rules or code. Data is input into the system, and the algorithm processes it to produce an output. The group accepts this output as the final decision.",
+ "objectionsDeadlocks": "If the algorithm produces a clearly erroneous result (a 'bug'), the group can vote to suspend the automation and manually override the decision. A technical review is then triggered to patch the code before it is used again."
+ }
+ },
+ {
+ "id": "autocratic-decision-making",
+ "label": "Autocratic Decision-Making",
+ "supportText": "A single leader or authority makes all decisions without group input.",
+ "sections": {
+ "corePrinciple": "We prioritize clarity and speed above all else. A single vision allows for rapid execution without the drag of committee meetings.",
+ "applicableScope": [
+ "Dictatorships",
+ "Owner-operated small biz",
+ "Emergencies"
+ ],
+ "consensusLevel": 0,
+ "stepByStepInstructions": "The designated leader analyzes the situation and makes a unilateral decision. They issue a command, and the group executes it without debate.",
+ "objectionsDeadlocks": "There is no formal appeal process. The only recourse for disagreement is to leave the organization ('voting with your feet'). Mass resignation is the only effective check on the leader's power."
+ }
+ },
+ {
+ "id": "hierarchical-decision-making",
+ "label": "Hierarchical Decision-Making",
+ "supportText": "Decisions are made at different levels of an organizational structure, with authority increasing at higher levels.",
+ "sections": {
+ "corePrinciple": "We use a chain of command to manage complexity. Decisions are made at the appropriate level of the organization to ensure efficiency.",
+ "applicableScope": [
+ "Military",
+ "Corporations"
+ ],
+ "stepByStepInstructions": "An employee formulates a proposal and submits it to their manager. The manager reviews it and either approves, denies, or escalates it to the next level of authority if it is above their pay grade.",
+ "objectionsDeadlocks": "If an employee believes their manager is blocking a decision unfairly, they may appeal to the 'skip-level' manager (their boss's boss) or an Ombudsman. This open-door policy serves as the check against bottlenecking."
+ }
+ },
+ {
+ "id": "negotiated-decisions",
+ "label": "Negotiated Decisions",
+ "supportText": "Stakeholders or parties discuss and negotiate until they reach an agreement.",
+ "sections": {
+ "corePrinciple": "We arrive at decisions through compromise. Parties with different interests meet in the middle to find a mutually acceptable agreement.",
+ "applicableScope": [
+ "Treaties",
+ "Partnerships",
+ "Salary"
+ ],
+ "consensusLevel": 100,
+ "stepByStepInstructions": "Each party states their initial position. Through a series of discussions, trade-offs are proposed and accepted. The process concludes when a final agreement is written and signed by all parties.",
+ "objectionsDeadlocks": "If negotiations stall, the parties may agree to bring in a neutral third-party mediator. If mediation fails, the negotiation is declared dead, and the parties return to their pre-negotiation status (BATNA - Best Alternative to a Negotiated Agreement)."
+ }
+ }
+ ]
+}
diff --git a/messages/en/create/customRule/membership.json b/messages/en/create/customRule/membership.json
new file mode 100644
index 0000000..253b2db
--- /dev/null
+++ b/messages/en/create/customRule/membership.json
@@ -0,0 +1,217 @@
+{
+ "_comment": "Create flow — membership / how members join (compact card stack)",
+ "page": {
+ "compactTitle": "How do new members join\nand get connected?",
+ "compactDescriptionBefore": "You can select multiple methods for different needs or ",
+ "compactDescriptionLinkLabel": "add",
+ "compactDescriptionAfter": " your own",
+ "expandedTitle": "How should new members join and get connected?",
+ "expandedDescription": "You can select multiple methods for different needs or add your own",
+ "seeAllLink": "See all membership approaches"
+ },
+ "confirmModal": {
+ "title": "Confirm selection",
+ "description": "Confirm to select this option.",
+ "nextButtonText": "Confirm"
+ },
+ "addPlatform": {
+ "nextButtonText": "Add Platform"
+ },
+ "sectionHeadings": {
+ "eligibility": "Eligibility & Philosophy",
+ "joiningProcess": "Joining Process",
+ "expectations": "Expectations & Removal"
+ },
+ "methods": [
+ {
+ "id": "open-access",
+ "label": "Open Access",
+ "supportText": "Maximum inclusion. Anyone can join immediately by simply showing up.",
+ "sections": {
+ "eligibility": "Membership is open to any individual who lives in [Region/Context] and supports our mission. We believe that gatekeeping creates unnecessary hierarchy, so we prioritize radical inclusivity and low barriers to entry. Our only requirement is a commitment to mutual respect and adherence to the Community Code of Conduct.",
+ "joiningProcess": "There is no formal application or waiting period. A person becomes a member instantly by joining our [Digital Platform/Meeting], introducing themselves to the group, and explicitly agreeing to the community standards. Access to all channels and working groups is granted immediately upon sign-up.",
+ "expectations": "Members are expected to participate constructively. We view removal as a last resort. Barring immediate safety threats, no member will be banned without first going through the steps outlined in our Conflict Management Policy. We prioritize the restorative measures defined in that section over punitive expulsion. However, refusal to engage in that process may result in removal."
+ }
+ },
+ {
+ "id": "orientation-required",
+ "label": "Orientation Required",
+ "supportText": "Newcomers must attend a training or orientation session.",
+ "sections": {
+ "eligibility": "We welcome all new members who are willing to align with our specific values and operational methods. We believe that shared knowledge is power. Requiring education before decision-making prevents confusion and ensures that all members are empowered to participate fully in our governance.",
+ "joiningProcess": "Prospective members enter as \"Observers.\" To become a voting \"Member,\" an individual must attend a mandatory [2-hour] \"New Member Orientation\" session. This session covers our history, decision-making tools, and safety protocols. Upon completion, they sign the membership agreement and are granted full voting rights.",
+ "expectations": "Members must remain active to retain voting status. Inactivity for [6 months] reverts a member to \"Alumni\" status. Regarding behavior, we follow the graduated sanctions described in our Conflict Management section. Membership revocation is the final step in that process and is reserved for cases where mediation and accountability attempts have failed."
+ }
+ },
+ {
+ "id": "invitation-only",
+ "label": "Invitation Only",
+ "supportText": "New members can only join if they are 'vouched for' by existing members.",
+ "sections": {
+ "eligibility": "To ensure the physical and digital safety of our community, we operate on a strict \"web of trust\" model. We prioritize the security of our vulnerable members over rapid expansion. Membership is restricted to those who are known and trusted by the existing network.",
+ "joiningProcess": "An applicant must be \"vouched for\" by at least [2] existing members in good standing. The vouchers must submit a statement confirming they know the applicant in real life and attest to their integrity. The Membership Working Group reviews these requests. The wider group then has a [72-hour] window to raise safety concerns before approval.",
+ "expectations": "Members are responsible for the integrity of those they vouch for. If trust is broken, we do not act unilaterally. Instead we engage the specific accountability mechanisms detailed in our Conflict Management Policy. Removal is a consensus decision made only after those specific mediation steps have been exhausted or rejected by the offending party."
+ }
+ },
+ {
+ "id": "contribution-based",
+ "label": "Contribution Based",
+ "supportText": "Membership is reserved for people contributing their labor.",
+ "sections": {
+ "eligibility": "We practice \"Do-ocracy\" which means the people doing the work make the decisions. Membership is not a static title but a dynamic status earned through contribution. This ensures that our governance reflects the reality of labor and prevents \"armchair\" participation from stalling our progress.",
+ "joiningProcess": "Participants enter as \"Volunteers.\" A Volunteer becomes a voting \"Member\" only after documenting [10 hours] of labor or completing [3 shifts] within a [30-day] period. \"Labor\" is defined broadly to include administrative tasks, emotional support, and physical work to ensure accessibility.",
+ "expectations": "To maintain voting rights, a member must log at least [5 hours] of activity per month. However, high activity does not grant immunity from community standards. Behavioral violations are handled separate from labor tracking by utilizing the workflows defined in the Conflict Management section. A member may be removed for hampering the group's safety regardless of their work output."
+ }
+ },
+ {
+ "id": "mentorship",
+ "label": "Mentorship",
+ "supportText": "New members are paired with 'Mentors' to guide them through a probationary period.",
+ "sections": {
+ "eligibility": "We believe that the best way to integrate new members is through personal guidance. Eligibility is open to those who are willing to learn and build relationships. We value inter-generational knowledge transfer and community cohesion over rapid growth.",
+ "joiningProcess": "Upon applying, a prospective member is assigned a Mentor. They undergo a [3-month] mentorship period involving regular check-ins and shared tasks. After the mentor validates the newcomer's readiness, full membership is granted.",
+ "expectations": "Mentees are expected to be proactive in their learning. Mentors must provide support, not just judgment. If the relationship fails, a new mentor may be assigned. Persistent lack of engagement or violation of values leads to offboarding."
+ }
+ },
+ {
+ "id": "peer-sponsorship",
+ "label": "Peer Sponsorship",
+ "supportText": "New member must be vouched for by an existing member.",
+ "sections": {
+ "eligibility": "Trust is the foundation of our group. We rely on the judgment of our existing members to bring in trustworthy individuals. Eligibility is restricted to those who have a personal connection with at least one current member.",
+ "joiningProcess": "An existing member must formally sponsor the applicant, submitting a statement of support. The sponsorship is reviewed by the membership committee, followed by a [notification period] for the wider group to raise objections before confirmation.",
+ "expectations": "Sponsors are partially accountable for the behavior of those they bring in during the onboarding phase. If a new member violates norms, the sponsor is expected to participate in the conflict resolution process."
+ }
+ },
+ {
+ "id": "consensus-or-vote-based-approval",
+ "label": "Consensus or Vote-Based Approval",
+ "supportText": "Group votes on whether to admit a new member.",
+ "sections": {
+ "eligibility": "We function as a democratic collective where every voice matters. We believe that adding a new member changes the group dynamic, so the group must consent to the addition. Eligibility implies a willingness to abide by collective decisions.",
+ "joiningProcess": "Candidates introduce themselves at a meeting or submit a profile. After a Q&A period, the existing membership votes. Approval requires [Consensus / Supermajority / Simple Majority].",
+ "expectations": "Members must respect the democratic process. Removal follows the same voting threshold as admission, ensuring that exclusion is a collective decision, not an individual one."
+ }
+ },
+ {
+ "id": "trial-period-provisional-membership",
+ "label": "Trial Period / Provisional Membership",
+ "supportText": "New members have a probationary period before full rights.",
+ "sections": {
+ "eligibility": "Commitment is proven through action, not just words. We believe in a 'try before you commit' approach for both the group and the individual. Anyone can start the trial, but full membership is earned.",
+ "joiningProcess": "Newcomers start as 'Provisional Members' with limited rights (e.g., no voting on strategy). After [3 months] or [X contributions], a review is conducted. Successful completion upgrades them to full 'Member' status.",
+ "expectations": "Provisional members must demonstrate activity and cultural alignment. If the trial period expires without meeting criteria, the provisional status is revoked or extended once."
+ }
+ },
+ {
+ "id": "referral-system-with-screening",
+ "label": "Referral System with Screening",
+ "supportText": "Members refer new people, with structured screening.",
+ "sections": {
+ "eligibility": "We combine the trust of referrals with the fairness of objective screening. This ensures we grow through trusted networks while maintaining quality standards and reducing nepotism.",
+ "joiningProcess": "Candidates must be referred by a member. Once referred, they complete a standard screening interview or questionnaire with the Membership Team to ensure they meet the baseline requirements independently of their referrer.",
+ "expectations": "Referred members are treated as individuals, not extensions of their referrer. They are subject to the standard Code of Conduct and removal processes."
+ }
+ },
+ {
+ "id": "membership-agreement-or-pledge",
+ "label": "Membership Agreement or Pledge",
+ "supportText": "New members must agree to specific values, responsibilities, or rules.",
+ "sections": {
+ "eligibility": "Our unity comes from shared agreement. Eligibility is based on the willingness to explicitly commit to our [Manifesto/Constitution/Code of Conduct]. We prioritize alignment of values over specific skills.",
+ "joiningProcess": "Prospective members review the Membership Agreement. They must sign (digitally or physically) or publicly recite the pledge. Membership is effective immediately upon this affirmation.",
+ "expectations": "The Agreement acts as the contract for behavior. Violation of the specific terms agreed to is grounds for immediate review and potential removal."
+ }
+ },
+ {
+ "id": "weighted-or-tiered-membership",
+ "label": "Weighted or Tiered Membership",
+ "supportText": "Different levels of membership with increasing rights over time.",
+ "sections": {
+ "eligibility": "We recognize that commitment levels vary. By offering tiers (e.g., Supporter, Contributor, Core), we allow people to engage at their capacity. Higher tiers require deeper accountability and grant more governance power.",
+ "joiningProcess": "Everyone joins at the 'Basic' tier. Moving to higher tiers requires meeting specific criteria (e.g., hours worked, dues paid, tenure) and applying for an upgrade, which is verified by the Governance Circle.",
+ "expectations": "Members must maintain the requirements of their tier. Falling below requirements results in a downgrade to a lower tier rather than removal from the group."
+ }
+ },
+ {
+ "id": "hybrid-approval-process",
+ "label": "Hybrid Approval Process",
+ "supportText": "Combines multiple approval steps like voting, screening, and referrals.",
+ "sections": {
+ "eligibility": "We require a rigorous vetting process to ensure high alignment. By combining methods, we filter for competence, culture, and trust. Eligibility is for those dedicated enough to navigate a multi-step process.",
+ "joiningProcess": "Step 1: Application/Referral. Step 2: Screening Interview. Step 3: Trial Task or Period. Step 4: Final Ratification Vote by the core team.",
+ "expectations": "Due to the high barrier to entry, members are expected to be highly active leaders. Removal is a formal process involving the same bodies that approved the member."
+ }
+ },
+ {
+ "id": "skill-based-contribution",
+ "label": "Skill-Based Contribution",
+ "supportText": "Joining by submitting work, such as a pull request.",
+ "sections": {
+ "eligibility": "Code (or work) speaks louder than words. We operate as a meritocracy where the ability to contribute is the only barrier to entry. Anyone capable of doing the work is eligible.",
+ "joiningProcess": "A contributor picks up a 'Good First Issue' or task. Once their contribution (e.g., Pull Request) is reviewed and merged/accepted by a Maintainer, they are invited to the organization roster.",
+ "expectations": "Continued membership depends on active contribution. Members who go dormant for [6 months] may be moved to 'Alumni'. Malicious contributions result in an immediate ban."
+ }
+ },
+ {
+ "id": "pay-to-join",
+ "label": "Pay-to-Join",
+ "supportText": "Requires financial contribution or dues.",
+ "sections": {
+ "eligibility": "We value financial sustainability and commitment. Paying dues demonstrates a tangible investment in the group's longevity. Eligibility is open to anyone willing and able to support the organization financially.",
+ "joiningProcess": "The prospective member selects a membership tier and sets up a payment method. Upon successful transaction, access to member-only spaces is automatically granted.",
+ "expectations": "Membership is contingent on active payment. Failure to pay dues results in a grace period followed by a lapse in membership status/access."
+ }
+ },
+ {
+ "id": "application-review",
+ "label": "Application & Review",
+ "supportText": "Application reviewed by an onboarding team.",
+ "sections": {
+ "eligibility": "We seek specific qualities to balance our team. A formal application allows us to assess skills, experience, and diversity needs objectively. Eligibility is open, but selection is competitive.",
+ "joiningProcess": "Candidates submit a detailed written application. The Membership Committee scores applications against a rubric. Selected candidates are notified and onboarded.",
+ "expectations": "Members obtained through this process are expected to fulfill the roles they applied for. Significant deviation from the stated intent in the application may trigger a review."
+ }
+ },
+ {
+ "id": "identity-verification",
+ "label": "Identity Verification",
+ "supportText": "Requires verification of identity, credentials, or background.",
+ "sections": {
+ "eligibility": "Security and accountability are paramount. We require Sybil resistance (one person, one account). Eligibility is restricted to real, unique humans who can prove their identity.",
+ "joiningProcess": "User submits proof of identity (ID, social media link, or cryptographic proof like Proof of Humanity). A verifier or automated system confirms the identity. Access is granted upon verification.",
+ "expectations": "Members must maintain a single identity. Validated bad actors are permanently blacklisted. Privacy is respected, but accountability is enforced."
+ }
+ },
+ {
+ "id": "collective-interviews",
+ "label": "Collective Interviews",
+ "supportText": "Group collectively interviews new members for cultural fit.",
+ "sections": {
+ "eligibility": "Culture is co-created. We believe the existing community should interact with potential members to ensure resonance. Eligibility requires the social skills to engage with the group.",
+ "joiningProcess": "Applicants attend a 'Ask Me Anything' or group interview session. After the session, the group debriefs and decides on admission based on the interaction.",
+ "expectations": "Members are expected to maintain the cultural vibe established in the interview. 'Vibe checks' may continue, and dissonance can lead to conflict resolution."
+ }
+ },
+ {
+ "id": "skill-based-evaluation",
+ "label": "Skill-Based Evaluation",
+ "supportText": "Requires passing a skills assessment or portfolio review.",
+ "sections": {
+ "eligibility": "We are a guild of professionals. Quality assurance is our priority. Eligibility is limited to those who can demonstrate a specific level of proficiency in our domain.",
+ "joiningProcess": "Candidate submits a portfolio or takes a standardized test. Experts within the group review the submission. A passing score grants membership.",
+ "expectations": "Members must maintain professional standards. Plagiarism or gross negligence in work leads to revocation of credentials/membership."
+ }
+ },
+ {
+ "id": "lottery-sortition",
+ "label": "Lottery / Sortition",
+ "supportText": "Members are selected randomly from a pool of eligible applicants.",
+ "sections": {
+ "eligibility": "We believe in fairness and equality. By removing human bias from selection, we ensure a representative sample of the community and prevent power consolidation. Eligibility is open to all who meet basic criteria.",
+ "joiningProcess": "Interested individuals submit their name to the pool. At regular intervals, a random selection (using a cryptographic or transparent physical randomizer) occurs. Selected individuals are invited to join.",
+ "expectations": "Selected members are expected to serve their term faithfully. Removal is rare and reserved for gross misconduct, as the legitimacy of the system relies on the randomness of the selection."
+ }
+ }
+ ]
+}
diff --git a/messages/en/create/footer.json b/messages/en/create/footer.json
index 24d4b23..bb9573d 100644
--- a/messages/en/create/footer.json
+++ b/messages/en/create/footer.json
@@ -14,6 +14,6 @@
"confirmCoreValues": "Confirm values",
"confirmCommunication": "Confirm",
"confirmMembership": "Confirm",
- "confirmRightRail": "Confirm",
+ "confirmDecisionApproaches": "Confirm",
"confirmConflictManagement": "Confirm"
}
diff --git a/messages/en/create/membership.json b/messages/en/create/membership.json
deleted file mode 100644
index a7337c4..0000000
--- a/messages/en/create/membership.json
+++ /dev/null
@@ -1,133 +0,0 @@
-{
- "_comment": "Create flow — membership / how members join (compact card stack)",
- "page": {
- "compactTitle": "How do new members join\nand get connected?",
- "compactDescriptionBefore": "You can select multiple methods for different needs or ",
- "compactDescriptionLinkLabel": "add",
- "compactDescriptionAfter": " your own",
- "expandedTitle": "How should new members join and get connected?",
- "expandedDescription": "You can select multiple methods for different needs or add your own",
- "seeAllLink": "See all membership approaches"
- },
- "confirmModal": {
- "title": "Confirm selection",
- "description": "Confirm to select this option.",
- "nextButtonText": "Confirm"
- },
- "addPlatform": {
- "nextButtonText": "Add Platform"
- },
- "sectionHeadings": {
- "eligibility": "Eligibility & Philosophy",
- "joiningProcess": "Joining Process",
- "expectations": "Expectations & Removal"
- },
- "cards": {
- "open-access": {
- "label": "Open Access",
- "supportText": "Maximum inclusion. Anyone can join immediately by simply showing up."
- },
- "orientation-required": {
- "label": "Orientation Required",
- "supportText": "Newcomers must attend a training or orientation session."
- },
- "invitation-only": {
- "label": "Invitation Only",
- "supportText": "New members can only join if they are 'vouched for' by existing members."
- },
- "contribution-based": {
- "label": "Contribution Based",
- "supportText": "Membership is reserved for people contributing their labor."
- },
- "mentorship": {
- "label": "Mentorship",
- "supportText": "New members are paired with 'Mentors' to guide them through a probationary period."
- },
- "6": {
- "label": "Label",
- "supportText": "Additional membership approach."
- },
- "7": {
- "label": "Label",
- "supportText": "Additional membership approach."
- },
- "8": {
- "label": "Label",
- "supportText": "Additional membership approach."
- }
- },
- "modals": {
- "open-access": {
- "title": "Open Access",
- "description": "Maximum inclusion. Anyone can join immediately by simply showing up.",
- "sections": {
- "eligibility": "Membership is open to any individual who lives in [Region/Context] and supports our mission. We believe that gatekeeping creates unnecessary hierarchy, so we prioritize radical inclusivity and low barriers to entry. Our only requirement is a commitment to mutual respect and adherence to the Community Code of Conduct.",
- "joiningProcess": "There is no formal application or waiting period. A person becomes a member instantly by joining our [Digital Platform/Meeting], introducing themselves to the group, and explicitly agreeing to the community standards. Access to all channels and working groups is granted immediately upon sign-up.",
- "expectations": "Members are expected to participate constructively. We view removal as a last resort. Barring immediate safety threats, no member will be banned without first going through the steps outlined in our Conflict Management Policy. We prioritize the restorative measures defined in that section over punitive expulsion. However, refusal to engage in that process may result in removal."
- }
- },
- "orientation-required": {
- "title": "Orientation Required",
- "description": "Newcomers must attend a training or orientation session.",
- "sections": {
- "eligibility": "",
- "joiningProcess": "",
- "expectations": ""
- }
- },
- "invitation-only": {
- "title": "Invitation Only",
- "description": "New members can only join if they are 'vouched for' by existing members.",
- "sections": {
- "eligibility": "",
- "joiningProcess": "",
- "expectations": ""
- }
- },
- "contribution-based": {
- "title": "Contribution Based",
- "description": "Membership is reserved for people contributing their labor.",
- "sections": {
- "eligibility": "",
- "joiningProcess": "",
- "expectations": ""
- }
- },
- "mentorship": {
- "title": "Mentorship",
- "description": "New members are paired with 'Mentors' to guide them through a probationary period.",
- "sections": {
- "eligibility": "",
- "joiningProcess": "",
- "expectations": ""
- }
- },
- "6": {
- "title": "Label",
- "description": "Additional membership approach.",
- "sections": {
- "eligibility": "",
- "joiningProcess": "",
- "expectations": ""
- }
- },
- "7": {
- "title": "Label",
- "description": "Additional membership approach.",
- "sections": {
- "eligibility": "",
- "joiningProcess": "",
- "expectations": ""
- }
- },
- "8": {
- "title": "Label",
- "description": "Additional membership approach.",
- "sections": {
- "eligibility": "",
- "joiningProcess": "",
- "expectations": ""
- }
- }
- }
-}
diff --git a/messages/en/create/completed.json b/messages/en/create/reviewAndComplete/completed.json
similarity index 100%
rename from messages/en/create/completed.json
rename to messages/en/create/reviewAndComplete/completed.json
diff --git a/messages/en/create/confirmStakeholders.json b/messages/en/create/reviewAndComplete/confirmStakeholders.json
similarity index 100%
rename from messages/en/create/confirmStakeholders.json
rename to messages/en/create/reviewAndComplete/confirmStakeholders.json
diff --git a/messages/en/create/finalReview.json b/messages/en/create/reviewAndComplete/finalReview.json
similarity index 100%
rename from messages/en/create/finalReview.json
rename to messages/en/create/reviewAndComplete/finalReview.json
diff --git a/messages/en/create/publish.json b/messages/en/create/reviewAndComplete/publish.json
similarity index 100%
rename from messages/en/create/publish.json
rename to messages/en/create/reviewAndComplete/publish.json
diff --git a/messages/en/create/rightRail.json b/messages/en/create/rightRail.json
deleted file mode 100644
index 4f53ce5..0000000
--- a/messages/en/create/rightRail.json
+++ /dev/null
@@ -1,147 +0,0 @@
-{
- "_comment": "Create flow — right rail / decision approaches (Figma Flow — Right Rail `20523:23509`)",
- "sidebar": {
- "title": "How should this community make difficult decisions?",
- "descriptionBefore": "Select as many as you need to describe how your group makes decisions. You can also ",
- "descriptionLinkLabel": "add",
- "descriptionAfter": " new decision making approaches or interact with the categories below to filter."
- },
- "messageBox": {
- "title": "Consider defining approaches to steward key resources:",
- "items": [
- { "id": "amend", "label": "Amend your CommunityRule" },
- { "id": "finances", "label": "Steward finances" },
- { "id": "project", "label": "Project level decisions" },
- { "id": "discipline", "label": "Discipline and member termination" }
- ]
- },
- "cardStack": {
- "toggleSeeAll": "See all decision approaches",
- "toggleShowLess": "Show less"
- },
- "confirmModal": {
- "title": "Confirm selection",
- "description": "Confirm to select this option.",
- "nextButtonText": "Confirm"
- },
- "addApproach": {
- "nextButtonText": "Add Approach"
- },
- "sectionHeadings": {
- "corePrinciple": "Core Principle",
- "applicableScope": "Applicable Scope",
- "stepByStepInstructions": "Step-by-Step Instructions",
- "consensusLevel": "Consensus Level",
- "objectionsDeadlocks": "Objections & Deadlocks"
- },
- "scopeAddButtonLabel": "Add Applicable Scope",
- "modals": {
- "lazy-consensus": {
- "title": "Lazy Consensus",
- "description": "A decision is assumed approved unless objections are raised within a specified timeframe.",
- "sections": {
- "corePrinciple": "We prioritize momentum and trust over bureaucracy. By assuming good faith, we avoid bottlenecks; silence is interpreted as consent to keep the work moving.",
- "applicableScope": [
- "Daily Operations",
- "Minor Expenditures",
- "Working Group Decisions"
- ],
- "stepByStepInstructions": "Post your proposal to the relevant channel with a specific deadline, such as 'Merging in 72 hours.' If the deadline passes without any objections, you are authorized to proceed. Explicit support is welcome but not required.",
- "consensusLevel": 90,
- "objectionsDeadlocks": "Any member can pause the process by raising a 'Block' or 'Concern' before the deadline. The proposer is then required to pause execution and engage in a dialogue to resolve the concern. If the disagreement cannot be resolved asynchronously, the proposal is escalated to a synchronous meeting or a higher governance tier for a final decision."
- }
- }
- },
- "cards": [
- {
- "id": "lazy-consensus",
- "label": "Lazy Consensus",
- "supportText": "A decision is assumed approved unless objections are raised within a specified timeframe.",
- "recommended": true
- },
- {
- "id": "do-ocracy",
- "label": "Do-ocracy",
- "supportText": "Decisions are made by those who take initiative and carry out the work.",
- "recommended": true
- },
- {
- "id": "consensus-decision-making",
- "label": "Consensus Decision-Making",
- "supportText": "All members must agree. Best for important decisions in small groups. Does not work well for low stakes decisions.",
- "recommended": true
- },
- {
- "id": "rotational-leadership",
- "label": "Rotational Leadership",
- "supportText": "Decision-making responsibilities rotate among members.",
- "recommended": true
- },
- {
- "id": "modified-consensus",
- "label": "Modified Consensus",
- "supportText": "Attempts to reach full agreement first, but falls back to voting if consensus isn’t possible.",
- "recommended": true
- },
- {
- "id": "label-1",
- "label": "Label",
- "supportText": "Additional decision approach.",
- "recommended": false
- },
- {
- "id": "label-2",
- "label": "Label",
- "supportText": "Additional decision approach.",
- "recommended": false
- },
- {
- "id": "label-3",
- "label": "Label",
- "supportText": "Additional decision approach.",
- "recommended": false
- },
- {
- "id": "label-4",
- "label": "Label",
- "supportText": "Additional decision approach.",
- "recommended": false
- },
- {
- "id": "label-5",
- "label": "Label",
- "supportText": "Additional decision approach.",
- "recommended": false
- },
- {
- "id": "label-6",
- "label": "Label",
- "supportText": "Additional decision approach.",
- "recommended": false
- },
- {
- "id": "label-7",
- "label": "Label",
- "supportText": "Additional decision approach.",
- "recommended": false
- },
- {
- "id": "label-8",
- "label": "Label",
- "supportText": "Additional decision approach.",
- "recommended": false
- },
- {
- "id": "label-9",
- "label": "Label",
- "supportText": "Additional decision approach.",
- "recommended": false
- },
- {
- "id": "label-10",
- "label": "Label",
- "supportText": "Additional decision approach.",
- "recommended": false
- }
- ]
-}
diff --git a/messages/en/index.ts b/messages/en/index.ts
index 32d362d..2a44d79 100644
--- a/messages/en/index.ts
+++ b/messages/en/index.ts
@@ -18,26 +18,34 @@ import login from "./pages/login.json";
import profile from "./pages/profile.json";
import navigation from "./navigation.json";
import metadata from "./metadata.json";
-import communication from "./create/communication.json";
-import createMembership from "./create/membership.json";
-import createConflictManagement from "./create/conflictManagement.json";
-import createInformational from "./create/informational.json";
-import createCommunityName from "./create/communityName.json";
-import createCommunitySize from "./create/communitySize.json";
-import createCommunityContext from "./create/communityContext.json";
-import createCommunityStructure from "./create/communityStructure.json";
-import createCommunityUpload from "./create/communityUpload.json";
-import createCommunitySave from "./create/communitySave.json";
-import createReview from "./create/review.json";
-import createCoreValues from "./create/coreValues.json";
-import createConfirmStakeholders from "./create/confirmStakeholders.json";
-import createFinalReview from "./create/finalReview.json";
-import createCompleted from "./create/completed.json";
-import createRightRail from "./create/rightRail.json";
+
+// create – stage 1: community
+import createInformational from "./create/community/informational.json";
+import createCommunityName from "./create/community/communityName.json";
+import createCommunityStructure from "./create/community/communityStructure.json";
+import createCommunityContext from "./create/community/communityContext.json";
+import createCommunitySize from "./create/community/communitySize.json";
+import createCommunityUpload from "./create/community/communityUpload.json";
+import createCommunitySave from "./create/community/communitySave.json";
+import createReview from "./create/community/review.json";
+
+// create – stage 2: customRule
+import createCoreValues from "./create/customRule/coreValues.json";
+import createCommunication from "./create/customRule/communication.json";
+import createMembership from "./create/customRule/membership.json";
+import createDecisionApproaches from "./create/customRule/decisionApproaches.json";
+import createConflictManagement from "./create/customRule/conflictManagement.json";
+
+// create – stage 3: reviewAndComplete
+import createConfirmStakeholders from "./create/reviewAndComplete/confirmStakeholders.json";
+import createFinalReview from "./create/reviewAndComplete/finalReview.json";
+import createCompleted from "./create/reviewAndComplete/completed.json";
+import createPublish from "./create/reviewAndComplete/publish.json";
+
+// create – cross-cutting (chrome + layout-shell strings)
import createFooter from "./create/footer.json";
import createTopNav from "./create/topNav.json";
import createDraftHydration from "./create/draftHydration.json";
-import createPublish from "./create/publish.json";
import createTemplateReview from "./create/templateReview.json";
export default {
@@ -62,26 +70,32 @@ export default {
profile,
},
create: {
- communication,
- membership: createMembership,
- conflictManagement: createConflictManagement,
- informational: createInformational,
- communityName: createCommunityName,
- communitySize: createCommunitySize,
- communityContext: createCommunityContext,
- communityStructure: createCommunityStructure,
- communityUpload: createCommunityUpload,
- communitySave: createCommunitySave,
- review: createReview,
- coreValues: createCoreValues,
- confirmStakeholders: createConfirmStakeholders,
- finalReview: createFinalReview,
- completed: createCompleted,
- rightRail: createRightRail,
+ community: {
+ informational: createInformational,
+ communityName: createCommunityName,
+ communityStructure: createCommunityStructure,
+ communityContext: createCommunityContext,
+ communitySize: createCommunitySize,
+ communityUpload: createCommunityUpload,
+ communitySave: createCommunitySave,
+ review: createReview,
+ },
+ customRule: {
+ coreValues: createCoreValues,
+ communication: createCommunication,
+ membership: createMembership,
+ decisionApproaches: createDecisionApproaches,
+ conflictManagement: createConflictManagement,
+ },
+ reviewAndComplete: {
+ confirmStakeholders: createConfirmStakeholders,
+ finalReview: createFinalReview,
+ completed: createCompleted,
+ publish: createPublish,
+ },
footer: createFooter,
topNav: createTopNav,
draftHydration: createDraftHydration,
- publish: createPublish,
templateReview: createTemplateReview,
},
navigation,
diff --git a/prisma/migrations/20260418170000_add_method_facet/migration.sql b/prisma/migrations/20260418170000_add_method_facet/migration.sql
new file mode 100644
index 0000000..451bc81
--- /dev/null
+++ b/prisma/migrations/20260418170000_add_method_facet/migration.sql
@@ -0,0 +1,21 @@
+-- CreateTable
+CREATE TABLE "MethodFacet" (
+ "id" TEXT NOT NULL,
+ "section" TEXT NOT NULL,
+ "slug" TEXT NOT NULL,
+ "group" TEXT NOT NULL,
+ "value" TEXT NOT NULL,
+ "matches" BOOLEAN NOT NULL,
+ "weight" DOUBLE PRECISION,
+
+ CONSTRAINT "MethodFacet_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "MethodFacet_section_slug_group_value_key" ON "MethodFacet"("section", "slug", "group", "value");
+
+-- CreateIndex
+CREATE INDEX "MethodFacet_section_idx" ON "MethodFacet"("section");
+
+-- CreateIndex
+CREATE INDEX "MethodFacet_group_value_matches_idx" ON "MethodFacet"("group", "value", "matches");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 10cf8f8..602a80b 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -71,3 +71,27 @@ model RuleTemplate {
sortOrder Int @default(0)
featured Boolean @default(false)
}
+
+/// Recommendation matrix (CR-88).
+/// JSON in `data/create/customRule/.json` is canonical; this table is
+/// rebuilt from those files at `prisma db seed` time so the API can join.
+/// See `docs/guides/template-recommendation-matrix.md` §7.
+model MethodFacet {
+ id String @id @default(cuid())
+ /// One of "communication" | "membership" | "decisionApproaches" | "conflictManagement".
+ section String
+ /// Matches the `id` of an entry in `messages/en/create/customRule/.json#/methods`.
+ slug String
+ /// One of "size" | "orgType" | "scale" | "maturity".
+ group String
+ /// Canonical facet value id, e.g. "workersCoop", "earlyStage".
+ value String
+ /// `true` iff the JSON marks this method as matching the facet (`✓` cell).
+ matches Boolean
+ /// Optional per-cell weight; reserved for a future weighted-rank pass (v1 ignores).
+ weight Float?
+
+ @@unique([section, slug, group, value])
+ @@index([section])
+ @@index([group, value, matches])
+}
diff --git a/prisma/seed.ts b/prisma/seed.ts
index 1986c42..e5936fe 100644
--- a/prisma/seed.ts
+++ b/prisma/seed.ts
@@ -1,4 +1,5 @@
import { PrismaClient, type Prisma } from "@prisma/client";
+import { seedMethodFacets } from "./seed/methodFacets";
/**
* Curated rule templates for GET /api/templates.
@@ -387,6 +388,14 @@ async function main() {
},
});
}
+
+ const facetSeed = await seedMethodFacets(prisma);
+ // eslint-disable-next-line no-console -- seed CLI feedback
+ console.log(
+ `Seeded MethodFacet rows: ${Object.entries(facetSeed.rowsBySection)
+ .map(([section, count]) => `${section}=${count}`)
+ .join(", ")}`,
+ );
}
main()
diff --git a/prisma/seed/methodFacets.ts b/prisma/seed/methodFacets.ts
new file mode 100644
index 0000000..e6056a7
--- /dev/null
+++ b/prisma/seed/methodFacets.ts
@@ -0,0 +1,117 @@
+import { readFile } from "node:fs/promises";
+import path from "node:path";
+import type { PrismaClient } from "@prisma/client";
+import {
+ FACET_GROUP_IDS,
+ FACET_VALUE_IDS_BY_GROUP,
+ SECTION_IDS,
+ type SectionId,
+ facetGroupsFileSchema,
+ resolveFacetMatch,
+ sectionFacetsSchema,
+} from "../../lib/server/validation/methodFacetsSchemas";
+
+const REPO_ROOT = path.resolve(__dirname, "..", "..");
+const DATA_DIR = path.join(REPO_ROOT, "data", "create", "customRule");
+
+/**
+ * Reads + Zod-validates `data/create/customRule/.json`.
+ * Throws on schema failures so the seed aborts before any DB write.
+ */
+async function loadSectionFacets(section: SectionId) {
+ const file = path.join(DATA_DIR, `${section}.json`);
+ const raw = await readFile(file, "utf8");
+ const parsed = JSON.parse(raw) as unknown;
+ const result = sectionFacetsSchema.safeParse(parsed);
+ if (!result.success) {
+ throw new Error(
+ `Invalid facet file ${file}: ${JSON.stringify(result.error.flatten(), null, 2)}`,
+ );
+ }
+ return result.data;
+}
+
+async function loadFacetGroups() {
+ const file = path.join(DATA_DIR, "_facetGroups.json");
+ const raw = await readFile(file, "utf8");
+ const parsed = JSON.parse(raw) as unknown;
+ const result = facetGroupsFileSchema.safeParse(parsed);
+ if (!result.success) {
+ throw new Error(
+ `Invalid facet groups file ${file}: ${JSON.stringify(result.error.flatten(), null, 2)}`,
+ );
+ }
+ return result.data;
+}
+
+type MethodFacetRow = {
+ section: string;
+ slug: string;
+ group: string;
+ value: string;
+ matches: boolean;
+ weight: number | null;
+};
+
+/**
+ * Flattens `{ size: { oneMember: true, ... }, orgType: { ... } }` per slug
+ * into one row per `(section, slug, group, value)`. Omitted groups/values
+ * default to `false` so the table density is constant.
+ */
+function flattenSectionFacets(
+ section: SectionId,
+ facets: Awaited>,
+): MethodFacetRow[] {
+ const rows: MethodFacetRow[] = [];
+ for (const [slug, perMethod] of Object.entries(facets)) {
+ for (const group of FACET_GROUP_IDS) {
+ const groupValues = perMethod[group];
+ for (const value of FACET_VALUE_IDS_BY_GROUP[group]) {
+ const cell = groupValues?.[value as keyof typeof groupValues];
+ const { match, weight } = resolveFacetMatch(cell);
+ rows.push({
+ section,
+ slug,
+ group,
+ value,
+ matches: match,
+ weight,
+ });
+ }
+ }
+ }
+ return rows;
+}
+
+/**
+ * Validates and re-seeds the `MethodFacet` table from the JSON files.
+ * Per-section atomic swap so the table is never partially populated.
+ *
+ * `_facetGroups.json` is validated for schema correctness but not stored —
+ * its only runtime purpose is the chip-id ↔ canonical-id lookup, which is
+ * read directly from the JSON by the wizard ranker.
+ */
+export async function seedMethodFacets(prisma: PrismaClient): Promise<{
+ rowsBySection: Record;
+}> {
+ await loadFacetGroups();
+
+ const rowsBySection: Record = {
+ communication: 0,
+ membership: 0,
+ decisionApproaches: 0,
+ conflictManagement: 0,
+ };
+
+ for (const section of SECTION_IDS) {
+ const facets = await loadSectionFacets(section);
+ const rows = flattenSectionFacets(section, facets);
+ rowsBySection[section] = rows.length;
+ await prisma.$transaction([
+ prisma.methodFacet.deleteMany({ where: { section } }),
+ prisma.methodFacet.createMany({ data: rows }),
+ ]);
+ }
+
+ return { rowsBySection };
+}
diff --git a/stories/pages/TextPage.stories.js b/stories/pages/TextPage.stories.js
index b6ef156..2ef2eef 100644
--- a/stories/pages/TextPage.stories.js
+++ b/stories/pages/TextPage.stories.js
@@ -8,7 +8,7 @@ export default {
export const Default = {
args: {
- messageNamespace: "create.communityName",
+ messageNamespace: "create.community.communityName",
stateField: "title",
maxLength: 48,
},
diff --git a/tests/components/TextPage.test.tsx b/tests/components/TextPage.test.tsx
index 6722f9e..2c5cc21 100644
--- a/tests/components/TextPage.test.tsx
+++ b/tests/components/TextPage.test.tsx
@@ -7,7 +7,7 @@ describe("CreateFlowTextFieldScreen (community name)", () => {
it("renders main heading", () => {
render(
,
@@ -22,7 +22,7 @@ describe("CreateFlowTextFieldScreen (community name)", () => {
it("renders description and text field", () => {
render(
,
diff --git a/tests/pages/decision-approaches.test.jsx b/tests/pages/decision-approaches.test.jsx
index 672f161..48ae06c 100644
--- a/tests/pages/decision-approaches.test.jsx
+++ b/tests/pages/decision-approaches.test.jsx
@@ -106,17 +106,22 @@ describe("Create flow decision-approaches page", () => {
).toBeInTheDocument();
});
- test("expanded view shows Label cards", async () => {
+ test("expanded view reveals additional non-recommended approaches", async () => {
const user = userEvent.setup();
render( );
+ expect(
+ screen.queryByRole("button", { name: /^Sociocracy:/ }),
+ ).not.toBeInTheDocument();
+
const toggle = screen.getByRole("button", {
name: "See all decision approaches",
});
await user.click(toggle);
- const labelButtons = screen.getAllByRole("button", { name: /^Label/ });
- expect(labelButtons.length).toBeGreaterThanOrEqual(1);
+ expect(
+ screen.getByRole("button", { name: /^Sociocracy:/ }),
+ ).toBeInTheDocument();
});
test("clicking a card opens the create modal and confirming selects it", async () => {
diff --git a/tests/unit/createFlowMethodsRoute.test.ts b/tests/unit/createFlowMethodsRoute.test.ts
new file mode 100644
index 0000000..a249353
--- /dev/null
+++ b/tests/unit/createFlowMethodsRoute.test.ts
@@ -0,0 +1,69 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const findManyMock = vi.fn();
+
+vi.mock("../../lib/server/env", () => ({
+ isDatabaseConfigured: () => true,
+}));
+
+vi.mock("../../lib/server/db", () => ({
+ prisma: {
+ methodFacet: {
+ findMany: (...args: unknown[]) => findManyMock(...args),
+ },
+ },
+}));
+
+import { GET } from "../../app/api/create-flow/methods/route";
+
+function makeReq(url: string) {
+ return { nextUrl: new URL(url) } as unknown as Parameters[0];
+}
+
+beforeEach(() => {
+ findManyMock.mockReset();
+});
+
+describe("GET /api/create-flow/methods", () => {
+ it("400s on missing or unknown section", async () => {
+ const r1 = await GET(makeReq("https://x.test/api/create-flow/methods"));
+ expect(r1.status).toBe(400);
+ const r2 = await GET(
+ makeReq("https://x.test/api/create-flow/methods?section=foo"),
+ );
+ expect(r2.status).toBe(400);
+ });
+
+ it("returns ranked methods from the facet query", async () => {
+ findManyMock.mockResolvedValueOnce([
+ { slug: "loomio", group: "size", value: "twoToFive" },
+ { slug: "loomio", group: "orgType", value: "workersCoop" },
+ { slug: "in-person", group: "size", value: "twoToFive" },
+ ]);
+ const res = await GET(
+ makeReq(
+ "https://x.test/api/create-flow/methods?section=communication&facet.size=twoToFive&facet.orgType=workersCoop",
+ ),
+ );
+ expect(res.status).toBe(200);
+ const json = (await res.json()) as {
+ section: string;
+ methods: { slug: string; matches: { score: number } }[];
+ };
+ expect(json.section).toBe("communication");
+ expect(json.methods.map((m) => m.slug)).toEqual(["loomio", "in-person"]);
+ expect(json.methods[0].matches.score).toBe(2);
+ });
+
+ it("returns empty methods when the DB query throws (caller falls back)", async () => {
+ findManyMock.mockRejectedValueOnce(new Error("db down"));
+ const res = await GET(
+ makeReq(
+ "https://x.test/api/create-flow/methods?section=communication&facet.size=oneMember",
+ ),
+ );
+ expect(res.status).toBe(200);
+ const json = (await res.json()) as { methods: unknown[] };
+ expect(json.methods).toEqual([]);
+ });
+});
diff --git a/tests/unit/deriveCompactCards.test.ts b/tests/unit/deriveCompactCards.test.ts
new file mode 100644
index 0000000..e8a3ea4
--- /dev/null
+++ b/tests/unit/deriveCompactCards.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, it } from "vitest";
+import { deriveCompactCards } from "../../app/(app)/create/hooks/useFacetRecommendations";
+
+const methods = [
+ { id: "alpha" },
+ { id: "bravo" },
+ { id: "charlie" },
+ { id: "delta" },
+ { id: "echo" },
+ { id: "foxtrot" },
+ { id: "golf" },
+] as const;
+
+describe("deriveCompactCards", () => {
+ it("falls back to authoring order with no badges when no facets selected", () => {
+ const result = deriveCompactCards(methods, {}, false, 5);
+ expect(result.compactCardIds).toEqual([
+ "alpha",
+ "bravo",
+ "charlie",
+ "delta",
+ "echo",
+ ]);
+ expect(result.recommendedIds.size).toBe(0);
+ });
+
+ it("falls back to authoring order with no badges when facets selected but every score is zero", () => {
+ const result = deriveCompactCards(methods, {}, true, 5);
+ expect(result.compactCardIds).toEqual([
+ "alpha",
+ "bravo",
+ "charlie",
+ "delta",
+ "echo",
+ ]);
+ expect(result.recommendedIds.size).toBe(0);
+ });
+
+ it("shows only recommended (matched) cards when fewer than the limit match", () => {
+ const result = deriveCompactCards(
+ methods,
+ { bravo: 2, delta: 1 },
+ true,
+ 5,
+ );
+ // Caller is responsible for pre-ranking by score (rankMethodsByScore).
+ // This test passes already-ranked input; the hook just respects ordering
+ // and tags only the matched subset — no padding with unrecommended cards.
+ expect(result.compactCardIds).toEqual(["bravo", "delta"]);
+ expect([...result.recommendedIds].sort()).toEqual(["bravo", "delta"]);
+ });
+
+ it("caps recommended cards at the limit when more than `limit` match", () => {
+ const scores = {
+ alpha: 4,
+ bravo: 3,
+ charlie: 3,
+ delta: 2,
+ echo: 1,
+ foxtrot: 1,
+ golf: 1,
+ };
+ const result = deriveCompactCards(methods, scores, true, 5);
+ expect(result.compactCardIds).toEqual([
+ "alpha",
+ "bravo",
+ "charlie",
+ "delta",
+ "echo",
+ ]);
+ expect(result.recommendedIds.size).toBe(5);
+ expect([...result.recommendedIds].sort()).toEqual([
+ "alpha",
+ "bravo",
+ "charlie",
+ "delta",
+ "echo",
+ ]);
+ });
+
+ it("returns a single card when only one method matches", () => {
+ const result = deriveCompactCards(methods, { charlie: 4 }, true, 5);
+ expect(result.compactCardIds).toEqual(["charlie"]);
+ expect([...result.recommendedIds]).toEqual(["charlie"]);
+ });
+
+ it("respects a smaller `limit` even when many methods match", () => {
+ const scores = { alpha: 4, bravo: 3, charlie: 3, delta: 2 };
+ const result = deriveCompactCards(methods, scores, true, 3);
+ expect(result.compactCardIds).toEqual(["alpha", "bravo", "charlie"]);
+ expect(result.recommendedIds.size).toBe(3);
+ });
+});
diff --git a/tests/unit/methodFacets.test.ts b/tests/unit/methodFacets.test.ts
new file mode 100644
index 0000000..81ef801
--- /dev/null
+++ b/tests/unit/methodFacets.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from "vitest";
+import { readFileSync } from "node:fs";
+import path from "node:path";
+import {
+ FACET_GROUP_IDS,
+ FACET_VALUE_IDS_BY_GROUP,
+ SECTION_IDS,
+ type SectionId,
+ facetGroupsFileSchema,
+ sectionFacetsSchema,
+} from "../../lib/server/validation/methodFacetsSchemas";
+
+const REPO_ROOT = path.resolve(__dirname, "..", "..");
+
+const SECTION_TO_MESSAGES_FILE: Record = {
+ communication: "messages/en/create/customRule/communication.json",
+ membership: "messages/en/create/customRule/membership.json",
+ decisionApproaches: "messages/en/create/customRule/decisionApproaches.json",
+ conflictManagement: "messages/en/create/customRule/conflictManagement.json",
+};
+
+function readJson(rel: string): T {
+ return JSON.parse(readFileSync(path.join(REPO_ROOT, rel), "utf8")) as T;
+}
+
+describe("data/create/customRule parity (CR-88)", () => {
+ for (const section of SECTION_IDS) {
+ const messagesPath = SECTION_TO_MESSAGES_FILE[section];
+ const dataPath = `data/create/customRule/${section}.json`;
+
+ it(`${section}: facet file matches messages methods one-to-one`, () => {
+ const messages = readJson<{ methods: { id: string }[] }>(messagesPath);
+ const dataParsed = sectionFacetsSchema.safeParse(readJson(dataPath));
+ expect(dataParsed.success).toBe(true);
+ if (!dataParsed.success) return;
+
+ const messageSlugs = new Set(messages.methods.map((m) => m.id));
+ const dataSlugs = new Set(Object.keys(dataParsed.data));
+
+ const onlyInMessages = [...messageSlugs].filter((s) => !dataSlugs.has(s));
+ const onlyInData = [...dataSlugs].filter((s) => !messageSlugs.has(s));
+
+ expect(onlyInMessages, `${section} slugs missing from data/`).toEqual([]);
+ expect(onlyInData, `${section} slugs missing from messages/`).toEqual([]);
+ });
+ }
+});
+
+describe("data/create/customRule/_facetGroups.json (CR-88)", () => {
+ const groups = readJson("data/create/customRule/_facetGroups.json");
+
+ it("matches the facetGroupsFileSchema", () => {
+ const parsed = facetGroupsFileSchema.safeParse(groups);
+ expect(parsed.success).toBe(true);
+ });
+
+ it("every chipId resolves to a real position in the referenced messages file", () => {
+ const parsed = facetGroupsFileSchema.parse(groups);
+ for (const group of FACET_GROUP_IDS) {
+ const block = parsed[group];
+ // source is "messages/en/.../foo.json#/"; resolve relative to repo root.
+ const [filePath, jsonPointer] = block.source.split("#");
+ const file = readJson>(filePath);
+ const arrayKey = jsonPointer.replace(/^\//, "");
+ const arr = file[arrayKey];
+ expect(Array.isArray(arr), `${group}: ${block.source} → array`).toBe(true);
+ const positions = new Set(arr.map((_, i) => String(i + 1)));
+ for (const [valueId, { chipId }] of Object.entries(block.values)) {
+ expect(
+ positions.has(chipId),
+ `${group}.${valueId} chipId ${chipId} should be a position in ${block.source} (have ${[...positions].join(",")})`,
+ ).toBe(true);
+ }
+ }
+ });
+
+ it("uses the canonical 19 value ids across the four groups", () => {
+ const parsed = facetGroupsFileSchema.parse(groups);
+ let total = 0;
+ for (const group of FACET_GROUP_IDS) {
+ const expected = new Set(FACET_VALUE_IDS_BY_GROUP[group]);
+ const actual = new Set(Object.keys(parsed[group].values));
+ expect(actual).toEqual(expected);
+ total += actual.size;
+ }
+ expect(total).toBe(19);
+ });
+});
diff --git a/tests/unit/methodFacetsSchemas.test.ts b/tests/unit/methodFacetsSchemas.test.ts
new file mode 100644
index 0000000..04908ff
--- /dev/null
+++ b/tests/unit/methodFacetsSchemas.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from "vitest";
+import {
+ flattenRequestedFacets,
+ methodFacetsSchema,
+ parseRequestedFacetsFromSearchParams,
+ resolveFacetMatch,
+} from "../../lib/server/validation/methodFacetsSchemas";
+
+describe("methodFacetsSchema", () => {
+ it("accepts boolean cells and partial groups", () => {
+ expect(
+ methodFacetsSchema.safeParse({
+ size: { oneMember: true, twoToFive: false },
+ orgType: { dao: { match: true, weight: 0.5 } },
+ }).success,
+ ).toBe(true);
+ });
+
+ it("rejects unknown facet group", () => {
+ expect(
+ methodFacetsSchema.safeParse({
+ nonsense: { foo: true },
+ }).success,
+ ).toBe(false);
+ });
+
+ it("rejects unknown value within a known group", () => {
+ expect(
+ methodFacetsSchema.safeParse({
+ size: { gigantic: true },
+ }).success,
+ ).toBe(false);
+ });
+});
+
+describe("resolveFacetMatch", () => {
+ it("treats undefined as { match:false }", () => {
+ expect(resolveFacetMatch(undefined)).toEqual({
+ match: false,
+ weight: null,
+ });
+ });
+ it("preserves weight when given as object", () => {
+ expect(resolveFacetMatch({ match: true, weight: 1.5 })).toEqual({
+ match: true,
+ weight: 1.5,
+ });
+ });
+});
+
+describe("parseRequestedFacetsFromSearchParams", () => {
+ it("collects facet.* params across multiple values per group", () => {
+ const params = new URLSearchParams();
+ params.append("facet.size", "oneMember");
+ params.append("facet.orgType", "dao");
+ params.append("facet.orgType", "nonprofit");
+ const out = parseRequestedFacetsFromSearchParams(params);
+ expect(out.size).toEqual(["oneMember"]);
+ expect(out.orgType?.sort()).toEqual(["dao", "nonprofit"]);
+ });
+
+ it("silently drops unknown groups and values", () => {
+ const params = new URLSearchParams();
+ params.append("facet.size", "tiny");
+ params.append("facet.unknown", "dao");
+ params.append("foo", "bar");
+ expect(parseRequestedFacetsFromSearchParams(params)).toEqual({});
+ });
+});
+
+describe("flattenRequestedFacets", () => {
+ it("emits one entry per (group, value) pair", () => {
+ const flat = flattenRequestedFacets({
+ size: ["oneMember", "twoToFive"],
+ orgType: ["dao"],
+ });
+ expect(flat).toEqual([
+ { group: "size", value: "oneMember" },
+ { group: "size", value: "twoToFive" },
+ { group: "orgType", value: "dao" },
+ ]);
+ });
+});
diff --git a/tests/unit/methodRecommendations.test.ts b/tests/unit/methodRecommendations.test.ts
new file mode 100644
index 0000000..61d4b8c
--- /dev/null
+++ b/tests/unit/methodRecommendations.test.ts
@@ -0,0 +1,160 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const findManyMock = vi.fn();
+
+vi.mock("../../lib/server/env", () => ({
+ isDatabaseConfigured: () => true,
+}));
+
+vi.mock("../../lib/server/db", () => ({
+ prisma: {
+ methodFacet: {
+ findMany: (...args: unknown[]) => findManyMock(...args),
+ },
+ },
+}));
+
+import {
+ listMethodRecommendations,
+ scoreTemplatesByFacets,
+} from "../../lib/server/methodRecommendations";
+
+beforeEach(() => {
+ findManyMock.mockReset();
+});
+
+describe("listMethodRecommendations (CR-88 §9.2)", () => {
+ it("returns empty rankings when no facets are requested", async () => {
+ const result = await listMethodRecommendations({
+ section: "communication",
+ facets: {},
+ });
+ expect(result).toEqual({ rankedSlugs: [], matchesBySlug: {} });
+ expect(findManyMock).not.toHaveBeenCalled();
+ });
+
+ it("scores methods by counting matched (group, value) pairs", async () => {
+ findManyMock.mockResolvedValueOnce([
+ { slug: "loomio", group: "size", value: "thirteenToOneHundred" },
+ { slug: "loomio", group: "orgType", value: "workersCoop" },
+ { slug: "in-person", group: "size", value: "thirteenToOneHundred" },
+ ]);
+
+ const result = await listMethodRecommendations({
+ section: "communication",
+ facets: {
+ size: ["thirteenToOneHundred"],
+ orgType: ["workersCoop"],
+ },
+ });
+
+ expect(result).toEqual({
+ rankedSlugs: ["loomio", "in-person"],
+ matchesBySlug: {
+ loomio: {
+ score: 2,
+ matchedFacets: ["size:thirteenToOneHundred", "orgType:workersCoop"],
+ },
+ "in-person": {
+ score: 1,
+ matchedFacets: ["size:thirteenToOneHundred"],
+ },
+ },
+ });
+ });
+
+ it("returns null on query failure (caller falls back to authoring order)", async () => {
+ findManyMock.mockRejectedValueOnce(new Error("db down"));
+ const result = await listMethodRecommendations({
+ section: "communication",
+ facets: { size: ["oneMember"] },
+ });
+ expect(result).toBeNull();
+ });
+
+ it("dedupes (group, value) so the same row never double-counts", async () => {
+ findManyMock.mockResolvedValueOnce([
+ { slug: "loomio", group: "size", value: "twoToFive" },
+ { slug: "loomio", group: "size", value: "twoToFive" },
+ ]);
+ const result = await listMethodRecommendations({
+ section: "communication",
+ facets: { size: ["twoToFive"] },
+ });
+ expect(result?.matchesBySlug["loomio"]?.score).toBe(1);
+ });
+});
+
+describe("scoreTemplatesByFacets (CR-88 §9.1)", () => {
+ it("aggregates per-method matches per template", async () => {
+ findManyMock.mockResolvedValueOnce([
+ {
+ section: "communication",
+ slug: "loomio",
+ group: "size",
+ value: "twoToFive",
+ },
+ {
+ section: "decisionApproaches",
+ slug: "consensus-decision-making",
+ group: "orgType",
+ value: "workersCoop",
+ },
+ ]);
+
+ const result = await scoreTemplatesByFacets({
+ facets: {
+ size: ["twoToFive"],
+ orgType: ["workersCoop"],
+ },
+ templateMethods: [
+ {
+ templateSlug: "consensus",
+ methods: [
+ { section: "communication", slug: "loomio" },
+ {
+ section: "decisionApproaches",
+ slug: "consensus-decision-making",
+ },
+ ],
+ },
+ {
+ templateSlug: "monarch",
+ methods: [
+ {
+ section: "decisionApproaches",
+ slug: "benevolent-dictator",
+ },
+ ],
+ },
+ ],
+ });
+
+ expect(result).toEqual([
+ {
+ templateSlug: "consensus",
+ score: 2,
+ matchedFacets: [
+ "communication:loomio:size:twoToFive",
+ "decisionApproaches:consensus-decision-making:orgType:workersCoop",
+ ],
+ },
+ { templateSlug: "monarch", score: 0, matchedFacets: [] },
+ ]);
+ });
+
+ it("returns 0-score entries for every template when facets are empty", async () => {
+ const result = await scoreTemplatesByFacets({
+ facets: {},
+ templateMethods: [
+ { templateSlug: "consensus", methods: [] },
+ { templateSlug: "monarch", methods: [] },
+ ],
+ });
+ expect(result).toEqual([
+ { templateSlug: "consensus", score: 0, matchedFacets: [] },
+ { templateSlug: "monarch", score: 0, matchedFacets: [] },
+ ]);
+ expect(findManyMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/unit/templateMethods.test.ts b/tests/unit/templateMethods.test.ts
new file mode 100644
index 0000000..2ecb847
--- /dev/null
+++ b/tests/unit/templateMethods.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from "vitest";
+import {
+ methodSlugFromTitle,
+ templateMethodsFromBody,
+} from "../../lib/server/templateMethods";
+
+describe("methodSlugFromTitle", () => {
+ it.each([
+ ["Consensus Decision-Making", "consensus-decision-making"],
+ ["Consent (Sociocracy)", "consent-sociocracy"],
+ ["Mutual aid", "mutual-aid"],
+ ["Workers’ Cooperative", "workers-cooperative"],
+ [" Multiple spaces ", "multiple-spaces"],
+ ])("%s -> %s", (input, expected) => {
+ expect(methodSlugFromTitle(input)).toBe(expected);
+ });
+});
+
+describe("templateMethodsFromBody", () => {
+ it("extracts (section, slug) pairs and skips Values", () => {
+ const body = {
+ sections: [
+ {
+ categoryName: "Values",
+ entries: [{ title: "Mutuality" }],
+ },
+ {
+ categoryName: "Communication",
+ entries: [
+ { title: "In-Person Meetings" },
+ { title: "Loomio" },
+ ],
+ },
+ {
+ categoryName: "Decision-making",
+ entries: [{ title: "Consensus Decision-Making" }],
+ },
+ {
+ categoryName: "Conflict management",
+ entries: [{ title: "Restorative Justice" }],
+ },
+ {
+ categoryName: "Membership",
+ entries: [{ title: "Peer Sponsorship" }],
+ },
+ ],
+ };
+ const result = templateMethodsFromBody(body);
+ expect(result).toEqual([
+ { section: "communication", slug: "in-person-meetings" },
+ { section: "communication", slug: "loomio" },
+ { section: "decisionApproaches", slug: "consensus-decision-making" },
+ { section: "conflictManagement", slug: "restorative-justice" },
+ { section: "membership", slug: "peer-sponsorship" },
+ ]);
+ });
+
+ it("dedupes within a template", () => {
+ const body = {
+ sections: [
+ {
+ categoryName: "Communication",
+ entries: [{ title: "Loomio" }, { title: "Loomio" }],
+ },
+ ],
+ };
+ expect(templateMethodsFromBody(body)).toEqual([
+ { section: "communication", slug: "loomio" },
+ ]);
+ });
+
+ it("returns [] for malformed bodies", () => {
+ expect(templateMethodsFromBody(null)).toEqual([]);
+ expect(templateMethodsFromBody({})).toEqual([]);
+ expect(templateMethodsFromBody({ sections: "nope" })).toEqual([]);
+ });
+});
From d3bb8cdd0fc266ddbed2236d9e0a8470170bb134 Mon Sep 17 00:00:00 2001
From: adilallo <39313955+adilallo@users.noreply.github.com>
Date: Mon, 20 Apr 2026 13:14:56 -0600
Subject: [PATCH 10/15] Template remove add, read-only, chips open modals
---
.../cards/RuleCard/RuleCard.container.tsx | 2 +
.../cards/RuleCard/RuleCard.types.ts | 3 +
.../cards/RuleCard/RuleCard.view.tsx | 3 +-
.../TemplateChipDetailModal.tsx | 354 ++++++++++++++++++
.../TemplateReviewCard/TemplateReviewCard.tsx | 90 +++--
lib/create/templateReviewMapping.ts | 110 +++++-
messages/en/create/customRule/coreValues.json | 35 ++
messages/en/create/templateReview.json | 8 +-
tests/unit/templateReviewMapping.test.ts | 70 ++++
9 files changed, 632 insertions(+), 43 deletions(-)
create mode 100644 app/components/cards/TemplateReviewCard/TemplateChipDetailModal.tsx
diff --git a/app/components/cards/RuleCard/RuleCard.container.tsx b/app/components/cards/RuleCard/RuleCard.container.tsx
index 6605c02..4a39dc0 100644
--- a/app/components/cards/RuleCard/RuleCard.container.tsx
+++ b/app/components/cards/RuleCard/RuleCard.container.tsx
@@ -31,6 +31,7 @@ const RuleCardContainer = memo(
logoUrl,
logoAlt,
communityInitials,
+ hideCategoryAddButton = false,
}) => {
const size = sizeProp ?? "L";
@@ -76,6 +77,7 @@ const RuleCardContainer = memo(
logoUrl={logoUrl}
logoAlt={logoAlt}
communityInitials={communityInitials}
+ hideCategoryAddButton={hideCategoryAddButton}
/>
);
},
diff --git a/app/components/cards/RuleCard/RuleCard.types.ts b/app/components/cards/RuleCard/RuleCard.types.ts
index 8c5f67a..846374c 100644
--- a/app/components/cards/RuleCard/RuleCard.types.ts
+++ b/app/components/cards/RuleCard/RuleCard.types.ts
@@ -26,6 +26,8 @@ export interface RuleCardProps {
logoUrl?: string;
logoAlt?: string;
communityInitials?: string;
+ /** Hide the per-category "+" add chip affordance (e.g. read-only template review). */
+ hideCategoryAddButton?: boolean;
}
export interface RuleCardViewProps {
@@ -42,4 +44,5 @@ export interface RuleCardViewProps {
logoUrl?: string;
logoAlt?: string;
communityInitials?: string;
+ hideCategoryAddButton?: boolean;
}
diff --git a/app/components/cards/RuleCard/RuleCard.view.tsx b/app/components/cards/RuleCard/RuleCard.view.tsx
index 84c0341..f416672 100644
--- a/app/components/cards/RuleCard/RuleCard.view.tsx
+++ b/app/components/cards/RuleCard/RuleCard.view.tsx
@@ -19,6 +19,7 @@ export function RuleCardView({
logoUrl,
logoAlt,
communityInitials,
+ hideCategoryAddButton = false,
}: RuleCardViewProps) {
const t = useTranslation("ruleCard");
const ariaLabel = t("ariaLabel")?.replace("{title}", title) || title;
@@ -280,7 +281,7 @@ export function RuleCardView({
onCustomChipClose={(chipId) => {
category.onCustomChipClose?.(category.name, chipId);
}}
- addButton={true}
+ addButton={!hideCategoryAddButton}
addButtonText="" // Empty text for icon-only circular button
className="w-full"
/>
diff --git a/app/components/cards/TemplateReviewCard/TemplateChipDetailModal.tsx b/app/components/cards/TemplateReviewCard/TemplateChipDetailModal.tsx
new file mode 100644
index 0000000..aa8bfab
--- /dev/null
+++ b/app/components/cards/TemplateReviewCard/TemplateChipDetailModal.tsx
@@ -0,0 +1,354 @@
+"use client";
+
+import { useMemo } from "react";
+import Create from "../../modals/Create";
+import Chip from "../../controls/Chip";
+import InputLabel from "../../utility/InputLabel";
+import ContentLockup from "../../type/ContentLockup";
+import ModalTextAreaField from "../../../(app)/create/components/ModalTextAreaField";
+import { useMessages, useTranslation } from "../../../contexts/MessagesContext";
+import type { TemplateChipDetail } from "../../../../lib/create/templateReviewMapping";
+
+export interface TemplateChipDetailModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ detail: TemplateChipDetail | null;
+}
+
+/**
+ * Read-only mirror of the custom-rule per-chip modals. Shows the exact text
+ * from `messages/en/create/customRule/*.json` for the matched preset — never
+ * the template `body` placeholder. When no preset is found for the chip label,
+ * the modal surfaces a clear "details not available" note rather than falling
+ * back to seed copy.
+ */
+export function TemplateChipDetailModal({
+ isOpen,
+ onClose,
+ detail,
+}: TemplateChipDetailModalProps) {
+ const m = useMessages();
+ const t = useTranslation("create.templateReview.chipDetailModal");
+
+ const resolved = useMemo(() => resolveChipContent(detail, m), [detail, m]);
+
+ return (
+
+
+
+ }
+ showBackButton={false}
+ showNextButton
+ onNext={onClose}
+ nextButtonText={t("closeButton")}
+ ariaLabel={resolved?.title || "Template entry details"}
+ >
+
+ {resolved?.body ?? (
+
+ {t("fallback.bodyLabel")}
+
+ )}
+
+
+ );
+}
+
+type ResolvedChipContent = {
+ title: string;
+ subtitle: string;
+ body: React.ReactNode;
+};
+
+function resolveChipContent(
+ detail: TemplateChipDetail | null,
+ m: ReturnType,
+): ResolvedChipContent | null {
+ if (!detail) return null;
+ const title = detail.chipLabel;
+
+ switch (detail.groupKey) {
+ case "coreValues": {
+ const cv = m.create.customRule.coreValues;
+ const preset = findCoreValuePreset(cv.values, detail.chipLabel);
+ if (!preset) return noPresetFallback(title);
+ return {
+ title,
+ subtitle: cv.detailModal.subtitle,
+ body: (
+ <>
+
+
+ >
+ ),
+ };
+ }
+
+ case "communication": {
+ const comm = m.create.customRule.communication;
+ const preset = findMethodByLabel(comm.methods, detail.chipLabel);
+ if (!preset) return noPresetFallback(title);
+ return {
+ title,
+ subtitle: preset.supportText,
+ body: (
+ <>
+
+
+
+ >
+ ),
+ };
+ }
+
+ case "membership": {
+ const mem = m.create.customRule.membership;
+ const preset = findMethodByLabel(mem.methods, detail.chipLabel);
+ if (!preset) return noPresetFallback(title);
+ return {
+ title,
+ subtitle: preset.supportText,
+ body: (
+ <>
+
+
+
+ >
+ ),
+ };
+ }
+
+ case "decisionApproaches": {
+ const da = m.create.customRule.decisionApproaches;
+ const preset = findMethodByLabel(da.methods, detail.chipLabel);
+ if (!preset) return noPresetFallback(title);
+ return {
+ title,
+ subtitle: preset.supportText,
+ body: (
+ <>
+
+
+
+
+
+ >
+ ),
+ };
+ }
+
+ case "conflictManagement": {
+ const cm = m.create.customRule.conflictManagement;
+ const preset = findMethodByLabel(cm.methods, detail.chipLabel);
+ if (!preset) return noPresetFallback(title);
+ return {
+ title,
+ subtitle: preset.supportText,
+ body: (
+ <>
+
+
+
+
+ >
+ ),
+ };
+ }
+
+ default:
+ return noPresetFallback(title);
+ }
+}
+
+function noPresetFallback(title: string): ResolvedChipContent {
+ return { title, subtitle: "", body: null };
+}
+
+function noop() {
+ /* read-only */
+}
+
+/**
+ * Minimal read-only Applicable Scope row — locked chips shown as "selected"
+ * without the "+ Add" affordance.
+ */
+function ReadOnlyScopeField({
+ label,
+ scopes,
+}: {
+ label: string;
+ scopes: readonly string[];
+}) {
+ return (
+
+
+
+ {scopes.map((scope) => (
+
+ ))}
+
+
+ );
+}
+
+function ReadOnlyValueField({
+ label,
+ value,
+}: {
+ label: string;
+ value: string;
+}) {
+ return (
+
+
+
+ {value}
+
+
+ );
+}
+
+/** Case-insensitive, trim-tolerant method lookup by `label`. */
+function findMethodByLabel(
+ methods: readonly T[],
+ label: string,
+): T | undefined {
+ const normalized = label.trim().toLowerCase();
+ return methods.find((m) => m.label.trim().toLowerCase() === normalized);
+}
+
+type CoreValuePreset = { label: string; meaning: string; signals: string };
+
+function findCoreValuePreset(
+ values: readonly unknown[],
+ label: string,
+): CoreValuePreset | undefined {
+ const normalized = label.trim().toLowerCase();
+ for (const v of values) {
+ if (
+ v &&
+ typeof v === "object" &&
+ "label" in v &&
+ typeof (v as CoreValuePreset).label === "string" &&
+ (v as CoreValuePreset).label.trim().toLowerCase() === normalized
+ ) {
+ const preset = v as Partial;
+ return {
+ label: preset.label ?? label,
+ meaning: preset.meaning ?? "",
+ signals: preset.signals ?? "",
+ };
+ }
+ }
+ return undefined;
+}
diff --git a/app/components/cards/TemplateReviewCard/TemplateReviewCard.tsx b/app/components/cards/TemplateReviewCard/TemplateReviewCard.tsx
index 95a2896..27467ef 100644
--- a/app/components/cards/TemplateReviewCard/TemplateReviewCard.tsx
+++ b/app/components/cards/TemplateReviewCard/TemplateReviewCard.tsx
@@ -1,18 +1,23 @@
"use client";
+import { useMemo, useState } from "react";
import Image from "next/image";
import RuleCard from "../RuleCard";
-import type { RuleCardProps } from "../RuleCard/RuleCard.types";
+import type {
+ Category,
+ RuleCardProps,
+} from "../RuleCard/RuleCard.types";
import { getAssetPath } from "../../../../lib/assetUtils";
import type { RuleTemplateDto } from "../../../../lib/create/fetchTemplates";
import {
- templateBodyToCategories,
+ templateBodyToReviewData,
templateSummaryFromBody,
} from "../../../../lib/create/templateReviewMapping";
import {
getGovernanceTemplateCatalogEntry,
} from "../../../../lib/templates/governanceTemplateCatalog";
import { TEMPLATE_GRID_FALLBACK_PRESENTATION } from "../../../../lib/templates/templateGridPresentation";
+import { TemplateChipDetailModal } from "./TemplateChipDetailModal";
export interface TemplateReviewCardProps {
template: RuleTemplateDto;
@@ -24,7 +29,9 @@ export interface TemplateReviewCardProps {
/**
* Expanded RuleCard for template review: surfaces + icon from Figma catalog (21764-16435);
- * tag rows from API `body`.
+ * tag rows from API `body`. Chip clicks open a read-only detail modal per
+ * facet group (values / communication / membership / decision-making / conflict
+ * management) so reviewers can see what each chip means without editing.
*/
export function TemplateReviewCard({
template,
@@ -33,33 +40,60 @@ export function TemplateReviewCard({
}: TemplateReviewCardProps) {
const catalog = getGovernanceTemplateCatalogEntry(template.slug);
const pres = catalog ?? TEMPLATE_GRID_FALLBACK_PRESENTATION;
- const categories = templateBodyToCategories(template.body);
+ const { categories: rawCategories, chipDetailsByChipId } = useMemo(
+ () => templateBodyToReviewData(template.body),
+ [template.body],
+ );
const summary = templateSummaryFromBody(template.description, template.body);
+ const [activeChipId, setActiveChipId] = useState(null);
+
+ const categories = useMemo(
+ () =>
+ rawCategories.map((category) => ({
+ ...category,
+ onChipClick: (_categoryName, chipId) => {
+ setActiveChipId(chipId);
+ },
+ })),
+ [rawCategories],
+ );
+
+ const activeDetail =
+ activeChipId !== null ? chipDetailsByChipId[activeChipId] ?? null : null;
+
return (
- {}}
- icon={
-
- }
- />
+ <>
+ {}}
+ hideCategoryAddButton
+ icon={
+
+ }
+ />
+ setActiveChipId(null)}
+ detail={activeDetail}
+ />
+ >
);
}
diff --git a/lib/create/templateReviewMapping.ts b/lib/create/templateReviewMapping.ts
index 60f57ba..b054453 100644
--- a/lib/create/templateReviewMapping.ts
+++ b/lib/create/templateReviewMapping.ts
@@ -21,27 +21,111 @@ function isDocumentSection(
}
/**
- * Maps API template `body` (published-rule document shape) to RuleCard category rows.
+ * Known facet groups that template sections map to. Matches the five modals on
+ * the custom-rule create flow (`m.create.customRule.*`).
*/
-export function templateBodyToCategories(body: unknown): Category[] {
- if (!body || typeof body !== "object") return [];
- const sections = (body as Record).sections;
- if (!Array.isArray(sections)) return [];
+export type TemplateFacetGroupKey =
+ | "coreValues"
+ | "communication"
+ | "membership"
+ | "decisionApproaches"
+ | "conflictManagement";
- const out: Category[] = [];
+/**
+ * Normalize a section `categoryName` (as it appears in a template's `body`)
+ * to the custom-rule facet-group key. Returns `null` for unknown categories.
+ * Keys are matched case- and punctuation-insensitively so variations like
+ * "Decision making" / "Decision-making" resolve to the same group.
+ */
+export function templateCategoryToGroupKey(
+ categoryName: string,
+): TemplateFacetGroupKey | null {
+ const key = categoryName.toLowerCase().replace(/[^a-z]+/g, "");
+ switch (key) {
+ case "values":
+ case "corevalues":
+ return "coreValues";
+ case "communication":
+ case "communications":
+ return "communication";
+ case "membership":
+ case "memberships":
+ return "membership";
+ case "decisionmaking":
+ case "decisionapproaches":
+ case "decisions":
+ return "decisionApproaches";
+ case "conflictmanagement":
+ case "conflict":
+ case "conflictresolution":
+ return "conflictManagement";
+ default:
+ return null;
+ }
+}
+
+/**
+ * Detail for a single chip rendered on a template review — includes the raw
+ * entry fields plus the facet-group key so a click can open the matching
+ * read-only modal (chip `label` is used to look up the preset method inside
+ * the group).
+ */
+export interface TemplateChipDetail {
+ chipId: string;
+ chipLabel: string;
+ categoryName: string;
+ groupKey: TemplateFacetGroupKey | null;
+ body: string;
+}
+
+/**
+ * Maps API template `body` (published-rule document shape) to RuleCard category
+ * rows **plus** a chipId → detail lookup for wiring chip clicks to the
+ * read-only detail modal.
+ */
+export function templateBodyToReviewData(body: unknown): {
+ categories: Category[];
+ chipDetailsByChipId: Record;
+} {
+ const empty = { categories: [] as Category[], chipDetailsByChipId: {} };
+ if (!body || typeof body !== "object") return empty;
+ const sections = (body as Record).sections;
+ if (!Array.isArray(sections)) return empty;
+
+ const categories: Category[] = [];
+ const chipDetailsByChipId: Record = {};
for (const raw of sections) {
if (!isDocumentSection(raw)) continue;
- const chipOptions: ChipOption[] = raw.entries.map((e, i) => ({
- id: `${raw.categoryName}-${i}`,
- label: e.title,
- state: "unselected",
- }));
- out.push({
+ const groupKey = templateCategoryToGroupKey(raw.categoryName);
+ const chipOptions: ChipOption[] = raw.entries.map((e, i) => {
+ const chipId = `${raw.categoryName}-${i}`;
+ chipDetailsByChipId[chipId] = {
+ chipId,
+ chipLabel: e.title,
+ categoryName: raw.categoryName,
+ groupKey,
+ body: e.body,
+ };
+ return {
+ id: chipId,
+ label: e.title,
+ state: "unselected",
+ };
+ });
+ categories.push({
name: raw.categoryName,
chipOptions,
});
}
- return out;
+ return { categories, chipDetailsByChipId };
+}
+
+/**
+ * Backwards-compatible wrapper kept so existing consumers can still grab just
+ * the rows when they don't need chip-click wiring.
+ */
+export function templateBodyToCategories(body: unknown): Category[] {
+ return templateBodyToReviewData(body).categories;
}
/**
diff --git a/messages/en/create/customRule/coreValues.json b/messages/en/create/customRule/coreValues.json
index 23b71f9..6e853e0 100644
--- a/messages/en/create/customRule/coreValues.json
+++ b/messages/en/create/customRule/coreValues.json
@@ -324,6 +324,41 @@
"label": "Participation",
"meaning": "Democracy requires the active engagement of all members in the decision-making process. To us, this means we require active contribution to maintain voting rights. We acknowledge a tension with Efficiency (Too many cooks).",
"signals": "Creating barriers to entry that make it difficult for new members to have a voice or ignoring the input of the broader membership."
+ },
+ {
+ "label": "Reliability",
+ "meaning": "Members can count on one another to follow through on what they commit to. To us, this means we do what we say we will do and notify others early when plans must change. We acknowledge a tension with Flexibility (Sticking to the plan vs adapting).",
+ "signals": "Missing deadlines without notice or making ambitious commitments the group knows it cannot keep."
+ },
+ {
+ "label": "Resilience",
+ "meaning": "The group can absorb shocks and keep functioning when conditions get hard. To us, this means we build redundancy into critical roles and document institutional knowledge so no single departure sinks the work. We acknowledge a tension with Efficiency (Backup capacity vs lean operations).",
+ "signals": "Concentrating all critical knowledge in one person or ignoring warning signs of burnout until key members quit."
+ },
+ {
+ "label": "Solidarity",
+ "meaning": "We stand with one another, especially when members face pressure from outside forces. To us, this means we defend members against unjust treatment and share risk collectively rather than leaving anyone exposed. We acknowledge a tension with Individualism (Collective defense vs personal autonomy).",
+ "signals": "Staying silent when a member faces mistreatment or leaving vulnerable members to handle external pressure alone."
+ },
+ {
+ "label": "Stewardship",
+ "meaning": "We take responsibility for tending the shared resources and relationships entrusted to us. To us, this means we make decisions with future members in mind and reinvest in the infrastructure we depend on. We acknowledge a tension with Efficiency (Long-term care vs short-term output).",
+ "signals": "Extracting short-term value from shared resources without replenishing them or deferring maintenance until systems fail."
+ },
+ {
+ "label": "Transparency",
+ "meaning": "Decisions, finances, and processes are visible to the membership by default. To us, this means we publish meeting notes, budgets, and decision logs where all members can read them. We acknowledge a tension with Privacy (Open books vs personal disclosure).",
+ "signals": "Making major decisions in private channels and only announcing outcomes, or hiding finances behind vague summaries."
+ },
+ {
+ "label": "Trust",
+ "meaning": "We extend good faith to one another and build relationships that can withstand disagreement. To us, this means we interpret others charitably and repair quickly when trust is strained. We acknowledge a tension with Accountability (Assuming good intent vs naming harm).",
+ "signals": "Jumping to worst-case interpretations of ambiguous behavior or withholding repair when a breach could be addressed directly."
+ },
+ {
+ "label": "Voluntarism",
+ "meaning": "Participation is freely chosen rather than coerced, and contribution is recognized on its own terms. To us, this means we don't pressure members into tasks they haven't consented to and we honor their right to step back. We acknowledge a tension with Reliability (Free participation vs guaranteed coverage).",
+ "signals": "Using guilt or social pressure to extract labor from members or shaming people who decline additional responsibilities."
}
]
}
diff --git a/messages/en/create/templateReview.json b/messages/en/create/templateReview.json
index 4c5ad58..b8958c3 100644
--- a/messages/en/create/templateReview.json
+++ b/messages/en/create/templateReview.json
@@ -13,5 +13,11 @@
"notFound": "This template was not found.",
"applyFailed": "Something went wrong. Please try again."
},
- "loading": "Loading template…"
+ "loading": "Loading template…",
+ "chipDetailModal": {
+ "closeButton": "Close",
+ "fallback": {
+ "bodyLabel": "Details for this entry are not yet available."
+ }
+ }
}
diff --git a/tests/unit/templateReviewMapping.test.ts b/tests/unit/templateReviewMapping.test.ts
index 17382af..b6b8cbf 100644
--- a/tests/unit/templateReviewMapping.test.ts
+++ b/tests/unit/templateReviewMapping.test.ts
@@ -1,6 +1,8 @@
import { describe, expect, it } from "vitest";
import {
templateBodyToCategories,
+ templateBodyToReviewData,
+ templateCategoryToGroupKey,
templateSummaryFromBody,
} from "../../lib/create/templateReviewMapping";
@@ -46,4 +48,72 @@ describe("templateReviewMapping", () => {
};
expect(templateSummaryFromBody("", body)).toBe("First paragraph.");
});
+
+ describe("templateCategoryToGroupKey", () => {
+ it.each([
+ ["Values", "coreValues"],
+ ["Core values", "coreValues"],
+ ["Communication", "communication"],
+ ["Membership", "membership"],
+ ["Decision-making", "decisionApproaches"],
+ ["Decision making", "decisionApproaches"],
+ ["Conflict management", "conflictManagement"],
+ ["Conflict Resolution", "conflictManagement"],
+ ] as const)("maps %s -> %s", (input, expected) => {
+ expect(templateCategoryToGroupKey(input)).toBe(expected);
+ });
+
+ it("returns null for unknown categories", () => {
+ expect(templateCategoryToGroupKey("Mystery")).toBeNull();
+ });
+ });
+
+ describe("templateBodyToReviewData", () => {
+ it("returns group-keyed chip detail lookup aligned with categories", () => {
+ const body = {
+ sections: [
+ {
+ categoryName: "Decision-making",
+ entries: [
+ { title: "Consensus Decision-Making", body: "body-1" },
+ { title: "Modified Consensus", body: "body-2" },
+ ],
+ },
+ {
+ categoryName: "Values",
+ entries: [{ title: "Solidarity", body: "body-3" }],
+ },
+ {
+ categoryName: "Mystery",
+ entries: [{ title: "Unknown", body: "body-4" }],
+ },
+ ],
+ };
+ const { categories, chipDetailsByChipId } = templateBodyToReviewData(body);
+ expect(categories).toHaveLength(3);
+
+ const firstChipId = categories[0].chipOptions[0].id;
+ expect(chipDetailsByChipId[firstChipId]).toEqual({
+ chipId: firstChipId,
+ chipLabel: "Consensus Decision-Making",
+ categoryName: "Decision-making",
+ groupKey: "decisionApproaches",
+ body: "body-1",
+ });
+ expect(
+ chipDetailsByChipId[categories[1].chipOptions[0].id].groupKey,
+ ).toBe("coreValues");
+ expect(
+ chipDetailsByChipId[categories[2].chipOptions[0].id].groupKey,
+ ).toBeNull();
+ });
+
+ it("is resilient to bad body input", () => {
+ expect(templateBodyToReviewData(null).categories).toEqual([]);
+ expect(templateBodyToReviewData({}).chipDetailsByChipId).toEqual({});
+ expect(templateBodyToReviewData({ sections: "nope" }).categories).toEqual(
+ [],
+ );
+ });
+ });
});
From c08cd62872347324ce7bfec5aa79edc2775ce76d Mon Sep 17 00:00:00 2001
From: adilallo <39313955+adilallo@users.noreply.github.com>
Date: Mon, 20 Apr 2026 16:45:15 -0600
Subject: [PATCH 11/15] Template flow cleaned up
---
app/(app)/create/CreateFlowLayoutClient.tsx | 274 +++++++++++-------
.../create/context/CreateFlowContext.tsx | 22 ++
.../screens/review/CommunityReviewScreen.tsx | 50 +++-
.../screens/review/FinalReviewScreen.tsx | 56 +++-
app/(app)/create/types.ts | 51 ++--
.../utils/clearCreateFlowPersistedDrafts.ts | 22 ++
.../utils/createFlowFooterClassNames.ts | 24 ++
.../utils/customRuleConfirmFooterSteps.ts | 69 +++++
.../create/utils/hasCreateFlowUserInput.ts | 27 --
.../templates/TemplatesPageClient.tsx | 61 +++-
.../navigation/TopNav/TopNav.container.tsx | 20 +-
.../RuleStack/RuleStack.container.tsx | 6 +
docs/create-flow.md | 22 +-
docs/guides/backend-linear-tickets.md | 4 +-
docs/guides/template-recommendation-matrix.md | 34 ++-
lib/create/applyTemplatePrefill.ts | 145 +++++++++
lib/create/buildFinalReviewCategories.ts | 152 ++++++++++
lib/create/loadTemplateReviewBySlug.ts | 44 +++
lib/create/methodSlugFromTitle.ts | 20 ++
lib/server/templateMethods.ts | 14 +-
lib/server/validation/createFlowSchemas.ts | 7 +
messages/en/create/community/review.json | 4 +-
messages/en/create/templateReview.json | 3 +-
tests/components/FinalReviewPage.test.tsx | 43 +++
tests/components/ReviewPage.test.tsx | 83 +++++-
tests/components/TopNav.test.tsx | 55 +++-
tests/contexts/CreateFlowContext.test.tsx | 111 +++++++
tests/pages/templates.test.jsx | 67 ++++-
tests/unit/RuleStack.test.jsx | 27 ++
tests/unit/applyTemplatePrefill.test.ts | 109 +++++++
tests/unit/buildFinalReviewCategories.test.ts | 135 +++++++++
tests/unit/hasCreateFlowUserInput.test.ts | 38 ---
32 files changed, 1545 insertions(+), 254 deletions(-)
create mode 100644 app/(app)/create/utils/clearCreateFlowPersistedDrafts.ts
create mode 100644 app/(app)/create/utils/createFlowFooterClassNames.ts
create mode 100644 app/(app)/create/utils/customRuleConfirmFooterSteps.ts
delete mode 100644 app/(app)/create/utils/hasCreateFlowUserInput.ts
create mode 100644 lib/create/applyTemplatePrefill.ts
create mode 100644 lib/create/buildFinalReviewCategories.ts
create mode 100644 lib/create/loadTemplateReviewBySlug.ts
create mode 100644 lib/create/methodSlugFromTitle.ts
create mode 100644 tests/contexts/CreateFlowContext.test.tsx
create mode 100644 tests/unit/applyTemplatePrefill.test.ts
create mode 100644 tests/unit/buildFinalReviewCategories.test.ts
delete mode 100644 tests/unit/hasCreateFlowUserInput.test.ts
diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx
index 36d6f24..057617d 100644
--- a/app/(app)/create/CreateFlowLayoutClient.tsx
+++ b/app/(app)/create/CreateFlowLayoutClient.tsx
@@ -34,11 +34,17 @@ import {
} from "./utils/anonymousDraftStorage";
import { deleteServerDraft } from "../../../lib/create/api";
import { writeLastPublishedRule } from "../../../lib/create/lastPublishedRule";
-import {
- fetchTemplateBySlug,
- type RuleTemplateDto,
-} from "../../../lib/create/fetchTemplates";
+import { buildTemplateCustomizePrefill } from "../../../lib/create/applyTemplatePrefill";
+import { loadTemplateReviewBySlug } from "../../../lib/create/loadTemplateReviewBySlug";
import messages from "../../../messages/en/index";
+import {
+ CREATE_FLOW_FOOTER_BUTTON_CLASS,
+ CREATE_FLOW_FOOTER_BUTTON_ON_DARK_CLASS,
+} from "./utils/createFlowFooterClassNames";
+import {
+ CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP,
+ type CustomRuleConfirmFooterStep,
+} from "./utils/customRuleConfirmFooterSteps";
import { useAuthModal } from "../../contexts/AuthModalContext";
import { useMessages, useTranslation } from "../../contexts/MessagesContext";
import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
@@ -114,7 +120,8 @@ function CreateFlowLayoutContent({
} = useCreateFlowNavigation(
skipCommunitySave ? { skipCommunitySave: true } : undefined,
);
- const { state, clearState, updateState } = useCreateFlow();
+ const { state, clearState, updateState, resetCustomRuleSelections } =
+ useCreateFlow();
const { draftSaveBannerMessage, setDraftSaveBannerMessage } =
useCreateFlowDraftSaveBanner();
const [publishBannerMessage, setPublishBannerMessage] = useState<
@@ -188,38 +195,139 @@ function CreateFlowLayoutContent({
);
}, [state, router, openLogin]);
- const handleUseTemplateWithoutChanges = useCallback(async () => {
+ /**
+ * Customize flow from a template-review page. Applies the template's
+ * customize selections onto `CreateFlowState` so the custom-rule screens
+ * render with chips pre-highlighted, then routes to `core-values` once
+ * the community name is set — otherwise to `informational` with a
+ * `pendingTemplateAction` pin so `/create/review` later redirects past
+ * itself straight to `core-values` (see `CommunityReviewScreen`).
+ *
+ * Why title alone? Other community-stage fields (e.g.
+ * `communityStructureChipSnapshots`) are sticky once the user lands on
+ * those screens, so they can't reliably answer "has the user given us
+ * real input yet?". A non-empty community name is the minimum bar
+ * `buildPublishPayload` already enforces — we reuse that here.
+ *
+ * Direct entry (marketing home "Popular templates" or `/templates`
+ * landed on directly) wipes the anonymous draft at the *click site* via
+ * `clearCreateFlowPersistedDrafts` before navigating, so `state.title`
+ * is empty here and the no-community branch fires naturally. No
+ * URL-marker plumbing needed in this handler.
+ */
+ const handleCustomizeTemplate = useCallback(async () => {
if (!templateReviewSlug) return;
setTemplateReviewApplyError(null);
setIsApplyingTemplate(true);
- const result = await fetchTemplateBySlug(templateReviewSlug);
+ const loaded = await loadTemplateReviewBySlug(templateReviewSlug);
setIsApplyingTemplate(false);
- if (result === null) {
- setTemplateReviewApplyError(messages.create.templateReview.errors.notFound);
+ if (loaded.ok === false) {
+ setTemplateReviewApplyError(loaded.message);
return;
}
- if ("error" in result) {
- setTemplateReviewApplyError(result.error);
+ const prefill = buildTemplateCustomizePrefill(loaded.template.body);
+ const hasCommunityName =
+ typeof state.title === "string" && state.title.trim().length > 0;
+ // Prefill merges (shallow) with current state. When we have to bounce the
+ // user to the community stage first, pin a pendingTemplateAction so
+ // `/create/review` knows to skip past itself to `core-values` later.
+ updateState({
+ ...prefill,
+ ...(hasCommunityName
+ ? { pendingTemplateAction: undefined }
+ : {
+ pendingTemplateAction: {
+ slug: templateReviewSlug,
+ mode: "customize",
+ },
+ }),
+ });
+ router.push(
+ hasCommunityName ? "/create/core-values" : "/create/informational",
+ );
+ }, [router, state.title, templateReviewSlug, updateState]);
+
+ /**
+ * "Use without changes" from a template-review page. Drops users into the
+ * review-and-complete stage (`confirm-stakeholders` → `final-review`) so the
+ * publish flow — and its server-enforced sign-in gate (`publishRule` 401 →
+ * `openLogin`) — is reused. The template body becomes the rule document;
+ * the user's community name remains the rule title.
+ *
+ * Community-name branch: apply template body/summary immediately and jump
+ * to `confirm-stakeholders`.
+ *
+ * No-community-name branch: same template body/summary apply so state is
+ * ready, plus a `pendingTemplateAction` pin so `/create/review` later
+ * redirects past itself straight to `confirm-stakeholders` once community
+ * data is captured (see `CommunityReviewScreen`). Users aren't forced back
+ * through the template picker just to pick the same template again.
+ *
+ * Direct entry (marketing home "Popular templates" or `/templates`
+ * landed on directly) wipes the anonymous draft at the click site via
+ * `clearCreateFlowPersistedDrafts` before navigating, so `state.title`
+ * is empty and the no-community branch fires naturally.
+ */
+ const handleUseTemplateWithoutChanges = useCallback(async () => {
+ if (!templateReviewSlug) return;
+ setTemplateReviewApplyError(null);
+
+ setIsApplyingTemplate(true);
+ const loaded = await loadTemplateReviewBySlug(templateReviewSlug);
+ setIsApplyingTemplate(false);
+ if (loaded.ok === false) {
+ setTemplateReviewApplyError(loaded.message);
return;
}
- const template: RuleTemplateDto = result;
+ const { template } = loaded;
const doc = template.body;
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
setTemplateReviewApplyError(messages.create.templateReview.errors.applyFailed);
return;
}
+ const sectionsRaw = (doc as { sections?: unknown }).sections;
+ const sections = Array.isArray(sectionsRaw)
+ ? (sectionsRaw as Record[])
+ : [];
+ if (sections.length === 0) {
+ setTemplateReviewApplyError(messages.create.templateReview.errors.applyFailed);
+ return;
+ }
+
+ // Using the template verbatim: scrub any prior customize picks so they
+ // don't bleed into `document.coreValues` at publish time.
+ resetCustomRuleSelections();
+
const summaryRaw =
typeof template.description === "string"
? template.description.trim()
: "";
- writeLastPublishedRule({
- id: `template:${template.slug}`,
- title: template.title,
- summary: summaryRaw.length > 0 ? summaryRaw : null,
- document: doc as Record,
+ const hasCommunityName =
+ typeof state.title === "string" && state.title.trim().length > 0;
+ updateState({
+ sections,
+ ...(summaryRaw.length > 0 ? { summary: summaryRaw } : {}),
+ ...(hasCommunityName
+ ? { pendingTemplateAction: undefined }
+ : {
+ pendingTemplateAction: {
+ slug: templateReviewSlug,
+ mode: "useWithoutChanges",
+ },
+ }),
});
- router.push("/create/completed");
- }, [router, templateReviewSlug]);
+ router.push(
+ hasCommunityName
+ ? "/create/confirm-stakeholders"
+ : "/create/informational",
+ );
+ }, [
+ resetCustomRuleSelections,
+ router,
+ state.title,
+ templateReviewSlug,
+ updateState,
+ ]);
const runAuthenticatedExit = useCreateFlowExit({
state,
@@ -360,8 +468,16 @@ function CreateFlowLayoutContent({
currentStep,
);
- const footerPrimaryButtonClass =
- "md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)]";
+ /**
+ * Custom Rule stage "confirm selection" steps: all five render the same
+ * primary footer button, differing only by disable predicate and label.
+ * Driving JSX from a config keeps the five sites aligned — adding a new
+ * selection screen means one row here, not a new branch below.
+ */
+ const customRuleConfirmFooter: CustomRuleConfirmFooterStep | undefined =
+ currentStep != null
+ ? CUSTOM_RULE_CONFIRM_FOOTER_STEP_BY_STEP.get(currentStep)
+ : undefined;
const hasTopOverlays =
Boolean(draftSaveBannerMessage) ||
@@ -483,7 +599,7 @@ function CreateFlowLayoutContent({
palette="default"
size="xsmall"
disabled={isApplyingTemplate}
- className="md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)] !text-white"
+ className={CREATE_FLOW_FOOTER_BUTTON_ON_DARK_CLASS}
onClick={() => void handleUseTemplateWithoutChanges()}
>
{messages.create.templateReview.footer.useWithoutChanges}
@@ -493,17 +609,8 @@ function CreateFlowLayoutContent({
palette="default"
size="xsmall"
disabled={isApplyingTemplate}
- title={
- messages.create.templateReview.footer.customizeAriaHint
- }
- className="md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)]"
- onClick={() => {
- if (!templateReviewSlug) return;
- // Preserve template slug for a future customize / prefill ticket (informational does not read it yet).
- router.push(
- `/create/informational?template=${encodeURIComponent(templateReviewSlug)}`,
- );
- }}
+ className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
+ onClick={() => void handleCustomizeTemplate()}
>
{messages.create.templateReview.footer.customize}
@@ -513,8 +620,12 @@ function CreateFlowLayoutContent({
buttonType="filled"
palette="default"
size="xsmall"
- disabled={isPublishing}
- className={footerPrimaryButtonClass}
+ disabled={
+ isPublishing ||
+ typeof state.title !== "string" ||
+ state.title.trim().length === 0
+ }
+ className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
goToNextStep();
}}
@@ -528,7 +639,7 @@ function CreateFlowLayoutContent({
palette="default"
size="xsmall"
disabled={isPublishing}
- className={footerPrimaryButtonClass}
+ className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
goToNextStep();
}}
@@ -545,7 +656,7 @@ function CreateFlowLayoutContent({
communitySaveMagicLinkSuccess ||
!isValidCreateFlowSaveEmail(state.communitySaveEmail)
}
- className={footerPrimaryButtonClass}
+ className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
void handleCommunitySaveMagicLinkSubmit();
}}
@@ -562,8 +673,11 @@ function CreateFlowLayoutContent({
palette="default"
size="xsmall"
disabled={isPublishing}
- className={footerPrimaryButtonClass}
+ className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
+ // Scrub any prior template-customize prefill so entering
+ // the custom-rule stage from review is always a clean slate.
+ resetCustomRuleSelections();
goToNextStep();
}}
>
@@ -574,93 +688,35 @@ function CreateFlowLayoutContent({
palette="default"
size="xsmall"
disabled={isPublishing}
- className={footerPrimaryButtonClass}
+ className={CREATE_FLOW_FOOTER_BUTTON_CLASS}
onClick={() => {
- router.push("/templates");
+ // `fromFlow=1` tells `/templates` to skip the fresh-slate
+ // draft clear it normally runs on template click, so the
+ // user's in-progress Create Community stage survives this
+ // detour. Direct entries to `/templates` (no marker) and
+ // home "Popular templates" clicks always start fresh by
+ // wiping anonymous draft storage at click time.
+ router.push("/templates?fromFlow=1");
}}
>
{footer.createFromTemplate}
- ) : currentStep === "core-values" && nextStep ? (
+ ) : customRuleConfirmFooter && nextStep ? (
- ) : currentStep === "communication-methods" && nextStep ? (
-
- ) : currentStep === "membership-methods" && nextStep ? (
-
- ) : currentStep === "decision-approaches" && nextStep ? (
-
- ) : currentStep === "conflict-management" && nextStep ? (
-