Implement modals across create flow

This commit is contained in:
adilallo
2026-04-17 23:45:29 -06:00
parent 36dcb79870
commit 4854c49c4a
21 changed files with 2089 additions and 318 deletions
@@ -0,0 +1,59 @@
"use client";
import { memo } from "react";
export interface InlineTextButtonProps {
/**
* Button label content.
*/
children: React.ReactNode;
/**
* Click handler.
*/
onClick?: (_event: React.MouseEvent<HTMLButtonElement>) => void;
/**
* Extra class names. Use `className` to override typography/color when the
* button must inherit parent font-size/leading (e.g. mid-paragraph usage).
*/
className?: string;
disabled?: boolean;
ariaLabel?: string;
type?: "button" | "submit" | "reset";
}
/**
* Small text-styled button for in-paragraph "link"-like controls (expand,
* add, etc.). The Figma "link" treatment is a tertiary-colored underline with
* a 3px underline-offset and inherited typography, which sits between a real
* anchor and a styled `Button`.
*
* Use this anywhere a `<button>` is needed inline with body copy — do not use
* for primary/secondary actions (reach for `Button` instead).
*/
function InlineTextButtonComponent({
children,
onClick,
className = "",
disabled = false,
ariaLabel,
type = "button",
}: InlineTextButtonProps) {
const baseClasses =
"cursor-pointer border-none bg-transparent p-0 font-inter font-normal text-[length:inherit] leading-[inherit] text-[color:var(--color-content-default-tertiary,#b4b4b4)] underline decoration-solid underline-offset-[3px] hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)] disabled:cursor-not-allowed disabled:opacity-60";
return (
<button
type={type}
onClick={onClick}
disabled={disabled}
aria-label={ariaLabel}
className={`${baseClasses} ${className}`.trim()}
>
{children}
</button>
);
}
InlineTextButtonComponent.displayName = "InlineTextButton";
export default memo(InlineTextButtonComponent);
@@ -33,6 +33,13 @@ export interface ChipProps {
*/
size?: ChipSizeValue;
className?: string;
/**
* Whether the chip should be non-interactive. Defaults to `true` when
* `state === "disabled"` to preserve historical behavior. Pass
* `disabled={false}` alongside `state="Disabled"` to render the dimmed
* "disabled" visual while keeping the chip clickable — useful for toggle
* groups where the unselected state is the disabled Figma visual.
*/
disabled?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
/**
+9 -4
View File
@@ -20,7 +20,10 @@ function ChipView({
inputRef,
ariaLabel,
}: ChipViewProps) {
const isDisabled = disabled || state === "disabled";
// The container is the source of truth for `disabled`. This allows
// `state="disabled"` to be used purely as a visual (for toggle-group chips
// that look dimmed while remaining clickable) by passing `disabled={false}`.
const isDisabled = disabled ?? false;
const isSelected = state === "selected";
const isCustom = state === "custom";
@@ -57,11 +60,13 @@ function ChipView({
} else if (state === "disabled") {
background = "bg-[var(--color-surface-default-secondary,#141414)]"; // dark background
border = "border-none";
textColor = "text-[color:var(--color-content-default-tertiary,#b4b4b4)]";
// Per Figma (node 19839:13842) disabled uses invert-tertiary for the
// strongly dimmed look, not default-tertiary.
textColor = "text-[color:var(--color-content-invert-tertiary,#2d2d2d)]";
} else if (isSelected) {
background = "bg-[var(--color-surface-inverse-brandaccent,#fdfaa8)]"; // yellow selected
background = "bg-[var(--color-surface-invert-brand-primary,#fefcc9)]"; // yellow selected
border = `${borderWidth} border-[var(--color-border-default-brand-primary,#fdfaa8)]`;
textColor = "text-[color:var(--color-content-inverse-primary,black)]";
textColor = "text-[color:var(--color-content-invert-primary,black)]";
} else {
// Unselected default
background =
@@ -0,0 +1,143 @@
"use client";
import { memo } from "react";
export interface IncrementerProps {
value: number;
/** Minimum value (default `-Infinity`). */
min?: number;
/** Maximum value (default `Infinity`). */
max?: number;
/** Step size applied to +/- actions (default `1`). */
step?: number;
onChange: (_next: number) => void;
/**
* Optional formatter for the displayed value. Receives the raw number and
* should return the rendered content. Default: `String(value)`.
*/
formatValue?: (_value: number) => React.ReactNode;
/**
* When true, the whole incrementer is non-interactive and the value renders
* in the "inactive" (tertiary) color per Figma.
*/
disabled?: boolean;
/** Accessible label for the decrement button (default "Decrease"). */
decrementAriaLabel?: string;
/** Accessible label for the increment button (default "Increase"). */
incrementAriaLabel?: string;
className?: string;
}
const STEP_BUTTON_CLASSES =
"bg-[var(--color-surface-default-secondary,#141414)] text-[var(--color-content-default-primary,#fff)] inline-flex shrink-0 items-center justify-center overflow-clip rounded-[var(--measures-radius-full,9999px)] px-[var(--space-200,8px)] py-[var(--measures-spacing-150,6px)] transition-[background,color,transform] duration-200 ease-in-out hover:scale-[1.02] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary,#fff)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary,#000)] disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:scale-100";
function MinusIcon() {
return (
<svg
aria-hidden
viewBox="0 0 24 24"
className="block size-[12px]"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 12h14"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
}
function PlusIcon() {
return (
<svg
aria-hidden
viewBox="0 0 24 24"
className="block size-[12px]"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 5v14M5 12h14"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
}
/**
* Figma: "Control / Incrementer" (`17857:30943`). A compact `[ - value + ]`
* row used for numeric step inputs (e.g. a percentage setting).
*
* For a labelled variant that matches "Control / Incrementer Block"
* (`19883:13283`), compose with {@link IncrementerBlock} instead.
*/
function IncrementerComponent({
value,
min = Number.NEGATIVE_INFINITY,
max = Number.POSITIVE_INFINITY,
step = 1,
onChange,
formatValue,
disabled = false,
decrementAriaLabel = "Decrease",
incrementAriaLabel = "Increase",
className = "",
}: IncrementerProps) {
const clampedValue = Math.min(Math.max(value, min), max);
const atMin = clampedValue <= min;
const atMax = clampedValue >= max;
const decrement = () => {
if (disabled || atMin) return;
onChange(Math.max(min, clampedValue - step));
};
const increment = () => {
if (disabled || atMax) return;
onChange(Math.min(max, clampedValue + step));
};
const valueColor = disabled
? "text-[color:var(--color-content-default-tertiary,#b4b4b4)]"
: "text-[color:var(--color-content-default-primary,#fff)]";
return (
<div
className={`inline-flex items-center gap-[16px] ${className}`.trim()}
data-figma-node="17857:30943"
>
<button
type="button"
onClick={decrement}
disabled={disabled || atMin}
aria-label={decrementAriaLabel}
className={STEP_BUTTON_CLASSES}
>
<MinusIcon />
</button>
<span
aria-live="polite"
className={`shrink-0 whitespace-nowrap font-inter text-[length:var(--sizing-350,14px)] font-medium leading-[18px] ${valueColor}`}
>
{formatValue ? formatValue(clampedValue) : clampedValue}
</span>
<button
type="button"
onClick={increment}
disabled={disabled || atMax}
aria-label={incrementAriaLabel}
className={STEP_BUTTON_CLASSES}
>
<PlusIcon />
</button>
</div>
);
}
IncrementerComponent.displayName = "Incrementer";
export default memo(IncrementerComponent);
@@ -0,0 +1,72 @@
"use client";
import { memo } from "react";
import Incrementer, { type IncrementerProps } from "./Incrementer";
import InputLabel from "../../utility/InputLabel";
import type {
InputLabelPaletteValue,
InputLabelSizeValue,
} from "../../utility/InputLabel/InputLabel.types";
export interface IncrementerBlockProps extends IncrementerProps {
/** Label text displayed above the incrementer. */
label: string;
/** Show the help "?" icon next to the label. Defaults to `true`. */
helpIcon?: boolean;
/**
* Helper text shown to the right of the label. Pass a string or `true` to
* render the default "Optional text".
*/
helperText?: boolean | string;
/** Show an asterisk indicating a required field. */
asterisk?: boolean;
/**
* Size of the label (`"s"` or `"m"`). Defaults to `"s"` to match the Figma
* "Incrementer Block" spec.
*/
labelSize?: InputLabelSizeValue;
/** Palette. Defaults to `"default"`. */
palette?: InputLabelPaletteValue;
/**
* Class applied to the root `<div>` wrapping the label + incrementer. Use
* this to control the block's layout width (e.g. `w-full`).
*/
blockClassName?: string;
}
/**
* Figma: "Control / Incrementer Block" (`19883:13283`). An `InputLabel` plus
* an {@link Incrementer} row, stacked with a 12px gap.
*/
function IncrementerBlockComponent({
label,
helpIcon = true,
helperText,
asterisk,
labelSize = "s",
palette = "default",
blockClassName = "",
className,
...incrementerProps
}: IncrementerBlockProps) {
return (
<div
className={`flex flex-col items-start gap-[var(--measures-spacing-300,12px)] py-[8px] ${blockClassName}`.trim()}
data-figma-node="19883:13283"
>
<InputLabel
label={label}
helpIcon={helpIcon}
helperText={helperText}
asterisk={asterisk}
size={labelSize}
palette={palette}
/>
<Incrementer {...incrementerProps} className={className} />
</div>
);
}
IncrementerBlockComponent.displayName = "IncrementerBlock";
export default memo(IncrementerBlockComponent);
@@ -0,0 +1,5 @@
export { default } from "./Incrementer";
export { default as Incrementer } from "./Incrementer";
export { default as IncrementerBlock } from "./IncrementerBlock";
export type { IncrementerProps } from "./Incrementer";
export type { IncrementerBlockProps } from "./IncrementerBlock";
@@ -0,0 +1,142 @@
"use client";
/**
* Shared "Applicable Scope" field used by the `decision-approaches` and
* `conflict-management` create flow modals. Pairs an `InputLabel` with a
* horizontally-wrapping list of toggle-chips plus an inline "+ Add" affordance
* that reveals a pill text input for creating new scope values.
*/
import { memo, useState } from "react";
import Chip from "../../components/controls/Chip";
import InputLabel from "../../components/utility/InputLabel";
export interface ApplicableScopeFieldProps {
/** Label rendered above the capsule row. */
label: string;
/** Text for the "+ Add …" affordance (e.g. "Add Applicable Scope"). */
addLabel: string;
/**
* The full list of chip values shown to the user. Each value is a unique
* string (chip label).
*/
scopes: string[];
/** Values currently toggled on (rendered in the Chip "Selected" state). */
selectedScopes: string[];
/** Fired when a chip is clicked; caller toggles inclusion in `selectedScopes`. */
onToggleScope: (_scope: string) => void;
/**
* Fired when the user submits a new scope via the inline input. Duplicate
* values (already in `scopes`) are filtered out before the callback fires.
*/
onAddScope: (_scope: string) => void;
/**
* Optional placeholder for the inline input. Defaults to `addLabel`.
*/
inputPlaceholder?: string;
className?: string;
}
function ApplicableScopeFieldComponent({
label,
addLabel,
scopes,
selectedScopes,
onToggleScope,
onAddScope,
inputPlaceholder,
className = "",
}: ApplicableScopeFieldProps) {
const [draft, setDraft] = useState("");
const [isAdding, setIsAdding] = useState(false);
const submitDraft = () => {
const trimmed = draft.trim();
if (!trimmed) {
setIsAdding(false);
setDraft("");
return;
}
if (!scopes.includes(trimmed)) {
onAddScope(trimmed);
}
setDraft("");
setIsAdding(false);
};
return (
<div className={`flex flex-col gap-2 ${className}`.trim()}>
<InputLabel label={label} helpIcon size="s" palette="default" />
<div className="flex flex-wrap items-center gap-2">
{scopes.map((scope) => {
const isSelected = selectedScopes.includes(scope);
return (
<Chip
key={scope}
label={scope}
state={isSelected ? "Selected" : "Disabled"}
palette="Default"
size="S"
disabled={false}
onClick={() => onToggleScope(scope)}
ariaLabel={`${isSelected ? "Deselect" : "Select"} ${scope}`}
/>
);
})}
{isAdding ? (
<input
type="text"
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={submitDraft}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submitDraft();
} else if (e.key === "Escape") {
setDraft("");
setIsAdding(false);
}
}}
placeholder={inputPlaceholder ?? addLabel}
aria-label={inputPlaceholder ?? addLabel}
className="h-[30px] rounded-[9999px] border border-[var(--color-border-default-tertiary)] bg-transparent px-3 font-inter text-[length:var(--sizing-300,12px)] font-medium leading-[14px] text-[color:var(--color-content-default-primary)] outline-none placeholder:text-[color:var(--color-content-default-tertiary)] focus-visible:border-[var(--color-border-default-brand-primary)]"
/>
) : (
<button
type="button"
onClick={() => setIsAdding(true)}
className="inline-flex items-center gap-[var(--measures-spacing-050,2px)] rounded-[var(--measures-radius-full,9999px)] px-[var(--space-250,10px)] py-[var(--measures-spacing-200,8px)] font-inter text-[length:var(--sizing-300,12px)] font-medium leading-[14px] text-[color:var(--color-content-default-primary)] hover:bg-[var(--color-surface-default-secondary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-transparent"
>
<AddGlyph />
{addLabel}
</button>
)}
</div>
</div>
);
}
function AddGlyph() {
return (
<svg
aria-hidden
viewBox="0 0 24 24"
className="block size-[14px]"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 5v14M5 12h14"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
}
ApplicableScopeFieldComponent.displayName = "ApplicableScopeField";
export default memo(ApplicableScopeFieldComponent);
@@ -0,0 +1,60 @@
"use client";
/**
* Shared "labelled text area" field used by every create flow modal section.
* Pairs an `InputLabel` (with help icon) with a `TextArea` set to the embedded
* appearance — matching the Figma "Control / Text Area" pattern.
*/
import { memo } from "react";
import TextArea from "../../components/controls/TextArea";
import InputLabel from "../../components/utility/InputLabel";
export interface ModalTextAreaFieldProps {
/** Label rendered above the text area. */
label: string;
/** Show the help "?" icon next to the label (default `true`). */
helpIcon?: boolean;
/** Current text value. */
value: string;
/** Fired on every change with the new value (no event). */
onChange: (_value: string) => void;
/** Optional rows for the underlying `<textarea>` (default 4). */
rows?: number;
/** Optional placeholder. */
placeholder?: string;
/** Disable the field. */
disabled?: boolean;
className?: string;
}
function ModalTextAreaFieldComponent({
label,
helpIcon = true,
value,
onChange,
rows = 4,
placeholder,
disabled = false,
className = "",
}: ModalTextAreaFieldProps) {
return (
<div className={`flex flex-col gap-2 ${className}`.trim()}>
<InputLabel label={label} helpIcon={helpIcon} size="s" palette="default" />
<TextArea
formHeader={false}
value={value}
onChange={(e) => onChange(e.target.value)}
size="large"
rows={rows}
appearance="embedded"
placeholder={placeholder}
disabled={disabled}
/>
</div>
);
}
ModalTextAreaFieldComponent.displayName = "ModalTextAreaField";
export default memo(ModalTextAreaFieldComponent);
+2 -2
View File
@@ -14,7 +14,7 @@ import { FinalReviewScreen } from "./review/FinalReviewScreen";
import { CommunicationMethodsScreen } from "./card/CommunicationMethodsScreen";
import { MembershipMethodsScreen } from "./card/MembershipMethodsScreen";
import { ConflictManagementScreen } from "./card/ConflictManagementScreen";
import { RightRailScreen } from "./right-rail/RightRailScreen";
import { DecisionApproachesScreen } from "./right-rail/DecisionApproachesScreen";
import { CompletedScreen } from "./completed/CompletedScreen";
/**
@@ -76,7 +76,7 @@ export function CreateFlowScreenView({
case "membership-methods":
return <MembershipMethodsScreen />;
case "decision-approaches":
return <RightRailScreen />;
return <DecisionApproachesScreen />;
case "conflict-management":
return <ConflictManagementScreen />;
case "confirm-stakeholders":
@@ -16,23 +16,18 @@ import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import CardStack from "../../../components/utility/CardStack";
import Create from "../../../components/modals/Create";
import TextArea from "../../../components/controls/TextArea";
import InlineTextButton from "../../../components/buttons/InlineTextButton";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} 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 ADD_PLATFORM_CARD_IDS = [
IN_PERSON_CARD_ID,
SIGNAL_CARD_ID,
VIDEO_MEETINGS_CARD_ID,
] as const;
const SECTION_FIELDS = [
"corePrinciple",
"logisticsAdmin",
@@ -50,40 +45,6 @@ const COMMUNICATION_CARD_ORDER = [
"7",
] as const;
function CreateModalSection({
title,
value: _value,
onChange,
}: {
title: string;
value: string;
onChange: (_value: string) => void;
}) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold leading-tight text-[var(--color-content-default-primary)]">
{title}
</h3>
<span
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-[var(--color-content-invert-brand-secondary)] bg-transparent text-[10px] font-medium leading-none text-[var(--color-content-invert-brand-secondary)]"
aria-hidden
>
?
</span>
</div>
<TextArea
formHeader={false}
value={_value}
onChange={(e) => onChange(e.target.value)}
size="large"
rows={6}
appearance="embedded"
/>
</div>
);
}
function AddPlatformModalContent({
platformCardId,
}: {
@@ -92,15 +53,15 @@ function AddPlatformModalContent({
const { markCreateFlowInteraction } = useCreateFlow();
const m = useMessages();
const comm = m.create.communication;
const modal = comm.modals[platformCardId as keyof typeof comm.modals];
const defaults =
modal && "sections" in modal
? modal.sections
: {
corePrinciple: "",
logisticsAdmin: "",
codeOfConduct: "",
};
const modal =
platformCardId in comm.modals
? comm.modals[platformCardId as keyof typeof comm.modals]
: null;
const defaults = modal?.sections ?? {
corePrinciple: "",
logisticsAdmin: "",
codeOfConduct: "",
};
const [sectionValues, setSectionValues] = useState<
Record<SectionField, string>
@@ -118,14 +79,13 @@ function AddPlatformModalContent({
[markCreateFlowInteraction],
);
if (!modal || !("sections" in modal)) return null;
return (
<div className="flex flex-col gap-6">
{SECTION_FIELDS.map((field) => (
<CreateModalSection
<ModalTextAreaField
key={field}
title={comm.sectionHeadings[field]}
label={comm.sectionHeadings[field]}
rows={6}
value={sectionValues[field]}
onChange={(v) => updateSection(field, v)}
/>
@@ -134,13 +94,6 @@ function AddPlatformModalContent({
);
}
function isAddPlatformCard(cardId: string | null): boolean {
return (
cardId !== null &&
(ADD_PLATFORM_CARD_IDS as readonly string[]).includes(cardId)
);
}
export function CommunicationMethodsScreen() {
const m = useMessages();
const comm = m.create.communication;
@@ -180,42 +133,55 @@ export function CommunicationMethodsScreen() {
) : (
<>
{comm.page.compactDescriptionBefore}
<button
type="button"
className="cursor-pointer border-none bg-transparent p-0 font-inherit text-[length:inherit] leading-[inherit] text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2 hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)]"
<InlineTextButton
onClick={() => {
markCreateFlowInteraction();
setExpanded(true);
}}
>
{comm.page.compactDescriptionLinkLabel}
</button>
</InlineTextButton>
{comm.page.compactDescriptionAfter}
</>
);
const modalConfig =
pendingCardId && 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,
};
})()
: {
title: comm.confirmModal.title,
description: comm.confirmModal.description,
nextButtonText: comm.confirmModal.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
const modalConfig = (() => {
if (!pendingCardId) {
return {
title: comm.confirmModal.title,
description: comm.confirmModal.description,
nextButtonText: comm.confirmModal.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
}
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;
return {
title: cardRow?.label ?? comm.confirmModal.title,
description: cardRow?.supportText ?? comm.confirmModal.description,
nextButtonText: comm.addPlatform.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
})();
const handleCardClick = useCallback(
(id: string) => {
@@ -286,8 +252,9 @@ export function CommunicationMethodsScreen() {
showBackButton={modalConfig.showBackButton}
currentStep={modalConfig.currentStep}
totalSteps={modalConfig.totalSteps}
backdropVariant="loginYellow"
>
{isAddPlatformCard(pendingCardId) && pendingCardId ? (
{pendingCardId ? (
<AddPlatformModalContent
key={pendingCardId}
platformCardId={pendingCardId}
@@ -3,6 +3,12 @@
/**
* `conflict-management` step — Figma compact card stack (node `20879-15979`).
* Registry: `CREATE_FLOW_SCREEN_REGISTRY["conflict-management"]`.
*
* 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
* content; labels are hard-coded per the Figma design.
*/
import { useState, useCallback, useMemo } from "react";
@@ -12,11 +18,14 @@ import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import CardStack from "../../../components/utility/CardStack";
import Create from "../../../components/modals/Create";
import InlineTextButton from "../../../components/buttons/InlineTextButton";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
import ModalTextAreaField from "../../components/ModalTextAreaField";
import ApplicableScopeField from "../../components/ApplicableScopeField";
const CONFLICT_CARD_ORDER = [
"peer-mediation",
@@ -29,6 +38,92 @@ const CONFLICT_CARD_ORDER = [
"8",
] as const;
type ConflictModalSections = {
corePrinciple: string;
applicableScope: string[];
selectedApplicableScope: string[];
processProtocol: string;
restorationFallbacks: string;
};
function AddConflictApproachModalContent({
approachCardId,
}: {
approachCardId: string;
}) {
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 defaults: ConflictModalSections = {
corePrinciple: modalSections?.corePrinciple ?? "",
applicableScope: modalSections?.applicableScope ?? [],
selectedApplicableScope: [],
processProtocol: modalSections?.processProtocol ?? "",
restorationFallbacks: modalSections?.restorationFallbacks ?? "",
};
const [sections, setSections] = useState<ConflictModalSections>(() => ({
corePrinciple: defaults.corePrinciple,
applicableScope: [...defaults.applicableScope],
selectedApplicableScope: [...defaults.selectedApplicableScope],
processProtocol: defaults.processProtocol,
restorationFallbacks: defaults.restorationFallbacks,
}));
const patch = useCallback(
<K extends keyof ConflictModalSections>(
key: K,
value: ConflictModalSections[K],
) => {
markCreateFlowInteraction();
setSections((prev) => ({ ...prev, [key]: value }));
},
[markCreateFlowInteraction],
);
return (
<div className="flex flex-col gap-6">
<ModalTextAreaField
label={cm.sectionHeadings.corePrinciple}
value={sections.corePrinciple}
onChange={(v) => patch("corePrinciple", v)}
/>
<ApplicableScopeField
label={cm.sectionHeadings.applicableScope}
addLabel={cm.scopeAddButtonLabel}
scopes={sections.applicableScope}
selectedScopes={sections.selectedApplicableScope}
onToggleScope={(scope) =>
patch(
"selectedApplicableScope",
sections.selectedApplicableScope.includes(scope)
? sections.selectedApplicableScope.filter((s) => s !== scope)
: [...sections.selectedApplicableScope, scope],
)
}
onAddScope={(scope) =>
patch("applicableScope", [...sections.applicableScope, scope])
}
/>
<ModalTextAreaField
label={cm.sectionHeadings.processProtocol}
value={sections.processProtocol}
onChange={(v) => patch("processProtocol", v)}
/>
<ModalTextAreaField
label={cm.sectionHeadings.restorationFallbacks}
value={sections.restorationFallbacks}
onChange={(v) => patch("restorationFallbacks", v)}
/>
</div>
);
}
export function ConflictManagementScreen() {
const m = useMessages();
const cm = m.create.conflictManagement;
@@ -68,41 +163,55 @@ export function ConflictManagementScreen() {
) : (
<>
{cm.page.compactDescriptionBefore}
<button
type="button"
className="cursor-pointer border-none bg-transparent p-0 font-inherit text-[length:inherit] leading-[inherit] text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2 hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)]"
<InlineTextButton
onClick={() => {
markCreateFlowInteraction();
setExpanded(true);
}}
>
{cm.page.compactDescriptionLinkLabel}
</button>
</InlineTextButton>
{cm.page.compactDescriptionAfter}
</>
);
const modalConfig =
pendingCardId && pendingCardId in cm.modals
? (() => {
const modal = cm.modals[pendingCardId as keyof typeof cm.modals];
return {
title: modal.title,
description: modal.description,
nextButtonText: cm.confirmModal.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
})()
: {
title: cm.confirmModal.title,
description: cm.confirmModal.description,
nextButtonText: cm.confirmModal.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
const modalConfig = (() => {
if (!pendingCardId) {
return {
title: cm.confirmModal.title,
description: cm.confirmModal.description,
nextButtonText: cm.confirmModal.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
}
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;
return {
title: cardRow?.label ?? cm.confirmModal.title,
description: cardRow?.supportText ?? cm.confirmModal.description,
nextButtonText: cm.addApproach.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
})();
const handleCardClick = useCallback(
(id: string) => {
@@ -173,7 +282,15 @@ export function ConflictManagementScreen() {
showBackButton={modalConfig.showBackButton}
currentStep={modalConfig.currentStep}
totalSteps={modalConfig.totalSteps}
/>
backdropVariant="loginYellow"
>
{pendingCardId ? (
<AddConflictApproachModalContent
key={pendingCardId}
approachCardId={pendingCardId}
/>
) : null}
</Create>
</CreateFlowStepShell>
);
}
@@ -3,6 +3,12 @@
/**
* `membership-methods` step — Figma compact card stack (node `20858-13947`).
* Registry: `CREATE_FLOW_SCREEN_REGISTRY["membership-methods"]`.
*
* 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
* content.
*/
import { useState, useCallback, useMemo } from "react";
@@ -12,11 +18,20 @@ import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import CardStack from "../../../components/utility/CardStack";
import Create from "../../../components/modals/Create";
import InlineTextButton from "../../../components/buttons/InlineTextButton";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
import ModalTextAreaField from "../../components/ModalTextAreaField";
const SECTION_FIELDS = [
"eligibility",
"joiningProcess",
"expectations",
] as const;
type SectionField = (typeof SECTION_FIELDS)[number];
const MEMBERSHIP_CARD_ORDER = [
"open-access",
@@ -29,6 +44,55 @@ const MEMBERSHIP_CARD_ORDER = [
"8",
] as const;
function AddMembershipModalContent({
membershipCardId,
}: {
membershipCardId: string;
}) {
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 [sectionValues, setSectionValues] = useState<
Record<SectionField, string>
>(() => ({
eligibility: defaults.eligibility,
joiningProcess: defaults.joiningProcess,
expectations: defaults.expectations,
}));
const updateSection = useCallback(
(key: SectionField, value: string) => {
markCreateFlowInteraction();
setSectionValues((prev) => ({ ...prev, [key]: value }));
},
[markCreateFlowInteraction],
);
return (
<div className="flex flex-col gap-6">
{SECTION_FIELDS.map((field) => (
<ModalTextAreaField
key={field}
label={mem.sectionHeadings[field]}
rows={6}
value={sectionValues[field]}
onChange={(v) => updateSection(field, v)}
/>
))}
</div>
);
}
export function MembershipMethodsScreen() {
const m = useMessages();
const mem = m.create.membership;
@@ -68,41 +132,55 @@ export function MembershipMethodsScreen() {
) : (
<>
{mem.page.compactDescriptionBefore}
<button
type="button"
className="cursor-pointer border-none bg-transparent p-0 font-inherit text-[length:inherit] leading-[inherit] text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2 hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)]"
<InlineTextButton
onClick={() => {
markCreateFlowInteraction();
setExpanded(true);
}}
>
{mem.page.compactDescriptionLinkLabel}
</button>
</InlineTextButton>
{mem.page.compactDescriptionAfter}
</>
);
const modalConfig =
pendingCardId && pendingCardId in mem.modals
? (() => {
const modal = mem.modals[pendingCardId as keyof typeof mem.modals];
return {
title: modal.title,
description: modal.description,
nextButtonText: mem.confirmModal.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
})()
: {
title: mem.confirmModal.title,
description: mem.confirmModal.description,
nextButtonText: mem.confirmModal.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
const modalConfig = (() => {
if (!pendingCardId) {
return {
title: mem.confirmModal.title,
description: mem.confirmModal.description,
nextButtonText: mem.confirmModal.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
}
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;
return {
title: cardRow?.label ?? mem.confirmModal.title,
description: cardRow?.supportText ?? mem.confirmModal.description,
nextButtonText: mem.addPlatform.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
})();
const handleCardClick = useCallback(
(id: string) => {
@@ -173,7 +251,15 @@ export function MembershipMethodsScreen() {
showBackButton={modalConfig.showBackButton}
currentStep={modalConfig.currentStep}
totalSteps={modalConfig.totalSteps}
/>
backdropVariant="loginYellow"
>
{pendingCardId ? (
<AddMembershipModalContent
key={pendingCardId}
membershipCardId={pendingCardId}
/>
) : null}
</Create>
</CreateFlowStepShell>
);
}
@@ -0,0 +1,320 @@
"use client";
/**
* `decision-approaches` step — Figma “Flow — Right Rail” (node `20523-23509`).
* Registry: `CREATE_FLOW_SCREEN_REGISTRY["decision-approaches"]` (`layoutKind: "right-rail"`).
*
* Layout matches {@link CreateFlowTwoColumnSelectShell}: one column below `lg` (1024px), two columns
* at `lg+` with a scrollable rail — same breakpoint and height chain as select steps, distinct content.
*
* 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.
*/
import { useState, useCallback, useMemo } from "react";
import DecisionMakingSidebar from "../../../components/utility/DecisionMakingSidebar";
import CardStack from "../../../components/utility/CardStack";
import Create from "../../../components/modals/Create";
import { IncrementerBlock } from "../../../components/controls/Incrementer";
import InlineTextButton from "../../../components/buttons/InlineTextButton";
import type { InfoMessageBoxItem } from "../../../components/utility/InfoMessageBox/InfoMessageBox.types";
import type { CardStackItem } from "../../../components/utility/CardStack/CardStack.types";
import { useMessages } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
import ModalTextAreaField from "../../components/ModalTextAreaField";
import ApplicableScopeField from "../../components/ApplicableScopeField";
const CONSENSUS_LEVEL_MIN = 0;
const CONSENSUS_LEVEL_MAX = 100;
const CONSENSUS_LEVEL_STEP = 5;
const CONSENSUS_LEVEL_DEFAULT = 75;
type RightRailModalSections = {
corePrinciple: string;
applicableScope: string[];
selectedApplicableScope: string[];
stepByStepInstructions: string;
consensusLevel: number;
objectionsDeadlocks: string;
};
function AddDecisionApproachModalContent({
approachCardId,
}: {
approachCardId: string;
}) {
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 defaults: RightRailModalSections = {
corePrinciple: modalSections?.corePrinciple ?? "",
applicableScope: modalSections?.applicableScope ?? [],
selectedApplicableScope: [],
stepByStepInstructions: modalSections?.stepByStepInstructions ?? "",
consensusLevel: modalSections?.consensusLevel ?? CONSENSUS_LEVEL_DEFAULT,
objectionsDeadlocks: modalSections?.objectionsDeadlocks ?? "",
};
const [sections, setSections] = useState<RightRailModalSections>(() => ({
corePrinciple: defaults.corePrinciple,
applicableScope: [...defaults.applicableScope],
selectedApplicableScope: [...defaults.selectedApplicableScope],
stepByStepInstructions: defaults.stepByStepInstructions,
consensusLevel: defaults.consensusLevel,
objectionsDeadlocks: defaults.objectionsDeadlocks,
}));
const patch = useCallback(
<K extends keyof RightRailModalSections>(
key: K,
value: RightRailModalSections[K],
) => {
markCreateFlowInteraction();
setSections((prev) => ({ ...prev, [key]: value }));
},
[markCreateFlowInteraction],
);
return (
<div className="flex flex-col gap-6">
<ModalTextAreaField
label={rr.sectionHeadings.corePrinciple}
value={sections.corePrinciple}
onChange={(v) => patch("corePrinciple", v)}
/>
<ApplicableScopeField
label={rr.sectionHeadings.applicableScope}
addLabel={rr.scopeAddButtonLabel}
scopes={sections.applicableScope}
selectedScopes={sections.selectedApplicableScope}
onToggleScope={(scope) =>
patch(
"selectedApplicableScope",
sections.selectedApplicableScope.includes(scope)
? sections.selectedApplicableScope.filter((s) => s !== scope)
: [...sections.selectedApplicableScope, scope],
)
}
onAddScope={(scope) =>
patch("applicableScope", [...sections.applicableScope, scope])
}
/>
<ModalTextAreaField
label={rr.sectionHeadings.stepByStepInstructions}
value={sections.stepByStepInstructions}
onChange={(v) => patch("stepByStepInstructions", v)}
/>
<IncrementerBlock
label={rr.sectionHeadings.consensusLevel}
value={sections.consensusLevel}
min={CONSENSUS_LEVEL_MIN}
max={CONSENSUS_LEVEL_MAX}
step={CONSENSUS_LEVEL_STEP}
onChange={(next) => patch("consensusLevel", next)}
formatValue={(v) => `${v}%`}
decrementAriaLabel="Decrease consensus level"
incrementAriaLabel="Increase consensus level"
/>
<ModalTextAreaField
label={rr.sectionHeadings.objectionsDeadlocks}
value={sections.objectionsDeadlocks}
onChange={(v) => patch("objectionsDeadlocks", v)}
/>
</div>
);
}
export function DecisionApproachesScreen() {
const m = useMessages();
const rr = m.create.rightRail;
const mdUp = useCreateFlowMdUp();
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState<string[]>(
[],
);
const [expanded, setExpanded] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
const selectedIds = state.selectedDecisionApproachIds ?? [];
const setSelectedIds = useCallback(
(next: string[]) => {
updateState({ selectedDecisionApproachIds: next });
},
[updateState],
);
const messageBoxItems: InfoMessageBoxItem[] = useMemo(
() =>
rr.messageBox.items.map((item) => ({
id: item.id,
label: item.label,
})),
[rr.messageBox.items],
);
const sampleCards: CardStackItem[] = useMemo(
() =>
rr.cards.map((c) => ({
id: c.id,
label: c.label,
supportText: c.supportText,
recommended: c.recommended,
})),
[rr.cards],
);
const cardById = useMemo(
() => new Map(rr.cards.map((c) => [c.id, c])),
[rr.cards],
);
const sidebarDescription = (
<>
{rr.sidebar.descriptionBefore}
<InlineTextButton
onClick={() => {
markCreateFlowInteraction();
setExpanded(true);
}}
>
{rr.sidebar.descriptionLinkLabel}
</InlineTextButton>
{rr.sidebar.descriptionAfter}
</>
);
const handleMessageBoxCheckboxChange = useCallback(
(id: string, checked: boolean) => {
markCreateFlowInteraction();
setMessageBoxCheckedIds((prev) =>
checked ? [...prev, id] : prev.filter((x) => x !== id),
);
},
[markCreateFlowInteraction],
);
const handleCardSelect = useCallback(
(id: string) => {
markCreateFlowInteraction();
setPendingCardId(id);
setCreateModalOpen(true);
},
[markCreateFlowInteraction],
);
const handleToggleExpand = useCallback(() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}, [markCreateFlowInteraction]);
const handleCreateModalClose = useCallback(() => {
setCreateModalOpen(false);
setPendingCardId(null);
}, []);
const handleCreateModalConfirm = useCallback(() => {
markCreateFlowInteraction();
if (pendingCardId) {
setSelectedIds(
selectedIds.includes(pendingCardId)
? selectedIds
: [...selectedIds, pendingCardId],
);
}
setCreateModalOpen(false);
setPendingCardId(null);
}, [markCreateFlowInteraction, pendingCardId, selectedIds, setSelectedIds]);
const modalConfig = (() => {
if (!pendingCardId) {
return {
title: rr.confirmModal.title,
description: rr.confirmModal.description,
nextButtonText: rr.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);
return {
title: card?.label ?? rr.confirmModal.title,
description: card?.supportText ?? rr.confirmModal.description,
nextButtonText: rr.addApproach.nextButtonText,
};
})();
return (
<CreateFlowTwoColumnSelectShell
contentTopBelowMd="space-800"
lgVerticalAlign="start"
header={
<DecisionMakingSidebar
title={rr.sidebar.title}
description={sidebarDescription}
messageBoxTitle={rr.messageBox.title}
messageBoxItems={messageBoxItems}
messageBoxCheckedIds={messageBoxCheckedIds}
onMessageBoxCheckboxChange={handleMessageBoxCheckboxChange}
size={mdUp ? "L" : "M"}
justification={mdUp ? "left" : "center"}
/>
}
>
<div className="flex w-full min-w-0 flex-col items-stretch gap-6 py-0">
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardSelect}
expanded={expanded}
onToggleExpand={handleToggleExpand}
hasMore={true}
toggleLabel={rr.cardStack.toggleSeeAll}
showLessLabel={rr.cardStack.toggleShowLess}
title=""
description=""
layout="singleStack"
compactRecommendedLimit={5}
className="w-full"
headerLockupSize={mdUp ? "L" : "M"}
/>
</div>
<Create
isOpen={createModalOpen}
onClose={handleCreateModalClose}
onNext={handleCreateModalConfirm}
title={modalConfig.title}
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
showBackButton={false}
backdropVariant="loginYellow"
>
{pendingCardId ? (
<AddDecisionApproachModalContent
key={pendingCardId}
approachCardId={pendingCardId}
/>
) : null}
</Create>
</CreateFlowTwoColumnSelectShell>
);
}
@@ -1,141 +0,0 @@
"use client";
/**
* `decision-approaches` step — Figma “Flow — Right Rail” (node `20523-23509`).
* Registry: `CREATE_FLOW_SCREEN_REGISTRY["decision-approaches"]` (`layoutKind: "right-rail"`).
*
* Layout matches {@link CreateFlowTwoColumnSelectShell}: one column below `lg` (1024px), two columns
* at `lg+` with a scrollable rail — same breakpoint and height chain as select steps, distinct content.
*/
import { useState, useCallback, useMemo } from "react";
import DecisionMakingSidebar from "../../../components/utility/DecisionMakingSidebar";
import CardStack from "../../../components/utility/CardStack";
import type { InfoMessageBoxItem } from "../../../components/utility/InfoMessageBox/InfoMessageBox.types";
import type { CardStackItem } from "../../../components/utility/CardStack/CardStack.types";
import { useMessages } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
export function RightRailScreen() {
const m = useMessages();
const rr = m.create.rightRail;
const mdUp = useCreateFlowMdUp();
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState<string[]>(
[],
);
const [expanded, setExpanded] = useState(false);
const selectedIds = state.selectedDecisionApproachIds ?? [];
const setSelectedIds = useCallback(
(next: string[]) => {
updateState({ selectedDecisionApproachIds: next });
},
[updateState],
);
const messageBoxItems: InfoMessageBoxItem[] = useMemo(
() =>
rr.messageBox.items.map((item) => ({
id: item.id,
label: item.label,
})),
[rr.messageBox.items],
);
const sampleCards: CardStackItem[] = useMemo(
() =>
rr.cards.map((c) => ({
id: c.id,
label: c.label,
supportText: c.supportText,
recommended: c.recommended,
})),
[rr.cards],
);
const sidebarDescription = (
<>
{rr.sidebar.descriptionBefore}
<button
type="button"
className="cursor-pointer border-none bg-transparent p-0 font-inherit text-[length:inherit] leading-[inherit] text-[var(--color-content-default-tertiary)] underline decoration-solid underline-offset-2 hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-border-invert-primary)]"
onClick={() => {
markCreateFlowInteraction();
setExpanded(true);
}}
>
{rr.sidebar.descriptionLinkLabel}
</button>
{rr.sidebar.descriptionAfter}
</>
);
const handleMessageBoxCheckboxChange = useCallback(
(id: string, checked: boolean) => {
markCreateFlowInteraction();
setMessageBoxCheckedIds((prev) =>
checked ? [...prev, id] : prev.filter((x) => x !== id),
);
},
[markCreateFlowInteraction],
);
const handleCardSelect = useCallback(
(id: string) => {
markCreateFlowInteraction();
setSelectedIds(
selectedIds.includes(id)
? selectedIds.filter((x) => x !== id)
: [...selectedIds, id],
);
},
[markCreateFlowInteraction, selectedIds, setSelectedIds],
);
const handleToggleExpand = useCallback(() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}, [markCreateFlowInteraction]);
return (
<CreateFlowTwoColumnSelectShell
contentTopBelowMd="space-800"
lgVerticalAlign="start"
header={
<DecisionMakingSidebar
title={rr.sidebar.title}
description={sidebarDescription}
messageBoxTitle={rr.messageBox.title}
messageBoxItems={messageBoxItems}
messageBoxCheckedIds={messageBoxCheckedIds}
onMessageBoxCheckboxChange={handleMessageBoxCheckboxChange}
size={mdUp ? "L" : "M"}
justification={mdUp ? "left" : "center"}
/>
}
>
<div className="flex w-full min-w-0 flex-col items-stretch gap-6 py-0">
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardSelect}
expanded={expanded}
onToggleExpand={handleToggleExpand}
hasMore={true}
toggleLabel={rr.cardStack.toggleSeeAll}
showLessLabel={rr.cardStack.toggleShowLess}
title=""
description=""
layout="singleStack"
compactRecommendedLimit={5}
className="w-full"
headerLockupSize={mdUp ? "L" : "M"}
/>
</div>
</CreateFlowTwoColumnSelectShell>
);
}
+728
View File
@@ -0,0 +1,728 @@
# Template Recommendation Matrix — Implementation Context (CR-88)
**Status:** Draft / context doc. Reference only — not yet implemented.
**Linear:** [CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-xlsx-sheets-ingestion)
**Roadmap:** [`docs/backend-roadmap.md`](backend-roadmap.md) §4 (`RuleTemplate`) and §13.
**Spec ticket:** [`docs/backend-linear-tickets.md`](backend-linear-tickets.md) Ticket 16.
This doc consolidates the **four product-authored matrix spreadsheets** with the
**existing data model, create-flow facets, and section structure** so we have a
single reference while implementing the importer + recommendation API.
> **Scope note:** No data, API, or UI surface for this feature is in production
> yet. **Backwards compatibility is not a constraint** — we will replace the
> hand-typed `prisma/seed.ts` `COMPOSITION_BY_SLUG` map, the existing
> `GET /api/templates` response shape, and the static `messages/en/create/*.json`
> card decks where it makes the design cleaner.
---
## 1. Goal (one paragraph)
Replace the hand-curated `prisma/seed.ts` `COMPOSITION_BY_SLUG` map with a
**spreadsheet-authored matrix** for each rule section
(Communication, Membership, Decision-making, Conflict management — and later
Values), where each row is a **method/pattern card** and each column is either
**long-form copy that populates the card UI** or a **facet flag** (✓/x or score)
that the recommendation engine uses to filter and rank cards based on the
user's create-flow answers (community size, organization type, location/scale,
maturity).
The same authoring contract should make it trivial for product to ship updated
spreadsheets and have the create-flow card decks (and the home/templates page
recommendations) update without any code changes.
---
## 2. The four spreadsheets
All four xlsx files share **the same column shape**: leading **content
columns** + trailing **facet columns** (✓ / x cells). Sheet name is `Current`
in every workbook.
### 2.1 Shared facet columns (last 19, identical across the four sheets)
Order is preserved here because the columns are positional in the sheets:
| # | Column header (xlsx) | Maps to wizard step / state field | Wizard chip label (`messages/en/create/...`) |
|---|---|---|---|
| 1 | `1 member` | `community-size``selectedCommunitySizeIds` (id `"1"`) | `1 member` |
| 2 | `2-5 members` | id `"2"` | `2-5 members` |
| 3 | `6-12 members` | id `"3"` | `6-12 members` |
| 4 | `13-100 members` | id `"4"` | `13-100 members` |
| 5 | `100-100,000 members` | id `"5"` | `100-100,000 members` |
| 6 | `Organization Type:DAO` (or `DAO` in conflict/comms/membership) | `community-structure``selectedOrganizationTypeIds` (id `"6"` in `organizationTypes`) | `DAO` |
| 7 | `Organization Type:For profit business` (or `For profit business`) | id `"5"` in `organizationTypes` | `For profit business` |
| 8 | `Organization Type:Nonprofit` (or `Nonprofit`) | id `"4"` in `organizationTypes` | `Nonprofit` |
| 9 | `Organization Type:Open source project` (or `Open source project`) | id `"3"` | `Open source project` |
| 10 | `Organization Type:Mutual aid` (or `Mutual aid`) | id `"2"` | `Mutual aid` |
| 11 | `Organization Type: Workers coop` (or `Workers coop`) | id `"1"` | `Workers coop` |
| 12 | `Location: Global` (or `Global`) | `community-structure``selectedScaleIds` (id `"4"` in `scaleOptions`) | `Global` |
| 13 | `Location: National` (or `National`) | id `"3"` | `National` |
| 14 | `Location: Regional` (or `Regional`) | id `"2"` | `Regional` |
| 15 | `Location: Local` (or `Local`) | id `"1"` | `Local` |
| 16 | `Organizational Maturity: Early stage` (or `Early stage`) | `community-structure``selectedMaturityIds` (id `"1"` in `maturityOptions`) | `Early stage` |
| 17 | `Organizational Maturity: Growth stage` (or `Growth stage`) | id `"2"` | `Growth stage` |
| 18 | `Organizational Maturity: Established` (or `Established`) | id `"3"` | `Established` |
| 19 | `Organizational Maturity: Enterprise` (or `Enterprise`) | id `"4"` | `Enterprise` |
**Important normalization rules (importer must enforce):**
- Decision-making prefixes columns with `Organization Type:`, `Location:`,
`Organizational Maturity:`. The other three sheets drop the prefix. Importer
should normalize to a single canonical key (e.g.
`orgType.workersCoop`, `scale.local`, `maturity.earlyStage`, `size.6_12`).
- Cell value semantics: `✓` → match, `x` (lowercase) → no match, blank → no
match, numbers → optional weighted score (only `Decision-making.xlsx` row 32
contains a non-symbol cell — `"Military, Corporations"` in the size column —
see §2.4 data-quality issues).
- Wizard chip ids are **positional 1..N** within each `messages/en/create/*`
array (see `chipRowsFromLabels` in
`app/create/screens/select/CommunityStructureSelectScreen.tsx` lines 4957).
The importer should emit a stable lookup table mapping
`(facetGroup, label) → wizardChipId` so the recommendation engine can match
a user's `selectedXxxIds` against the matrix without depending on label
spelling.
- Curly apostrophes appear in `Workers coop`. Compare on a normalized key,
not on raw label.
### 2.2 Communication Methods (`Communication Methods.xlsx`, sheet `Current`)
Maps 1:1 to `messages/en/create/communication.json` and the
`communication-methods` step
(`app/create/screens/card/CommunicationMethodsScreen.tsx`).
**Content columns (positions 15):**
| Sheet column | Card field |
|---|---|
| `Label` | `cards[<id>].label` and `modals[<id>].title` |
| `Description` | `cards[<id>].supportText` and `modals[<id>].description` |
| `Core Principle & Scope` | `modals[<id>].sections.corePrinciple` |
| `Logistics, Admin & Norms` | `modals[<id>].sections.logisticsAdmin` |
| `Code of Conduct` | `modals[<id>].sections.codeOfConduct` |
`SECTION_FIELDS = ["corePrinciple", "logisticsAdmin", "codeOfConduct"]` is
the source of truth (`CommunicationMethodsScreen.tsx`).
**Card rows (11):** In-Person Meetings · Signal · Video Meetings · Loomio ·
Matrix / Element · GitHub / GitLab · Discord · Email Distribution List · Slack
· WhatsApp · Discourse (Forum).
### 2.3 Membership / Group-Membership (`Group_Membership_Methods.xlsx`, sheet `Current`)
Maps to the `membership-methods` step
(`app/create/screens/card/MembershipMethodsScreen.tsx`) and
`messages/en/create/membership.json`.
**Content columns (positions 15):**
| Sheet column | Card field (proposed naming) |
|---|---|
| `Label` | `cards[<id>].label` / modal title |
| `Description` | `cards[<id>].supportText` / modal description |
| `Eligibility & Philosophy` | modal section A (`eligibilityPhilosophy`) |
| `Joining Process` | modal section B (`joiningProcess`) |
| `Expectations & Removal` | modal section C (`expectationsRemoval`) |
**Card rows (19):** Open Access · Orientation Required · Invitation Only ·
Contribution Based · Mentorship · Peer Sponsorship · Consensus or Vote-Based
Approval · Trial Period / Provisional Membership · Referral System with
Screening · Membership Agreement or Pledge · Weighted or Tiered Membership ·
Hybrid Approval Process · Skill-Based Contribution · Pay-to-Join · Application
& Review · Identity Verification · Collective Interviews · Skill-Based
Evaluation · Lottery / Sortition.
> The wizard's existing `membership.json` modal section keys do not yet match
> these. Since backwards compatibility is not a constraint, **rename the
> wizard's section keys to match the matrix** (`eligibilityPhilosophy` /
> `joiningProcess` / `expectationsRemoval`) when wiring this up — the existing
> copy is placeholder.
### 2.4 Decision-making (`Decision-making.xlsx`, sheet `Current`)
Maps to the `decision-approaches` step
(`app/create/screens/right-rail/DecisionApproachesScreen.tsx`) and
`messages/en/create/rightRail.json`.
**Content columns (positions 17):**
| Sheet column | Card field (proposed naming) |
|---|---|
| `Label` | card title |
| `Description` | card support text |
| `Core Principle` | modal section A (`corePrinciple`) |
| `Applicable Scope` | modal section B (`applicableScope`) — free-text examples, e.g. `"Daily Operations, Minor Expenditures"` |
| `Consensus Level` | numeric 0.01.0 stored under `scalars.consensusLevel` (e.g. `0.51`, `0.67`, `1.0`) — drives the **Consensus axis** in any future visual sort/filter |
| `Step-by-Step Instructions` | modal section C (`stepByStep`) |
| `Objections & Deadlocks` | modal section D (`objectionsDeadlocks`) |
**Card rows (32):** Lazy Consensus · Do-ocracy · Consensus Decision-Making ·
Rotational Leadership · Modified Consensus · Consensus Seeking with Delegates
· Sociocracy · Supermajority Rule · Ranked Choice Voting · Range Voting ·
Majority Rule · Approval Voting · Weighted Voting · Cumulative Voting ·
Quadratic Voting · Continuous Voting · Holacracy · Collaborative Platforms ·
Deliberative Polling · Investor-Filled Board Seats · Elected Board of
Directors · Advisory Committees · Delegated Decision-Making · Executive
Committees · First Past the Post · Lottery/Sortition · Proof of Work · Random
Choice · Algorithm-Driven Decisions · Autocratic Decision-Making ·
Hierarchical Decision-Making · Negotiated Decisions.
**Data-quality issues to handle in the importer (do not silently drop):**
- Row 32 (`Hierarchical Decision-Making`): the `Consensus Level` cell contains
`"Military, Corporations"` (the value clearly belongs to `Applicable Scope`,
which itself already contains `"Military, Corporations"`). Importer should
flag this as a validation error and require a fix in the source workbook
rather than try to repair it.
- Row 11 (`Range Voting`): the **last facet column** (`Maturity: Enterprise`)
is empty in the source — treat empty as `x` (no match) **only after** the
importer logs a warning so the author knows it wasn't intentional ✓.
### 2.5 Conflict Management (`Conflict Management Methods.xlsx`, sheet `Current`)
Maps to the `conflict-management` step
(`app/create/screens/card/ConflictManagementScreen.tsx`) and
`messages/en/create/conflictManagement.json`.
**Content columns (positions 16):**
| Sheet column | Card field (proposed naming) |
|---|---|
| `Title` | card title (note: not `Label` like the other three) |
| `Description` | card support text |
| `Core Principle` | modal section A (`corePrinciple`) |
| `Applicable Scope` | modal section B (`applicableScope`) |
| `Process Protocol` | modal section C (`processProtocol`) |
| `Restoration & Fallbacks` | modal section D (`restorationFallbacks`) |
**Card rows (19):** Peer Mediation · Conflict Resolution Council · Facilitated
Negotiation · Ad Hoc Arbitration · Conflict Workshops · Supermajority Vote ·
Interest-Based Bargaining · Restorative Practices · Mediation · Circle
Processes · Judicial Committees · Managerial Decision · Internal Tribunal ·
Consensus Building · Binding Arbitration · Non-Binding Arbitration · Binding
Contracts · Lottery/Sortition · Rotational Judging.
> Conflict Management sheet uses `Title` instead of `Label` and omits the
> `Organization Type:` / `Location:` / `Organizational Maturity:` prefixes —
> normalize both at import time.
---
## 3. Existing data model & wizard surface area
### 3.1 `RuleTemplate` (today)
```64:73:prisma/schema.prisma
model RuleTemplate {
id String @id @default(cuid())
slug String @unique
title String
category String?
description String?
body Json
sortOrder Int @default(0)
featured Boolean @default(false)
}
```
`body` JSON is the rendered rule document
(`{ sections: [{ categoryName, entries: [{ title, body }] }, ...] }`),
authored today by the `bodyFromXlsxComposition()` helper in
`prisma/seed.ts` from a hand-typed `COMPOSITION_BY_SLUG` map.
**Section ordering (canonical):** Values → Communication → Membership →
Decision-making → Conflict management. Final-review and `governancePatternBody`
both rely on this exact order and casing.
```16:60:prisma/seed.ts
function governancePatternBody(coreValues: string): Prisma.InputJsonValue {
return {
sections: [
{ categoryName: "Values", entries: [{ title: "Core stance", body: coreValues }] },
{ categoryName: "Communication", entries: [...] },
{ categoryName: "Membership", entries: [...] },
{ categoryName: "Decision-making", entries: [...] },
{ categoryName: "Conflict management", entries: [...] },
],
};
}
```
### 3.2 Wizard facets captured today (`CreateFlowState`)
```83:95:app/create/types.ts
selectedCommunitySizeIds?: string[];
selectedOrganizationTypeIds?: string[];
selectedScaleIds?: string[];
selectedMaturityIds?: string[];
selectedCoreValueIds?: string[];
selectedCommunicationMethodIds?: string[];
selectedMembershipMethodIds?: string[];
selectedDecisionApproachIds?: string[];
selectedConflictManagementIds?: string[];
```
The first four are exactly the four **facet groups** in the matrix sheets. The
last four are the user's chosen **cards per section**, which the recommendation
flow can either pre-select (when picked from a template) or feed back into
ranking.
These same fields are validated server-side by `createFlowStateSchema` in
`lib/server/validation/createFlowSchemas.ts` (lines 47106) — the recommend
endpoint should reuse that schema (or a strict subset) instead of redefining
the facet shape.
### 3.3 Wizard step order
Source of truth is `app/create/utils/flowSteps.ts` (`FLOW_STEP_ORDER`). The
relevant slice is:
```
review → core-values → communication-methods → membership-methods →
decision-approaches → conflict-management → confirm-stakeholders → final-review
```
`docs/create-flow.md`'s step table is **stale**; trust `flowSteps.ts`.
### 3.4 Where templates already surface in the UI
| Surface | File |
|---|---|
| Marketing home "Popular templates" | `app/(marketing)/MarketingRuleStackSection.tsx` |
| Templates index | `app/(marketing)/templates/page.tsx` |
| Template preview (by slug) | `app/create/review-template/[slug]/page.tsx` |
| "Use without changes" → publish | `app/create/CreateFlowLayoutClient.tsx` `handleUseTemplateWithoutChanges` |
| API list | `app/api/templates/route.ts` (GET only, no params) |
There is currently **no** recommendation logic, no facet filtering, and the
`/create/informational?template=<slug>` query param is a known no-op (see
`CreateFlowLayoutClient.tsx` lines 479482).
---
## 4. Repo conventions to follow (don't reinvent)
These are the patterns the implementation must match. References point at the
canonical example for each.
### 4.1 API routes (`app/api/**/route.ts`)
`app/api/drafts/me/route.ts` is the reference — every new route in this
feature must match this exact shape:
1. `if (!isDatabaseConfigured()) return dbUnavailable();` — always first.
(`lib/server/env.ts`, `lib/server/responses.ts`).
2. For auth'd routes: `const user = await getSessionUser();` then
`return NextResponse.json({ error: "Unauthorized" }, { status: 401 });` if
missing. (Recommendation read endpoints can stay unauthenticated.)
3. For request bodies: `readLimitedJson(request)` →
`<schema>.safeParse(parsed.value)` → `jsonFromZodError(validated.error)` on
failure. (`lib/server/validation/requestBody.ts`,
`lib/server/validation/zodHttp.ts`).
4. Success: `NextResponse.json({ <key>: data })` — flat object with one or two
named keys, no `success: true` envelope.
5. Errors: structured `{ error: { code, message } }` (Zod path) or simple
`{ error: "..." }` (auth path). Match what's already in the repo.
6. Server-side query helpers swallow Prisma failures and return `[]`/`null`
(see `listRuleTemplatesFromDb` in `lib/server/ruleTemplates.ts` lines 930).
Routes do **not** wrap helper calls in `try/catch`.
### 4.2 Zod schemas live in `lib/server/validation/`
- One file per feature area (e.g. `createFlowSchemas.ts`, future
`templateRecommendationSchemas.ts`).
- Export the schema **and** the inferred type
(`export type X = z.infer<typeof xSchema>`).
- Wrap any free-form JSON blobs with `assertPlainJsonValue` /
`DEFAULT_PLAIN_JSON_LIMITS` (`lib/server/validation/plainJson.ts`) so the
size/depth bounds match the rest of the API.
- Reuse `FLOW_STEP_ORDER` and existing array bounds where they overlap (see
the `selectedXxxMethodIds` arrays in `createFlowStateSchema`).
### 4.3 Prisma access
- Singleton: `import { prisma } from "lib/server/db";` — never
`new PrismaClient()` from app code. (Standalone scripts under `scripts/` /
`prisma/` may instantiate their own, matching `prisma/seed.ts` lines
363403.)
- Server-only "fetch/list" helpers live under `lib/server/<feature>.ts`,
return DTOs (not raw Prisma rows), and degrade gracefully
(`isDatabaseConfigured()` short-circuit, `try/catch` → empty result).
- No `$transaction` patterns exist yet; **introduce one** for the importer
(write `TemplateMethod` + `TemplateMethodFacet` rows atomically).
### 4.4 DTO style
- Hand-written `type` aliases that mirror a Prisma `select` clause, co-located
with the consumer (see `RuleTemplateDto` in
`lib/create/fetchTemplates.ts` lines 514).
- For a feature with both client and server consumers, put the type in
`lib/<feature>/types.ts` and import from both sides.
### 4.5 Standalone scripts
- Use `tsx` (already a dev dep; entry point `package.json` `prisma.seed`
field).
- Layout matches `prisma/seed.ts`: `async function main()`, log a one-line
success summary, `console.error(e); process.exit(1)` on failure,
`await prisma.$disconnect()` in `finally`.
- Add an entry to `package.json` `scripts` (e.g.
`"templates:import": "tsx scripts/import-templates-xlsx.ts"`).
- No shared dotenv loader — rely on env from the shell / Next runtime.
- Support a `--dry-run` flag that validates + diffs without writing.
### 4.6 Tests
- Vitest under `tests/unit/*.test.ts` for parsers / validators / pure
functions (see `tests/unit/createFlowValidation.test.ts`).
- API routes are not unit-tested today; cover route behavior indirectly with a
`tests/unit/templateRecommendationSchemas.test.ts` (Zod) plus a fixture
workbook + importer test under `tests/unit/importTemplatesXlsx.test.ts`.
- E2E for the wizard (if needed) goes under `tests/e2e/*.spec.ts` — not
required for CR-88 acceptance.
- Test utilities: `tests/utils/test-utils.tsx` (`renderWithProviders`); MSW
server in `tests/msw/server.ts`. No Prisma mock helper exists; importer test
should use a fixture workbook and stub the `prisma` client at the import
site.
### 4.7 Logging
- Use `logger` from `lib/logger.ts` for any server-side info/warn/error in
scripts and route helpers (matches `app/api/auth/magic-link/request/route.ts`
lines 1415, 3545). No `apiError` helper exists; do not introduce one.
### 4.8 New deps
- `xlsx` (SheetJS) is **not** currently in `package.json`. Add it as a
**prod** dep only if the importer is invoked from app code; if the importer
is script-only, `devDependency` is fine. CR-88's plan calls for a
build/CLI-time importer, so `devDependencies` is the right home.
### 4.9 i18n / `messages/` constraint
- Card decks and modal copy are currently keyed in
`messages/en/<feature>.json` and read via
`useMessages().create.<feature>` (`app/contexts/MessagesContext.tsx`,
`messages/en/index.ts`).
- Only `en` is wired today, so we **don't** have a translation backlog
blocking us. The wiring step (§7) replaces `messages/en/create/{communication,
membership,rightRail,conflictManagement}.json` card/modal payloads with
values served by `GET /api/template-methods` (still keyed by the same
message namespace shape so future i18n can layer on if needed). Header
strings, button labels, and other purely-static UI copy stay in
`messages/en/*`.
### 4.10 `.cursorrules` scope
- The repo's `.cursorrules` PascalCase / lowercase normalization rule applies
to **React component props only**. It does **not** apply to API query
params, request bodies, or DB columns. The recommendation API uses lowercase
facet keys throughout (`orgType`, `scale`, `maturity`, `size`).
---
## 5. Authoring contract (informs §6 storage + §7 importer)
The four spreadsheets together imply this row schema (per matrix workbook):
```ts
type MatrixRow = {
/** Stable slug derived from `Label`/`Title` (kebab-case, lowercase, ascii).
* Used as the card id everywhere downstream. */
id: string;
/** Section this row belongs to. One of: communication, membership,
* decisionMaking, conflictManagement. (values is not yet sheet-driven.) */
section: "communication" | "membership" | "decisionMaking" | "conflictManagement";
/** Card-facing copy. Keys differ per section; importer normalizes. */
card: {
label: string;
description: string;
/** Section-specific long-form fields (34 per section). */
modalSections: Record<string, string>;
};
/** Optional numeric scalar fields (e.g. decisionMaking `Consensus Level`). */
scalars?: Record<string, number>;
/** Facet matches (✓ → true, x/blank → false). Keys are canonical facet ids. */
facets: {
size: Record<"1" | "2_5" | "6_12" | "13_100" | "100_100k", boolean>;
orgType: Record<"dao" | "forProfit" | "nonprofit" | "openSource" | "mutualAid" | "workersCoop", boolean>;
scale: Record<"global" | "national" | "regional" | "local", boolean>;
maturity: Record<"earlyStage" | "growthStage" | "established" | "enterprise", boolean>;
};
};
```
A sibling **manifest** documents the per-section section-key mapping and
column header → canonical facet/scalar key mapping, so the importer can be
stable across header rewording.
---
## 6. Storage (decided: normalized tables)
We are introducing two new Prisma models. Hand-typed `COMPOSITION_BY_SLUG` in
`prisma/seed.ts` is replaced by template rows that **reference** method slugs.
```prisma
model TemplateMethod {
id String @id @default(cuid())
section String // communication | membership | decisionMaking | conflictManagement
slug String
label String
description String
modalSections Json // { corePrinciple: "...", logisticsAdmin: "...", ... }
scalars Json? // { consensusLevel: 0.51 }
sortOrder Int @default(0)
facets TemplateMethodFacet[]
@@unique([section, slug])
@@index([section])
}
model TemplateMethodFacet {
id String @id @default(cuid())
methodId String
group String // size | orgType | scale | maturity
value String // e.g. "workersCoop"
matches Boolean // ✓ → true, x/blank → false
weight Float? // optional numeric override for future scoring
method TemplateMethod @relation(fields: [methodId], references: [id], onDelete: Cascade)
@@unique([methodId, group, value])
@@index([group, value, matches])
}
```
`RuleTemplate.body` continues to express a **chosen composition** of methods
(one or more per section). Curated templates in `prisma/seed.ts` become
references to `TemplateMethod.slug` instead of literal copy strings — when
copy changes in the spreadsheet, every template that references that slug
inherits the new copy.
A follow-up (out of scope for CR-88) may add a `RuleTemplateMethodLink` join
table if templates need ordering or per-template overrides; the current `body`
JSON shape is sufficient for the first ship.
---
## 7. Importer (`scripts/import-templates-xlsx.ts`)
Phased plan that the implementation agent can follow top-to-bottom. Mirrors
the structure of `prisma/seed.ts` (singleton client, `main()` +
`finally { $disconnect }`, `process.exit(1)` on failure).
1. **Read `.xlsx`** with [`xlsx`](https://www.npmjs.com/package/xlsx) (SheetJS,
add as devDependency) from a configurable input dir (default
`data/template-matrix/`). The four workbooks live there as committed
artifacts, not in `Downloads/`.
2. **Schema-validate per section** with Zod schemas that live in
`lib/server/validation/templateRecommendationSchemas.ts` so the API and
importer share the row shape: required column headers, allowed cell
symbols (``, `x`, blank, decimal for `Consensus Level`).
3. **Normalize**: kebab-case slug from label, strip
`Organization Type:` / `Location:` / `Organizational Maturity:` prefixes,
collapse whitespace, normalize curly quotes.
4. **Cross-sheet validation**: facet columns must match the canonical 19-column
set; unknown columns fail loudly via the importer (use `logger.error`).
5. **Diff & upsert** inside `prisma.$transaction([...])`: upsert
`TemplateMethod` rows by `(section, slug)`; delete + recreate
`TemplateMethodFacet` rows for each method.
6. **Emit a JSON snapshot** to `prisma/data/template-matrix.json` so
`prisma/seed.ts` can replay imports when the source workbooks aren't
available (e.g. CI seed without the spreadsheet checked in).
7. **Flags**: `--dry-run` (validate + diff, no writes), `--allow-warnings`
(don't fail on the row-32 / row-11 issues in §2.4 while authors are
iterating).
8. **Tests** in `tests/unit/importTemplatesXlsx.test.ts`: a fixture workbook
with two rows per section asserts both validation errors (unknown column,
bad symbol, miscategorized cell) and successful normalization. Reuse
Vitest patterns from `tests/unit/createFlowValidation.test.ts`.
Per Ticket 16 and the roadmap, **prefer batch `.xlsx` import** over a live
Google Sheets API in production. Authors export to `.xlsx` and a maintainer
runs `npm run templates:import` (or CI does on a `data/template-matrix/` change).
---
## 8. APIs
Two read endpoints. Both follow §4.1 conventions exactly: `dbUnavailable()`
guard → server helper from `lib/server/templateMethods.ts` →
`NextResponse.json({ ... })`.
### 8.1 `GET /api/templates` (rewrite)
Query params (all optional):
- `facet.size=<chipId>` (repeatable)
- `facet.orgType=<chipId>` (repeatable)
- `facet.scale=<chipId>` (repeatable)
- `facet.maturity=<chipId>` (repeatable)
Behavior:
- No params → existing curated ordering (`featured`, `sortOrder`, `title`),
no scoring.
- With facets → score each template by counting matching facets across the
methods referenced in its `body`; return ranked `templates` plus an
optional `scores` map.
Response:
```ts
{
templates: RuleTemplateDto[],
scores?: Record<string, { score: number; matchedFacets: string[] }>
}
```
Param parsing helper lives next to `listRuleTemplatesFromDb` in
`lib/server/ruleTemplates.ts` (e.g. `parseTemplateFacetsFromSearchParams`).
### 8.2 `GET /api/template-methods?section=<section>[&facet.*=...]`
Powers the four card-deck wizard steps and the section-level recommendation
view. Response:
```ts
{
section: "communication" | "membership" | "decisionMaking" | "conflictManagement",
methods: Array<{
slug: string;
label: string;
description: string;
modalSections: Record<string, string>;
scalars?: Record<string, number>;
/** Per-method facet match against the requested facets (omitted when no facets passed). */
matches?: { score: number; matchedFacets: string[] };
}>
}
```
Server helper: `listTemplateMethodsFromDb({ section, facets })` in
`lib/server/templateMethods.ts`. Same swallow-and-return-`[]` failure mode as
`listRuleTemplatesFromDb`.
### 8.3 `POST /api/templates/recommend` (follow-up, optional)
If product wants to send the full `CreateFlowState` (not just facet ids), the
body schema **reuses** `createFlowStateSchema` from
`lib/server/validation/createFlowSchemas.ts`. Same scoring engine, just a
richer input. Skip until §8.1 + §8.2 ship.
**Empty / partial facets:** never error. Fall back to today's ordering and
return all rows.
---
## 9. Wizard wiring (UI follow-on, not strictly part of CR-88)
Once the API exists:
- `communication-methods` / `membership-methods` / `decision-approaches` /
`conflict-management` screens each call
`GET /api/template-methods?section=...&facet.*=...`. The card label and
modal copy come from the API response, not from
`messages/en/create/<section>.json`. Static JSON in those four files is
pruned to the page-level strings (header titles, button labels, modal
chrome) only.
- Selecting a template on the marketing home or `templates/` page can prefill
the create flow's `selected*MethodIds` from the template's composition (this
closes the `?template=` no-op gap noted in
`CreateFlowLayoutClient.tsx`).
- Recommendations should never **hide** options from the user — ranking only.
Authors expect to see "all 32 decision-making patterns" with the ✓-matching
ones surfaced first.
---
## 10. Open questions for product before coding
1. **Should `Values` also be sheet-driven?** Today it's free-text only and
not in any of the four matrices. Roadmap implies eventual parity.
2. **Scoring vs filtering**: do we want to **hide** non-✓ rows when a facet
is set, or only **rank** them? Recommend ranking with a soft cutoff.
3. **Per-template featured composition vs library-wide**: should
`RuleTemplate` rows continue to exist as named compositions
("Consensus", "Elected Board", etc.), or become derived from a
"this is the best mix for nonprofit + 13100 + early stage" scoring? Doc
today assumes the former — templates remain curated.
4. **Authoring source of truth**: are the `Downloads/*.xlsx` files committed
to `data/template-matrix/` going forward, or do they live in a Drive folder
pulled by the importer at build time? Recommend committing.
5. **Data validation strictness**: the current Decision-making sheet has a
miscategorized cell (row 32, see §2.4). Importer should fail by default,
with a `--allow-warnings` flag for in-progress edits.
---
## 11. Test plan (acceptance for CR-88)
- [ ] `scripts/import-templates-xlsx.ts` runs end-to-end on the four committed
workbooks with no errors and produces the expected DB diff (or JSON
snapshot).
- [ ] Editing a row in the source workbook and re-running the importer changes
the rank order returned by `GET /api/templates?facet.orgType=4`
(the `Nonprofit` chip id) without any manual Studio edit.
- [ ] `tests/unit/importTemplatesXlsx.test.ts` rejects each documented
validation failure (unknown column, bad symbol, miscategorized row).
- [ ] `tests/unit/templateRecommendationSchemas.test.ts` exercises the Zod
schemas the importer and API share.
- [ ] Manual smoke on the four wizard card-deck steps: facet-narrowed
ordering surfaces matching cards first; facetless GET returns the
full curated list.
- [ ] No regression in existing template surfaces (marketing home, templates
index, review-template preview).
---
## 12. Source files referenced
- `prisma/schema.prisma` — `RuleTemplate` model (lines 6473).
- `prisma/seed.ts` — current curated composition + xlsx-shaped helpers
(lines 1404).
- `app/api/templates/route.ts` — existing GET endpoint (to be rewritten).
- `app/api/drafts/me/route.ts` — reference route shape (`dbUnavailable` →
`getSessionUser` → `readLimitedJson` → `safeParse` → `jsonFromZodError`).
- `lib/server/db.ts` — Prisma singleton (lines 118).
- `lib/server/responses.ts` — `dbUnavailable()` (lines 18).
- `lib/server/ruleTemplates.ts` — `listRuleTemplatesFromDb` (lines 930).
- `lib/server/validation/createFlowSchemas.ts` — schema to reuse for
`POST /api/templates/recommend` (lines 47106).
- `lib/server/validation/requestBody.ts` — `readLimitedJson` (lines 1348).
- `lib/server/validation/zodHttp.ts` — `jsonFromZodError` (lines 417).
- `lib/server/validation/plainJson.ts` — `assertPlainJsonValue` /
`DEFAULT_PLAIN_JSON_LIMITS`.
- `lib/logger.ts` — server-side `logger`.
- `app/create/types.ts` — `CreateFlowState` and facet fields.
- `app/create/utils/flowSteps.ts` — canonical step order.
- `app/create/utils/createFlowScreenRegistry.ts` — screen layout per step.
- `app/create/screens/select/CommunityStructureSelectScreen.tsx` — chip-id
derivation pattern (positional `String(i+1)`).
- `app/create/screens/card/CommunicationMethodsScreen.tsx` — section-field
contract (`SECTION_FIELDS`).
- `messages/en/create/{communitySize,communityStructure,communication,membership,rightRail,conflictManagement}.json` —
current static card / chip copy that the matrix supersedes.
- `lib/templates/governanceTemplateCatalog.ts`,
`lib/templates/templateGridPresentation.ts`,
`lib/create/fetchTemplates.ts` — current presentation/DTO layer.
- `tests/unit/createFlowValidation.test.ts` — Vitest pattern for new
schema/importer tests.
- Roadmap: `docs/backend-roadmap.md` §4 (lines 8385), §13.
- Spec: `docs/backend-linear-tickets.md` Ticket 16 (lines 280304).
## 13. Source workbooks
| File | Sheet | Rows | Cols | Section |
|---|---|---|---|---|
| `Communication Methods.xlsx` | `Current` | 11 cards | 24 | `communication` |
| `Group_Membership_Methods.xlsx` | `Current` | 19 cards | 24 | `membership` |
| `Decision-making.xlsx` | `Current` | 32 cards | 26 | `decisionMaking` |
| `Conflict Management Methods.xlsx` | `Current` | 19 cards | 25 | `conflictManagement` |
Counts include the header row. Decision-making has 26 columns because of two
extra content fields (`Consensus Level`, `Step-by-Step Instructions` vs the
4-section pattern of the others).
+36
View File
@@ -79,6 +79,42 @@
"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": ""
}
}
}
}
+75 -8
View File
@@ -14,6 +14,16 @@
"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",
@@ -51,26 +61,83 @@
"modals": {
"peer-mediation": {
"title": "Peer Mediation",
"description": "Trained members within the organization mediate disputes among peers."
"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."
"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."
"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."
"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."
"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." },
"7": { "title": "Label", "description": "Additional conflict management approach." },
"8": { "title": "Label", "description": "Additional conflict management approach." }
"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": ""
}
}
}
}
+65 -8
View File
@@ -14,6 +14,14 @@
"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",
@@ -51,26 +59,75 @@
"modals": {
"open-access": {
"title": "Open Access",
"description": "Maximum inclusion. Anyone can join immediately by simply showing up."
"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."
"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."
"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."
"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."
"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." },
"7": { "title": "Label", "description": "Additional membership approach." },
"8": { "title": "Label", "description": "Additional membership approach." }
"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": ""
}
}
}
}
+33
View File
@@ -19,6 +19,39 @@
"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",
@@ -1,8 +1,8 @@
import { RightRailScreen } from "../../app/create/screens/right-rail/RightRailScreen";
import { DecisionApproachesScreen } from "../../app/create/screens/right-rail/DecisionApproachesScreen";
export default {
title: "Pages/Create Flow/Right rail",
component: RightRailScreen,
title: "Pages/Create Flow/Decision approaches",
component: DecisionApproachesScreen,
parameters: {
layout: "fullscreen",
docs: {
@@ -6,15 +6,15 @@ import {
} from "../utils/test-utils";
import userEvent from "@testing-library/user-event";
import { describe, test, expect, afterEach } from "vitest";
import { RightRailScreen } from "../../app/create/screens/right-rail/RightRailScreen";
import { DecisionApproachesScreen } from "../../app/create/screens/right-rail/DecisionApproachesScreen";
afterEach(() => {
cleanup();
});
describe("Create flow right-rail page", () => {
describe("Create flow decision-approaches page", () => {
test("renders without error", () => {
render(<RightRailScreen />);
render(<DecisionApproachesScreen />);
expect(
screen.getByRole("heading", {
@@ -24,7 +24,7 @@ describe("Create flow right-rail page", () => {
});
test("renders sidebar description with add link", () => {
render(<RightRailScreen />);
render(<DecisionApproachesScreen />);
const description = screen.getByText((content, element) => {
if (element?.tagName !== "P") return false;
@@ -39,7 +39,7 @@ describe("Create flow right-rail page", () => {
});
test("renders message box with title and checkboxes", () => {
render(<RightRailScreen />);
render(<DecisionApproachesScreen />);
const region = screen.getByRole("region", {
name: "Consider defining approaches to steward key resources:",
@@ -65,7 +65,7 @@ describe("Create flow right-rail page", () => {
});
test("renders card stack with See all decision approaches toggle", () => {
render(<RightRailScreen />);
render(<DecisionApproachesScreen />);
expect(
screen.getByRole("button", { name: "See all decision approaches" }),
@@ -73,7 +73,7 @@ describe("Create flow right-rail page", () => {
});
test("renders recommended approach cards", () => {
render(<RightRailScreen />);
render(<DecisionApproachesScreen />);
expect(
screen.getByRole("button", {
@@ -94,7 +94,7 @@ describe("Create flow right-rail page", () => {
test("toggle expands and shows Show less", async () => {
const user = userEvent.setup();
render(<RightRailScreen />);
render(<DecisionApproachesScreen />);
const toggle = screen.getByRole("button", {
name: "See all decision approaches",
@@ -108,7 +108,7 @@ describe("Create flow right-rail page", () => {
test("expanded view shows Label cards", async () => {
const user = userEvent.setup();
render(<RightRailScreen />);
render(<DecisionApproachesScreen />);
const toggle = screen.getByRole("button", {
name: "See all decision approaches",
@@ -119,21 +119,29 @@ describe("Create flow right-rail page", () => {
expect(labelButtons.length).toBeGreaterThanOrEqual(1);
});
test("clicking a card toggles selection", async () => {
test("clicking a card opens the create modal and confirming selects it", async () => {
const user = userEvent.setup();
render(<RightRailScreen />);
render(<DecisionApproachesScreen />);
const card = screen.getByRole("button", {
name: /Lazy Consensus: A decision is assumed approved/,
});
await user.click(card);
const dialog = await screen.findByRole("dialog");
expect(dialog).toBeInTheDocument();
const confirmButton = within(dialog).getByRole("button", {
name: "Add Approach",
});
await user.click(confirmButton);
expect(screen.getByText("SELECTED")).toBeInTheDocument();
});
test("message box checkboxes are interactive", async () => {
const user = userEvent.setup();
render(<RightRailScreen />);
render(<DecisionApproachesScreen />);
const amendCheckbox = screen.getByRole("checkbox", {
name: "Amend your CommunityRule",