Implement create custom recommendations

This commit is contained in:
adilallo
2026-04-20 12:41:10 -06:00
parent e9dab04b34
commit 45bbbb8a35
75 changed files with 6403 additions and 1452 deletions
+10 -3
View File
@@ -39,9 +39,16 @@ file are a smell once they're used more than once.
## Copy & data
- Step copy lives in `messages/en/create/<step>.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/<stage>/<step>.json` where
`<stage>` 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.<stage>.*`
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.
+12 -6
View File
@@ -15,9 +15,13 @@ notation). Never hard-code user-facing strings in components.
- `messages/en/<area>.json` for single-file areas (`common.json`,
`navigation.json`, `metadata.json`).
- `messages/en/<folder>/<entry>.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/<stage>/<step>.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
+31 -9
View File
@@ -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 (
<CreateFlowProvider enableAnonymousPersistence={enableAnonymousPersistence}>
<CreateFlowProvider enableLocalDraftMirroring={enableLocalDraftMirroring}>
<CreateFlowDraftSaveBannerProvider>
<CreateFlowLayoutContent
sessionUser={sessionUser}
@@ -91,7 +99,7 @@ function CreateFlowLayoutContent({
}) {
const { create } = useMessages();
const footer = create.footer;
const communitySaveMessages = create.communitySave;
const communitySaveMessages = create.community.communitySave;
const tLogin = useTranslation("pages.login");
const router = useRouter();
const pathname = usePathname();
@@ -142,7 +150,7 @@ function CreateFlowLayoutContent({
if (payloadResult.ok === false) {
setPublishBannerMessage(
payloadResult.error === "missingCommunityName"
? messages.create.publish.missingCommunityName
? messages.create.reviewAndComplete.publish.missingCommunityName
: payloadResult.error,
);
return;
@@ -176,7 +184,7 @@ function CreateFlowLayoutContent({
setPublishBannerMessage(
publishResult.error.trim() !== ""
? publishResult.error
: messages.create.publish.genericPublishFailed,
: messages.create.reviewAndComplete.publish.genericPublishFailed,
);
}, [state, router, openLogin]);
@@ -226,6 +234,20 @@ function CreateFlowLayoutContent({
const saveDraft = opts?.saveDraft ?? false;
if (!sessionResolved) return;
// Exit from `/create/completed` is post-publish: the rule is saved, so we
// skip the leave-confirm + login prompt and just wipe the in-flight draft.
// For signed-in users we also DELETE the server draft so a future visit to
// /create starts fresh instead of rehydrating yesterday's work.
if (currentStep === "completed") {
clearState();
clearAnonymousCreateFlowStorage();
if (sessionUser) {
void deleteServerDraft();
}
router.push("/");
return;
}
if (sessionUser === null) {
if (saveDraft) return;
const returnToTemplateReview =
@@ -372,7 +394,7 @@ function CreateFlowLayoutContent({
<Alert
type="banner"
status="danger"
title={messages.create.publish.finalizeBannerTitle}
title={messages.create.reviewAndComplete.publish.finalizeBannerTitle}
description={publishBannerMessage}
onClose={() => setPublishBannerMessage(null)}
className="w-full"
@@ -622,7 +644,7 @@ function CreateFlowLayoutContent({
goToNextStep();
}}
>
{footer.confirmRightRail}
{footer.confirmDecisionApproaches}
</Button>
) : currentStep === "conflict-management" && nextStep ? (
<Button
@@ -657,7 +679,7 @@ function CreateFlowLayoutContent({
>
{currentStep === "final-review"
? isPublishing
? messages.create.publish.finalizeButtonPublishing
? messages.create.reviewAndComplete.publish.finalizeButtonPublishing
: footer.finalizeCommunityRule
: currentStep === "confirm-stakeholders"
? footer.confirmStakeholders
+20 -35
View File
@@ -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);
+54 -14
View File
@@ -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<CreateFlowState>(() => {
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<CreateFlowStep | null>(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(() => {
@@ -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<FacetGroupId, Record<string, string>> = (() => {
const out: Record<FacetGroupId, Record<string, string>> = {
size: {},
orgType: {},
scale: {},
maturity: {},
};
for (const group of FACET_GROUPS) {
const block = (facetGroups as Record<string, unknown>)[group];
if (block && typeof block === "object" && "values" in block) {
const values = (block as { values: Record<string, { chipId: string }> })
.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<FacetGroupId, keyof CreateFlowState> = {
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<string, number>;
/**
* `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<string, number> = {};
/**
* Calls `GET /api/create-flow/methods?section=<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<FacetRecommendationsResult>({
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<string | null>(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<string, number> = {};
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<T extends { id: string }>(
methods: readonly T[],
scoresBySlug: Record<string, number>,
): T[] {
const indexById = new Map<string, number>();
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<T extends { id: string }>(
rankedMethods: readonly T[],
scoresBySlug: Record<string, number>,
hasAnyFacets: boolean,
limit: number,
): { compactCardIds: string[]; recommendedIds: Set<string> } {
const fallback = () => ({
compactCardIds: rankedMethods.slice(0, limit).map((m) => m.id),
recommendedIds: new Set<string>(),
});
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)),
};
}
@@ -35,7 +35,7 @@ export function CreateFlowScreenView({
case "community-name":
return (
<CreateFlowTextFieldScreen
messageNamespace="create.communityName"
messageNamespace="create.community.communityName"
stateField="title"
maxLength={48}
/>
@@ -45,7 +45,7 @@ export function CreateFlowScreenView({
case "community-context":
return (
<CreateFlowTextFieldScreen
messageNamespace="create.communityContext"
messageNamespace="create.community.communityContext"
stateField="communityContext"
maxLength={48}
mainAlign="center"
@@ -58,7 +58,7 @@ export function CreateFlowScreenView({
case "community-save":
return (
<CreateFlowTextFieldScreen
messageNamespace="create.communitySave"
messageNamespace="create.community.communitySave"
stateField="communitySaveEmail"
maxLength={254}
mainAlign="center"
@@ -13,6 +13,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";
@@ -24,10 +29,6 @@ import {
} from "../../components/createFlowLayoutTokens";
import ModalTextAreaField from "../../components/ModalTextAreaField";
const IN_PERSON_CARD_ID = "in-person-meetings";
const SIGNAL_CARD_ID = "signal";
const VIDEO_MEETINGS_CARD_ID = "video-meetings";
const SECTION_FIELDS = [
"corePrinciple",
"logisticsAdmin",
@@ -35,16 +36,6 @@ const SECTION_FIELDS = [
] as const;
type SectionField = (typeof SECTION_FIELDS)[number];
const COMMUNICATION_CARD_ORDER = [
IN_PERSON_CARD_ID,
SIGNAL_CARD_ID,
VIDEO_MEETINGS_CARD_ID,
"4",
"5",
"6",
"7",
] as const;
function AddPlatformModalContent({
platformCardId,
}: {
@@ -52,15 +43,13 @@ function AddPlatformModalContent({
}) {
const { markCreateFlowInteraction } = useCreateFlow();
const m = useMessages();
const comm = m.create.communication;
const modal =
platformCardId in comm.modals
? comm.modals[platformCardId as keyof typeof comm.modals]
: null;
const defaults = modal?.sections ?? {
corePrinciple: "",
logisticsAdmin: "",
codeOfConduct: "",
const comm = m.create.customRule.communication;
const method = comm.methods.find((entry) => entry.id === platformCardId);
const sections = method?.sections;
const defaults: Record<SectionField, string> = {
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"}
/>
@@ -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"}
/>
@@ -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<SectionField, string> = {
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"}
/>
@@ -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(
() =>
@@ -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 = [
{
@@ -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 =
@@ -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(() => {
@@ -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 (
<div className="flex flex-col gap-6">
<ModalTextAreaField
label={rr.sectionHeadings.corePrinciple}
label={da.sectionHeadings.corePrinciple}
value={sections.corePrinciple}
onChange={(v) => patch("corePrinciple", v)}
/>
<ApplicableScopeField
label={rr.sectionHeadings.applicableScope}
addLabel={rr.scopeAddButtonLabel}
label={da.sectionHeadings.applicableScope}
addLabel={da.scopeAddButtonLabel}
scopes={sections.applicableScope}
selectedScopes={sections.selectedApplicableScope}
onToggleScope={(scope) =>
@@ -109,12 +112,12 @@ function AddDecisionApproachModalContent({
}
/>
<ModalTextAreaField
label={rr.sectionHeadings.stepByStepInstructions}
label={da.sectionHeadings.stepByStepInstructions}
value={sections.stepByStepInstructions}
onChange={(v) => patch("stepByStepInstructions", v)}
/>
<IncrementerBlock
label={rr.sectionHeadings.consensusLevel}
label={da.sectionHeadings.consensusLevel}
value={sections.consensusLevel}
min={CONSENSUS_LEVEL_MIN}
max={CONSENSUS_LEVEL_MAX}
@@ -125,7 +128,7 @@ function AddDecisionApproachModalContent({
incrementAriaLabel="Increase consensus level"
/>
<ModalTextAreaField
label={rr.sectionHeadings.objectionsDeadlocks}
label={da.sectionHeadings.objectionsDeadlocks}
value={sections.objectionsDeadlocks}
onChange={(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<string[]>(
@@ -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}
<InlineTextButton
onClick={() => {
markCreateFlowInteraction();
setExpanded(true);
}}
>
{rr.sidebar.descriptionLinkLabel}
{da.sidebar.descriptionLinkLabel}
</InlineTextButton>
{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={
<DecisionMakingSidebar
title={rr.sidebar.title}
title={da.sidebar.title}
description={sidebarDescription}
messageBoxTitle={rr.messageBox.title}
messageBoxTitle={da.messageBox.title}
messageBoxItems={messageBoxItems}
messageBoxCheckedIds={messageBoxCheckedIds}
onMessageBoxCheckboxChange={handleMessageBoxCheckboxChange}
@@ -287,12 +293,13 @@ export function DecisionApproachesScreen() {
expanded={expanded}
onToggleExpand={handleToggleExpand}
hasMore={true}
toggleLabel={rr.cardStack.toggleSeeAll}
showLessLabel={rr.cardStack.toggleShowLess}
toggleLabel={da.cardStack.toggleSeeAll}
showLessLabel={da.cardStack.toggleShowLess}
title=""
description=""
layout="singleStack"
compactRecommendedLimit={5}
compactCardIds={compactCardIds}
className="w-full"
headerLockupSize={mdUp ? "L" : "M"}
/>
@@ -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<
@@ -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<
@@ -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<ChipOption[]>(
[],
@@ -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],
@@ -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 = () => {
+4 -4
View File
@@ -85,13 +85,13 @@ export interface CreateFlowState {
coreValuesChipsSnapshot?: CommunityStructureChipSnapshotRow[];
/** User-authored detail text keyed by chip id (preset ids or custom UUIDs). */
coreValueDetailsByChipId?: Record<string, CoreValueDetailEntry>;
/** 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. */
@@ -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,
},
};
+56
View File
@@ -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<string>(SECTION_IDS);
/**
* GET /api/create-flow/methods?section=<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.<section>.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 });
}
+17
View File
@@ -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 });
}
+20 -6
View File
@@ -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.<group>=<value>` 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 },
);
}
@@ -26,6 +26,7 @@ const CardStackContainer = memo<CardStackProps>(
description = "",
layout = "default",
compactRecommendedLimit = 5,
compactCardIds,
compactDesktopLayout: compactDesktopLayoutProp = "grid",
headerLockupSize,
toggleAlignment = "center",
@@ -83,6 +84,7 @@ const CardStackContainer = memo<CardStackProps>(
description={description}
layout={layout}
compactRecommendedLimit={compactRecommendedLimit}
compactCardIds={compactCardIds}
compactDesktopLayout={compactDesktopLayoutProp}
headerLockupSize={headerLockupSize}
toggleAlignment={toggleAlignment}
@@ -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";
@@ -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") {
+79
View File
@@ -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"
}
}
}
}
+321
View File
@@ -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
}
}
}
@@ -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
}
}
}
@@ -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
}
}
}
+553
View File
@@ -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
}
}
}
File diff suppressed because it is too large Load Diff
+17
View File
@@ -118,6 +118,23 @@ async function errorBodyMessage(res: Response): Promise<string> {
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<void> {
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<SaveDraftResult> {
+171
View File
@@ -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 `"<group>:<value>"` 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<string, MethodRanking["matches"]>;
};
/**
* 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<ListMethodRecommendationsResult | null> {
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<string, MethodRanking["matches"]> = {};
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<TemplateRanking[] | null> {
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<string>();
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 "<group>:<value>" matches.
const matchesByMethod = new Map<string, Set<string>>();
for (const row of rows) {
const k = `${row.section}:${row.slug}`;
const set = matchesByMethod.get(k) ?? new Set<string>();
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;
}
}
+95 -11
View File
@@ -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<RuleTemplateDto[]> {
}
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<string, TemplateScore>;
};
/**
* 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<RankedTemplatesResult> {
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<string, TemplateScore> = {};
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 };
}
+76
View File
@@ -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<string, SectionId> = {
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<string>();
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;
}
@@ -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/<section>.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<typeof facetMatchSchema>;
/**
* 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 extends readonly [string, ...string[]]>(
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<typeof methodFacetsSchema>;
/**
* Whole-section file shape: object keyed by method slug
* (`messages/en/create/customRule/<section>.json#/methods[].id`).
*/
export const sectionFacetsSchema = z.record(z.string(), methodFacetsSchema);
export type SectionFacetsFile = z.infer<typeof sectionFacetsSchema>;
/**
* `_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<typeof facetGroupsFileSchema>;
/**
* 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<typeof requestedFacetsSchema>;
/** 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<FacetGroupId, Set<string>> = {
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;
}
-120
View File
@@ -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": ""
}
}
}
}
-143
View File
@@ -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": ""
}
}
}
}
@@ -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."
}
}
]
}
@@ -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."
}
}
]
}
@@ -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 isnt 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)."
}
}
]
}
@@ -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."
}
}
]
}
+1 -1
View File
@@ -14,6 +14,6 @@
"confirmCoreValues": "Confirm values",
"confirmCommunication": "Confirm",
"confirmMembership": "Confirm",
"confirmRightRail": "Confirm",
"confirmDecisionApproaches": "Confirm",
"confirmConflictManagement": "Confirm"
}
-133
View File
@@ -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": ""
}
}
}
}
-147
View File
@@ -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 isnt 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
}
]
}
+48 -34
View File
@@ -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,
@@ -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");
+24
View File
@@ -71,3 +71,27 @@ model RuleTemplate {
sortOrder Int @default(0)
featured Boolean @default(false)
}
/// Recommendation matrix (CR-88).
/// JSON in `data/create/customRule/<section>.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/<section>.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])
}
+9
View File
@@ -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()
+117
View File
@@ -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/<section>.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<ReturnType<typeof loadSectionFacets>>,
): 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<SectionId, number>;
}> {
await loadFacetGroups();
const rowsBySection: Record<SectionId, number> = {
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 };
}
+1 -1
View File
@@ -8,7 +8,7 @@ export default {
export const Default = {
args: {
messageNamespace: "create.communityName",
messageNamespace: "create.community.communityName",
stateField: "title",
maxLength: 48,
},
+2 -2
View File
@@ -7,7 +7,7 @@ describe("CreateFlowTextFieldScreen (community name)", () => {
it("renders main heading", () => {
render(
<CreateFlowTextFieldScreen
messageNamespace="create.communityName"
messageNamespace="create.community.communityName"
stateField="title"
maxLength={48}
/>,
@@ -22,7 +22,7 @@ describe("CreateFlowTextFieldScreen (community name)", () => {
it("renders description and text field", () => {
render(
<CreateFlowTextFieldScreen
messageNamespace="create.communityName"
messageNamespace="create.community.communityName"
stateField="title"
maxLength={48}
/>,
+8 -3
View File
@@ -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(<DecisionApproachesScreen />);
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 () => {
+69
View File
@@ -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<typeof GET>[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([]);
});
});
+93
View File
@@ -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);
});
});
+88
View File
@@ -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<SectionId, string> = {
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<T>(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<unknown>("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#/<arrayKey>"; resolve relative to repo root.
const [filePath, jsonPointer] = block.source.split("#");
const file = readJson<Record<string, { label: string }[]>>(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);
});
});
+83
View File
@@ -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" },
]);
});
});
+160
View File
@@ -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();
});
});
+77
View File
@@ -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([]);
});
});