Create custom flow UI

This commit is contained in:
adilallo
2026-04-17 22:25:24 -06:00
parent eedb70f9f3
commit 36dcb79870
38 changed files with 1227 additions and 250 deletions
+1 -1
View File
@@ -44,7 +44,7 @@ Set `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` in `.env` so **signed-in** users can
### Create flow URLs (custom wizard) ### Create flow URLs (custom wizard)
The **custom** create-rule wizard lives under **`/create/…`**. The header links to **`/create`**, which redirects to the first step. **Semantic** URL segments (e.g. `community-name`, `community-size`) match Figma intent; order is **`FLOW_STEP_ORDER`** in `app/create/utils/flowSteps.ts`, with UI from **`app/create/[screenId]/page.tsx`** and **`CREATE_FLOW_SCREEN_REGISTRY`** for Figma traceability. **Figma** stages: **Create Community** (through `review`), **Create Custom CommunityRule** (`cards``right-rail`), **Review and complete** (`confirm-stakeholders``completed`). **`/create/review-template/[slug]`** is a template **preview** only. Full tables and persistence are in **[docs/create-flow.md](docs/create-flow.md)**; engineering tracking: Linear **CR-89** / Ticket 17 in [docs/backend-linear-tickets.md](docs/backend-linear-tickets.md). The **custom** create-rule wizard lives under **`/create/…`**. The header links to **`/create`**, which redirects to the first step. **Semantic** URL segments (e.g. `community-name`, `community-size`) match Figma intent; order is **`FLOW_STEP_ORDER`** in `app/create/utils/flowSteps.ts`, with UI from **`app/create/[screenId]/page.tsx`** and **`CREATE_FLOW_SCREEN_REGISTRY`** for Figma traceability. **Figma** stages: **Create Community** (through `review`), **Create Custom CommunityRule** (`communication-methods``right-rail`), **Review and complete** (`confirm-stakeholders``completed`). **`/create/review-template/[slug]`** is a template **preview** only. Full tables and persistence are in **[docs/create-flow.md](docs/create-flow.md)**; engineering tracking: Linear **CR-89** / Ticket 17 in [docs/backend-linear-tickets.md](docs/backend-linear-tickets.md).
## Frontend / tests ## Frontend / tests
@@ -10,6 +10,7 @@ export type ProportionBarState =
| "2-0" | "2-0"
| "2-1" | "2-1"
| "2-2" | "2-2"
| "2-3"
| "3-0" | "3-0"
| "3-1" | "3-1"
| "3-2"; | "3-2";
@@ -20,7 +21,9 @@ export interface ProportionBarProps {
progress?: ProportionBarState; progress?: ProportionBarState;
className?: string; className?: string;
/** /**
* `segmented` (Figma: create-flow footer): pill-shaped partial fills inside each segment. * Kept for backwards compatibility. Both `default` and `segmented` render the
* same fill geometry (square leading edges, matching Figma). Future variants
* can differentiate here without API changes.
*/ */
variant?: ProportionBarVariant; variant?: ProportionBarVariant;
} }
@@ -1,24 +1,41 @@
import type { ProportionBarViewProps } from "./ProportionBar.types"; import type { ProportionBarViewProps } from "./ProportionBar.types";
/**
* Per-step fill ratio for the second (middle) segment at `2-X` progress states.
* Values are taken directly from Figma (`17861:33241`, `18861:15250`, `21434:17632`)
* and are intentionally non-uniform — they are NOT `X/3`.
*/
const SECOND_SEGMENT_FILL_RATIO: Record<number, number> = {
0: 0,
1: 1 / 4,
2: 1 / 2,
3: 3 / 4,
};
function getSecondSegmentFillRatio(partial: number): number {
return SECOND_SEGMENT_FILL_RATIO[partial] ?? 0;
}
export function ProportionBarView({ export function ProportionBarView({
progress, progress,
className, className,
barClasses, barClasses,
variant, // `variant` is kept in the prop API for callers, but both `default` and
// `segmented` now render identical fill geometry (square leading edges).
variant: _variant,
}: ProportionBarViewProps) { }: ProportionBarViewProps) {
// Proportion bar type // Proportion bar type
const [fullSegments, partialSegment] = progress.split("-").map(Number); const [fullSegments, partialSegment] = progress.split("-").map(Number);
const segmented = variant === "segmented";
// Calculate total progress: // Calculate total progress:
// - For 1-X: first section is (X+1)/6 filled // - For 1-X: first section is (X+1)/6 filled
// - For 2-X: first section full, second section X/3 filled // - For 2-X: first section full, second section filled per Figma ratios (see `SECOND_SEGMENT_FILL_RATIO`)
// - For 3-X: first two sections full, third section X/3 filled // - For 3-X: first two sections full, third section X/3 filled
// Max is 3 full segments = 9 units // Max is 3 full segments = 9 units
let totalProgress = 0; let totalProgress = 0;
if (fullSegments === 1) { if (fullSegments === 1) {
totalProgress = (partialSegment + 1) / 6; // 1/6 to 6/6 of first section totalProgress = (partialSegment + 1) / 6; // 1/6 to 6/6 of first section
} else if (fullSegments === 2) { } else if (fullSegments === 2) {
totalProgress = 1 + partialSegment / 3; // 1 full + 0/3 to 2/3 of second totalProgress = 1 + getSecondSegmentFillRatio(partialSegment);
} else if (fullSegments === 3) { } else if (fullSegments === 3) {
totalProgress = 2 + partialSegment / 3; // 2 full + 0/3 to 2/3 of third totalProgress = 2 + partialSegment / 3; // 2 full + 0/3 to 2/3 of third
} }
@@ -55,33 +72,32 @@ export function ProportionBarView({
</div> </div>
{/* Fill layer - always show 3 sections, fill amount varies */} {/* Fill layer - always show 3 sections, fill amount varies */}
{/*
The leading (right) edge of every partial fill is a straight (square) edge —
only the outermost left/right edges of the whole bar can round to match the
background capsule.
*/}
<div className="absolute inset-0 flex gap-[var(--spacing-scale-008)] px-[4px] overflow-hidden"> <div className="absolute inset-0 flex gap-[var(--spacing-scale-008)] px-[4px] overflow-hidden">
{/* First section - for 1-X: (X+1)/6 filled, for 2-X and 3-X: fully filled */} {/* First section - for 1-X: (X+1)/6 filled, for 2-X and 3-X: fully filled */}
<div className="flex-1 h-full relative"> <div className="flex-1 h-full relative">
{fullSegments === 1 ? ( {fullSegments === 1 ? (
<div <div
className={`absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)] rounded-l-[var(--radius-full)] ${ className="absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)] rounded-l-[var(--radius-full)]"
segmented && partialSegment < 5
? "rounded-r-[var(--radius-full)]"
: ""
}`.trim()}
style={{ width: `${((partialSegment + 1) / 6) * 100}%` }} style={{ width: `${((partialSegment + 1) / 6) * 100}%` }}
/> />
) : fullSegments >= 2 ? ( ) : fullSegments >= 2 ? (
<div className="absolute inset-0 bg-[var(--color-content-default-brand-primary)] rounded-l-[var(--radius-full)]" /> <div className="absolute inset-0 bg-[var(--color-content-default-brand-primary)] rounded-l-[var(--radius-full)]" />
) : null} ) : null}
</div> </div>
{/* Second section - for 2-X: X/3 filled, for 3-X: fully filled, otherwise empty */} {/* Second section for 2-X: Figma ratio fill (see `SECOND_SEGMENT_FILL_RATIO`); for 3-X: full; otherwise empty. */}
<div className="flex-1 h-full relative"> <div className="flex-1 h-full relative">
{fullSegments === 2 ? ( {fullSegments === 2 ? (
partialSegment > 0 ? ( partialSegment > 0 ? (
<div <div
className={`absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)] ${ className="absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)]"
segmented style={{
? "rounded-l-[var(--radius-full)] rounded-r-[var(--radius-full)]" width: `${getSecondSegmentFillRatio(partialSegment) * 100}%`,
: "" }}
}`.trim()}
style={{ width: `${(partialSegment / 3) * 100}%` }}
/> />
) : null ) : null
) : fullSegments >= 3 ? ( ) : fullSegments >= 3 ? (
@@ -89,16 +105,12 @@ export function ProportionBarView({
) : null} ) : null}
</div> </div>
{/* Third section - for 3-X: X/3 filled, otherwise empty */} {/* Third section - for 3-X: X/3 filled, otherwise empty */}
{/* Round right corner when at 100% (third section fully filled, partialSegment === 3) */} {/* Round right corner only when the fill reaches the absolute right edge of the bar (partialSegment >= 3) */}
<div className="flex-1 h-full relative"> <div className="flex-1 h-full relative">
{fullSegments === 3 && partialSegment > 0 ? ( {fullSegments === 3 && partialSegment > 0 ? (
<div <div
className={`absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)] ${ className={`absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)] ${
segmented partialSegment >= 3 ? "rounded-r-[var(--radius-full)]" : ""
? "rounded-l-[var(--radius-full)] rounded-r-[var(--radius-full)]"
: partialSegment >= 3
? "rounded-r-[var(--radius-full)]"
: ""
}`.trim()} }`.trim()}
style={{ width: `${Math.min((partialSegment / 3) * 100, 100)}%` }} style={{ width: `${Math.min((partialSegment / 3) * 100, 100)}%` }}
/> />
@@ -21,7 +21,10 @@ const CardStackContainer = memo<CardStackProps>(
title = "", title = "",
description = "", description = "",
layout = "default", layout = "default",
compactRecommendedLimit = 5,
compactDesktopLayout: compactDesktopLayoutProp = "grid",
headerLockupSize, headerLockupSize,
toggleAlignment = "center",
className = "", className = "",
}) => { }) => {
const [internalExpanded, setInternalExpanded] = useState(false); const [internalExpanded, setInternalExpanded] = useState(false);
@@ -75,7 +78,10 @@ const CardStackContainer = memo<CardStackProps>(
title={title} title={title}
description={description} description={description}
layout={layout} layout={layout}
compactRecommendedLimit={compactRecommendedLimit}
compactDesktopLayout={compactDesktopLayoutProp}
headerLockupSize={headerLockupSize} headerLockupSize={headerLockupSize}
toggleAlignment={toggleAlignment}
className={className} className={className}
/> />
); );
@@ -21,8 +21,19 @@ export interface CardStackProps {
description?: string; description?: string;
/** "default" = compact grid/column + expanded grid; "singleStack" = always one column, expand shows more in same stack */ /** "default" = compact grid/column + expanded grid; "singleStack" = always one column, expand shows more in same stack */
layout?: "default" | "singleStack"; layout?: "default" | "singleStack";
/**
* Max recommended cards in compact (non-expanded) mode. Default 5; Figma compact stack uses 3.
*/
compactRecommendedLimit?: number;
/**
* At `md+`, how compact recommended cards are laid out. `flexWrap` matches Figma Flow — Compact Card Stack (three cards in a row).
* `pyramidFive` = two rows (3 + 2) centered for five recommended cards (membership step).
*/
compactDesktopLayout?: "grid" | "flexWrap" | "pyramidFive";
/** Optional title/description lockup size (create-flow passes `md`-matched `L`/`M`). Defaults to `L`. */ /** Optional title/description lockup size (create-flow passes `md`-matched `L`/`M`). Defaults to `L`. */
headerLockupSize?: HeaderLockupSizeValue; headerLockupSize?: HeaderLockupSizeValue;
/** Alignment of the expand/collapse control in `singleStack` layout (Figma right-rail: end). */
toggleAlignment?: "center" | "end";
className?: string; className?: string;
} }
@@ -38,6 +49,9 @@ export interface CardStackViewProps {
title: string; title: string;
description: string; description: string;
layout: "default" | "singleStack"; layout: "default" | "singleStack";
compactRecommendedLimit: number;
compactDesktopLayout: "grid" | "flexWrap" | "pyramidFive";
headerLockupSize: HeaderLockupSizeValue | undefined; headerLockupSize: HeaderLockupSizeValue | undefined;
toggleAlignment: "center" | "end";
className: string; className: string;
} }
@@ -16,13 +16,18 @@ export function CardStackView({
title, title,
description, description,
layout, layout,
compactRecommendedLimit,
compactDesktopLayout,
headerLockupSize, headerLockupSize,
toggleAlignment,
className, className,
}: CardStackViewProps) { }: CardStackViewProps) {
const lockupSize = headerLockupSize ?? "L"; const lockupSize = headerLockupSize ?? "L";
const isSelected = (id: string) => selectedIds.includes(id); const isSelected = (id: string) => selectedIds.includes(id);
// Compact: recommended only (up to 5). Expanded: all cards. // Compact: recommended only (default up to 5). Expanded: all cards.
const compactCards = cards.filter((c) => c.recommended ?? false).slice(0, 5); const compactCards = cards
.filter((c) => c.recommended ?? false)
.slice(0, compactRecommendedLimit);
// Single stack: always one column; expand reveals more in same stack (scrollable) // Single stack: always one column; expand reveals more in same stack (scrollable)
if (layout === "singleStack") { if (layout === "singleStack") {
@@ -39,7 +44,7 @@ export function CardStackView({
/> />
</div> </div>
) : null} ) : null}
<div className="flex flex-col gap-[8px] w-full min-w-0"> <div className="flex w-full min-w-0 flex-col gap-2">
{displayedCards.map((item) => ( {displayedCards.map((item) => (
<Card <Card
key={item.id} key={item.id}
@@ -58,7 +63,9 @@ export function CardStackView({
<button <button
type="button" type="button"
onClick={onToggleExpand} onClick={onToggleExpand}
className="font-inter text-base font-normal leading-6 text-[var(--color-gray-000)] underline hover:opacity-90 focus:outline-none self-center cursor-pointer" className={`font-inter text-base font-normal leading-6 text-[var(--color-gray-000)] underline hover:opacity-90 focus:outline-none cursor-pointer ${
toggleAlignment === "end" ? "self-end" : "self-center"
}`}
> >
{expanded ? showLessLabel : toggleLabel} {expanded ? showLessLabel : toggleLabel}
</button> </button>
@@ -81,7 +88,7 @@ export function CardStackView({
) : null} ) : null}
{expanded ? ( {expanded ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-6 w-full"> <div className="mx-auto grid w-full max-w-[min(100%,860px)] grid-cols-1 gap-x-4 gap-y-6 md:grid-cols-2">
{cards.map((item) => ( {cards.map((item) => (
<Card <Card
key={item.id} key={item.id}
@@ -96,10 +103,215 @@ export function CardStackView({
/> />
))} ))}
</div> </div>
) : compactDesktopLayout === "pyramidFive" ? (
<>
<div className="flex w-full flex-col gap-2 md:hidden">
{compactCards.map((item) => (
<Card
key={item.id}
id={item.id}
label={item.label}
supportText={item.supportText}
recommended={item.recommended ?? false}
selected={isSelected(item.id)}
orientation="horizontal"
showInfoIcon={false}
className="min-h-[142px]"
onClick={() => onCardSelect(item.id)}
/>
))}
</div>
<div className="mx-auto hidden w-full max-w-[min(100%,860px)] md:block">
{/*
lg+: fixed 3 + 2 rows (no flex-wrap on the top row — avoids 2+1+2 when the first row wraps).
mdlg: same shell as the 3-card step — each row is `flex justify-center gap-2` so cards
stay a tight cluster with gap-2 until lg expands to the 3+2 pyramid.
*/}
<div className="hidden flex-col gap-2 lg:flex">
<div className="flex justify-center gap-2">
{compactCards.slice(0, 3).map((item) => (
<Card
key={item.id}
id={item.id}
label={item.label}
supportText={item.supportText}
recommended={item.recommended ?? false}
selected={isSelected(item.id)}
orientation="horizontal"
showInfoIcon={false}
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
onClick={() => onCardSelect(item.id)}
/>
))}
</div>
{compactCards.length > 3 ? (
<div className="flex justify-center gap-2">
{compactCards
.slice(3, compactRecommendedLimit)
.map((item) => (
<Card
key={item.id}
id={item.id}
label={item.label}
supportText={item.supportText}
recommended={item.recommended ?? false}
selected={isSelected(item.id)}
orientation="horizontal"
showInfoIcon={false}
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
onClick={() => onCardSelect(item.id)}
/>
))}
</div>
) : null}
</div>
<div className="hidden flex-col gap-2 md:flex lg:hidden">
<div className="flex justify-center gap-2">
{compactCards.slice(0, 2).map((item) => (
<Card
key={item.id}
id={item.id}
label={item.label}
supportText={item.supportText}
recommended={item.recommended ?? false}
selected={isSelected(item.id)}
orientation="horizontal"
showInfoIcon={false}
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
onClick={() => onCardSelect(item.id)}
/>
))}
</div>
<div className="flex justify-center gap-2">
{compactCards.slice(2, 4).map((item) => (
<Card
key={item.id}
id={item.id}
label={item.label}
supportText={item.supportText}
recommended={item.recommended ?? false}
selected={isSelected(item.id)}
orientation="horizontal"
showInfoIcon={false}
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
onClick={() => onCardSelect(item.id)}
/>
))}
</div>
{compactCards[4] ? (
<div className="flex justify-center gap-2">
<Card
id={compactCards[4].id}
label={compactCards[4].label}
supportText={compactCards[4].supportText}
recommended={compactCards[4].recommended ?? false}
selected={isSelected(compactCards[4].id)}
orientation="horizontal"
showInfoIcon={false}
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
onClick={() => onCardSelect(compactCards[4].id)}
/>
</div>
) : null}
</div>
</div>
</>
) : compactDesktopLayout === "flexWrap" ? (
<>
<div className="flex w-full flex-col gap-2 md:hidden">
{compactCards.map((item) => (
<Card
key={item.id}
id={item.id}
label={item.label}
supportText={item.supportText}
recommended={item.recommended ?? false}
selected={isSelected(item.id)}
orientation="horizontal"
showInfoIcon={false}
className="min-h-[142px]"
onClick={() => onCardSelect(item.id)}
/>
))}
</div>
{/* mdlg: pyramid (2 + 1), each row centered; lg+: one centered row (not edge-to-edge in a 2-col grid) */}
{compactCards.length === 3 ? (
<>
<div className="mx-auto hidden w-full max-w-[min(100%,860px)] flex-col gap-2 md:flex lg:hidden">
<div className="flex justify-center gap-2">
{compactCards.slice(0, 2).map((item) => (
<Card
key={item.id}
id={item.id}
label={item.label}
supportText={item.supportText}
recommended={item.recommended ?? false}
selected={isSelected(item.id)}
orientation="horizontal"
showInfoIcon={false}
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
onClick={() => onCardSelect(item.id)}
/>
))}
</div>
<div className="flex justify-center">
<Card
id={compactCards[2].id}
label={compactCards[2].label}
supportText={compactCards[2].supportText}
recommended={compactCards[2].recommended ?? false}
selected={isSelected(compactCards[2].id)}
orientation="horizontal"
showInfoIcon={false}
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
onClick={() => onCardSelect(compactCards[2].id)}
/>
</div>
</div>
<div className="mx-auto hidden w-full max-w-[min(100%,860px)] flex-wrap justify-center gap-2 lg:flex">
{compactCards.map((item) => (
<Card
key={item.id}
id={item.id}
label={item.label}
supportText={item.supportText}
recommended={item.recommended ?? false}
selected={isSelected(item.id)}
orientation="horizontal"
showInfoIcon={false}
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
onClick={() => onCardSelect(item.id)}
/>
))}
</div>
</>
) : (
<div className="mx-auto hidden w-full max-w-[min(100%,860px)] flex-wrap justify-center gap-2 md:flex">
{compactCards.map((item) => (
<div
key={item.id}
className="flex w-full min-w-0 shrink-0 justify-center md:w-[281px] md:max-w-[281px]"
>
<Card
id={item.id}
label={item.label}
supportText={item.supportText}
recommended={item.recommended ?? false}
selected={isSelected(item.id)}
orientation="horizontal"
showInfoIcon={false}
className="h-[142px] min-h-[142px] max-h-[142px] w-full max-w-[281px]"
onClick={() => onCardSelect(item.id)}
/>
</div>
))}
</div>
)}
</>
) : ( ) : (
<> <>
{/* Compact under 640: single column, up to 5 recommended cards */} {/* Compact under 640: single column, up to 5 recommended cards */}
<div className="flex flex-col gap-6 w-full md:hidden"> <div className="flex w-full flex-col gap-2 md:hidden">
{compactCards.map((item) => ( {compactCards.map((item) => (
<Card <Card
key={item.id} key={item.id}
+81 -16
View File
@@ -14,7 +14,10 @@ import { useCreateFlowExit } from "./hooks/useCreateFlowExit";
import CreateFlowTopNav from "../components/utility/CreateFlowTopNav"; import CreateFlowTopNav from "../components/utility/CreateFlowTopNav";
import { getNextStep, getStepIndex } from "./utils/flowSteps"; import { getNextStep, getStepIndex } from "./utils/flowSteps";
import { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress"; import { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress";
import { createFlowStepUsesCenteredTextLayout } from "./utils/createFlowScreenRegistry"; import {
createFlowStepUsesCenteredTextLayout,
createFlowStepUsesCardLayout,
} from "./utils/createFlowScreenRegistry";
import CreateFlowFooter from "../components/utility/CreateFlowFooter"; import CreateFlowFooter from "../components/utility/CreateFlowFooter";
import Button from "../components/buttons/Button"; import Button from "../components/buttons/Button";
import { buildPublishPayload } from "../../lib/create/buildPublishPayload"; import { buildPublishPayload } from "../../lib/create/buildPublishPayload";
@@ -299,36 +302,34 @@ function CreateFlowLayoutContent({
}, [state.communitySaveEmail, tLogin, updateState]); }, [state.communitySaveEmail, tLogin, updateState]);
const isCompletedStep = currentStep === "completed"; const isCompletedStep = currentStep === "completed";
const isRightRailStep = currentStep === "right-rail"; const isRightRailStep = currentStep === "decision-approaches";
const isFinalReviewStep = currentStep === "final-review"; const isFinalReviewStep = currentStep === "final-review";
const isCardsStep = currentStep === "cards"; const isCardLayoutStep = createFlowStepUsesCardLayout(currentStep);
/** Two-column select steps: at `lg+` only the right column scrolls; main must not scroll the full page. */ /** Two-column select / right-rail: below `lg` main scrolls; at `lg+` only the right column scrolls. */
const isSelectSplitScrollStep = const isSelectSplitScrollStep =
currentStep === "community-size" || currentStep === "community-size" ||
currentStep === "community-structure" || currentStep === "community-structure" ||
currentStep === "core-values"; currentStep === "core-values" ||
currentStep === "decision-approaches";
const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1; const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1;
/** At `md+`, main cross-axis: center by default; exceptions stay top-aligned (see product spec). */ /** At `md+`, main cross-axis: center by default; exceptions stay top-aligned (see product spec). */
const mainContentClass = isCompletedStep const mainContentClass = isCompletedStep
? "items-stretch overflow-y-auto md:overflow-hidden" ? "items-stretch overflow-y-auto md:overflow-hidden"
: isRightRailStep : isSelectSplitScrollStep
? "items-stretch overflow-hidden" ? "items-start justify-start overflow-y-auto max-lg:overflow-y-auto lg:min-h-0 lg:items-stretch lg:overflow-hidden"
: isSelectSplitScrollStep : isFinalReviewStep || isCardLayoutStep || isTemplateReviewRoute
? "items-start justify-start overflow-y-auto max-lg:overflow-y-auto lg:min-h-0 lg:items-stretch lg:overflow-hidden" ? "items-start justify-center overflow-y-auto"
: isFinalReviewStep || isCardsStep || isTemplateReviewRoute : "items-start justify-center overflow-y-auto md:items-center";
? "items-start justify-center overflow-y-auto"
: "items-start justify-center overflow-y-auto md:items-center";
const isTextStep = createFlowStepUsesCenteredTextLayout(currentStep); const isTextStep = createFlowStepUsesCenteredTextLayout(currentStep);
const mainMaxMdJustify = const mainMaxMdJustify =
isTextStep && !isCompletedStep && !isRightRailStep isTextStep && !isCompletedStep && !isRightRailStep
? "max-md:justify-center" ? "max-md:justify-center"
: "max-md:justify-start"; : "max-md:justify-start";
const mainMaxMdCross = const mainMaxMdCross = isCompletedStep
isCompletedStep || isRightRailStep ? "max-md:flex-col max-md:items-stretch"
? "max-md:flex-col max-md:items-stretch" : "max-md:flex-col max-md:items-center";
: "max-md:flex-col max-md:items-center";
const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`; const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`;
const saveDraftOnExit = const saveDraftOnExit =
Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX; Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX;
@@ -575,6 +576,70 @@ function CreateFlowLayoutContent({
> >
{footer.confirmCoreValues} {footer.confirmCoreValues}
</Button> </Button>
) : currentStep === "communication-methods" && nextStep ? (
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={
isPublishing ||
(state.selectedCommunicationMethodIds?.length ?? 0) === 0
}
className={footerPrimaryButtonClass}
onClick={() => {
goToNextStep();
}}
>
{footer.confirmCommunication}
</Button>
) : currentStep === "membership-methods" && nextStep ? (
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={
isPublishing ||
(state.selectedMembershipMethodIds?.length ?? 0) === 0
}
className={footerPrimaryButtonClass}
onClick={() => {
goToNextStep();
}}
>
{footer.confirmMembership}
</Button>
) : currentStep === "decision-approaches" && nextStep ? (
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={
isPublishing ||
(state.selectedDecisionApproachIds?.length ?? 0) === 0
}
className={footerPrimaryButtonClass}
onClick={() => {
goToNextStep();
}}
>
{footer.confirmRightRail}
</Button>
) : currentStep === "conflict-management" && nextStep ? (
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={
isPublishing ||
(state.selectedConflictManagementIds?.length ?? 0) === 0
}
className={footerPrimaryButtonClass}
onClick={() => {
goToNextStep();
}}
>
{footer.confirmConflictManagement}
</Button>
) : nextStep ? ( ) : nextStep ? (
<Button <Button
buttonType="filled" buttonType="filled"
+11 -19
View File
@@ -1,33 +1,25 @@
"use client"; import { notFound } from "next/navigation";
import { notFound, useRouter } from "next/navigation";
import { use, useEffect } from "react";
import { CreateFlowScreenView } from "../screens/CreateFlowScreenView"; import { CreateFlowScreenView } from "../screens/CreateFlowScreenView";
import { isValidStep } from "../utils/flowSteps"; import { isValidStep } from "../utils/flowSteps";
import type { CreateFlowStep } from "../types"; import type { CreateFlowStep } from "../types";
/**
* Single dynamic route for the whole create wizard (every step in `FLOW_STEP_ORDER`).
*
* Only **canonical** `screenId` values from `CreateFlowStep` are valid. Old placeholder
* segments from pre-product shells are not redirected — unknown slugs `notFound()`.
*/
interface PageProps { interface PageProps {
params: Promise<{ screenId: string }>; params: Promise<{ screenId: string }>;
} }
export default function CreateFlowScreenPage({ params }: PageProps) { export default async function CreateFlowScreenPage({ params }: PageProps) {
const { screenId: raw } = use(params); const { screenId: raw } = await params;
const router = useRouter();
useEffect(() => {
if (raw === "community-reflection") {
router.replace("/create/community-save");
}
}, [raw, router]);
if (raw === "community-reflection") {
return null;
}
if (!isValidStep(raw)) { if (!isValidStep(raw)) {
notFound(); notFound();
} }
const screenId = raw as CreateFlowStep; return <CreateFlowScreenView screenId={raw as CreateFlowStep} />;
return <CreateFlowScreenView screenId={screenId} />;
} }
@@ -1,7 +1,10 @@
"use client"; "use client";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { CreateFlowStepShell } from "./CreateFlowStepShell"; import {
CreateFlowStepShell,
type CreateFlowContentTopBelowMd,
} from "./CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "./createFlowLayoutTokens"; import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "./createFlowLayoutTokens";
export type CreateFlowSelectShellLgVerticalAlign = "center" | "start"; export type CreateFlowSelectShellLgVerticalAlign = "center" | "start";
@@ -9,23 +12,29 @@ export type CreateFlowSelectShellLgVerticalAlign = "center" | "start";
interface CreateFlowTwoColumnSelectShellProps { interface CreateFlowTwoColumnSelectShellProps {
header: ReactNode; header: ReactNode;
children: ReactNode; children: ReactNode;
/**
* Top padding below create-flow chrome. Select steps use `space-1400`; right-rail uses `space-800`
* (Figma Flow — Right Rail).
*/
contentTopBelowMd?: CreateFlowContentTopBelowMd;
/** /**
* At `lg+`, layout variant: `"center"` = vertically centered pair (community size/structure). * At `lg+`, layout variant: `"center"` = vertically centered pair (community size/structure).
* `"start"` = top-weighted layout with a scrollable right column (core values): uses `items-stretch` * `"start"` = top-weighted layout with a scrollable right column (core values, right-rail): uses `items-stretch`
* so the right column gets a bounded height; `items-start` would grow with content and break scroll. * so the right column gets a bounded height; `items-start` would grow with content and break scroll.
*/ */
lgVerticalAlign?: CreateFlowSelectShellLgVerticalAlign; lgVerticalAlign?: CreateFlowSelectShellLgVerticalAlign;
} }
/** /**
* Two-column layout for create-flow select steps (community size/structure, core values). * Two-column layout for create-flow select steps (community size/structure, core values) and
* Below `lg`, layout and scrolling match the previous single-column behavior (full page scroll). * {@link RightRailScreen} (decision approaches). Below `lg` (1024px), one column + main scrolls.
* At `lg+`, mirrors {@link CompletedScreen}: static header column + scrollable controls column * At `lg+`, mirrors {@link CompletedScreen}: static header column + scrollable controls column
* (`min-h-0` + `overflow-y-auto` height chain; see completed page right rail). * (`min-h-0` + `overflow-y-auto` height chain; see completed page right rail).
*/ */
export function CreateFlowTwoColumnSelectShell({ export function CreateFlowTwoColumnSelectShell({
header, header,
children, children,
contentTopBelowMd = "space-1400",
lgVerticalAlign = "center", lgVerticalAlign = "center",
}: CreateFlowTwoColumnSelectShellProps) { }: CreateFlowTwoColumnSelectShellProps) {
/** `stretch` is required for `min-h-0` + `overflow-y-auto` on the right column. */ /** `stretch` is required for `min-h-0` + `overflow-y-auto` on the right column. */
@@ -38,7 +47,7 @@ export function CreateFlowTwoColumnSelectShell({
return ( return (
<CreateFlowStepShell <CreateFlowStepShell
variant="centeredNarrow" variant="centeredNarrow"
contentTopBelowMd="space-1400" contentTopBelowMd={contentTopBelowMd}
className={ className={
/* Below `lg`: natural height — same as legacy select screens (main scrolls). */ /* Below `lg`: natural height — same as legacy select screens (main scrolls). */
/* At `lg+`: fill main + clip so only the right column scrolls (CompletedScreen pattern). */ /* At `lg+`: fill main + clip so only the right column scrolls (CompletedScreen pattern). */
@@ -8,3 +8,11 @@ export const CREATE_FLOW_MD_UP_GRID_CELL_CLASS =
/** Two 640px columns + `--measures-spacing-1200` (48px) gutter. */ /** Two 640px columns + `--measures-spacing-1200` (48px) gutter. */
export const CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS = "md:max-w-[1328px]"; export const CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS = "md:max-w-[1328px]";
/**
* Card-stack steps only (Figma compact card stack): wider than header lockup so the card grid /
* pyramid fits (max 860px). Header lockup stays {@link CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}.
* Cardcard gap uses `gap-2` in `CardStack` (same on mobile and md+).
*/
export const CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS =
"w-full min-w-0 md:max-w-[min(100%,860px)]";
+15 -5
View File
@@ -11,12 +11,18 @@ import { ConfirmStakeholdersScreen } from "./select/ConfirmStakeholdersScreen";
import { CommunityUploadScreen } from "./upload/CommunityUploadScreen"; import { CommunityUploadScreen } from "./upload/CommunityUploadScreen";
import { CommunityReviewScreen } from "./review/CommunityReviewScreen"; import { CommunityReviewScreen } from "./review/CommunityReviewScreen";
import { FinalReviewScreen } from "./review/FinalReviewScreen"; import { FinalReviewScreen } from "./review/FinalReviewScreen";
import { CardsScreen } from "./card/CardsScreen"; import { CommunicationMethodsScreen } from "./card/CommunicationMethodsScreen";
import { MembershipMethodsScreen } from "./card/MembershipMethodsScreen";
import { ConflictManagementScreen } from "./card/ConflictManagementScreen";
import { RightRailScreen } from "./right-rail/RightRailScreen"; import { RightRailScreen } from "./right-rail/RightRailScreen";
import { CompletedScreen } from "./completed/CompletedScreen"; import { CompletedScreen } from "./completed/CompletedScreen";
/** /**
* Renders the create-flow screen for a validated `screenId` (URL segment under /create/). * Maps each wizard `screenId` to its screen component.
*
* **Folder rule (Figma):** subfolders match `CREATE_FLOW_SCREEN_REGISTRY[].layoutKind`
* — `select/` (two-column chip flows), `card/` (compact card-stack steps), `text/`, etc.
* The URL segment (`communication-methods`) is not the folder name; see `createFlowScreenRegistry.ts`.
*/ */
export function CreateFlowScreenView({ export function CreateFlowScreenView({
screenId, screenId,
@@ -65,10 +71,14 @@ export function CreateFlowScreenView({
return <CommunityReviewScreen />; return <CommunityReviewScreen />;
case "core-values": case "core-values":
return <CoreValuesSelectScreen />; return <CoreValuesSelectScreen />;
case "cards": case "communication-methods":
return <CardsScreen />; return <CommunicationMethodsScreen />;
case "right-rail": case "membership-methods":
return <MembershipMethodsScreen />;
case "decision-approaches":
return <RightRailScreen />; return <RightRailScreen />;
case "conflict-management":
return <ConflictManagementScreen />;
case "confirm-stakeholders": case "confirm-stakeholders":
return <ConfirmStakeholdersScreen />; return <ConfirmStakeholdersScreen />;
case "final-review": case "final-review":
@@ -1,5 +1,14 @@
"use client"; "use client";
/**
* `communication-methods` step Figma Flow Compact Card Stack (node `20246-15828`).
* Registry: `layoutKind: "card"` (`CREATE_FLOW_SCREEN_REGISTRY["communication-methods"]`).
*
* Lives under `screens/card/` (not `select/`): Figma **card stack** layout is a distinct shell from
* two-column chip **select** frames. Future card-stack steps get their own `*Screen.tsx` here and
* reuse `CardStack` / `CreateFlowStepShell` as needed.
*/
import { useState, useCallback, useMemo } from "react"; import { useState, useCallback, useMemo } from "react";
import { useMessages } from "../../../contexts/MessagesContext"; import { useMessages } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
@@ -9,7 +18,10 @@ import CardStack from "../../../components/utility/CardStack";
import Create from "../../../components/modals/Create"; import Create from "../../../components/modals/Create";
import TextArea from "../../../components/controls/TextArea"; import TextArea from "../../../components/controls/TextArea";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell"; import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens"; import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
const IN_PERSON_CARD_ID = "in-person-meetings"; const IN_PERSON_CARD_ID = "in-person-meetings";
const SIGNAL_CARD_ID = "signal"; const SIGNAL_CARD_ID = "signal";
@@ -129,16 +141,24 @@ function isAddPlatformCard(cardId: string | null): boolean {
); );
} }
export function CardsScreen() { export function CommunicationMethodsScreen() {
const m = useMessages(); const m = useMessages();
const comm = m.create.communication; const comm = m.create.communication;
const mdUp = useCreateFlowMdUp(); const mdUp = useCreateFlowMdUp();
const { markCreateFlowInteraction } = useCreateFlow(); const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [createModalOpen, setCreateModalOpen] = useState(false); const [createModalOpen, setCreateModalOpen] = useState(false);
const [pendingCardId, setPendingCardId] = useState<string | null>(null); const [pendingCardId, setPendingCardId] = useState<string | null>(null);
const selectedIds = state.selectedCommunicationMethodIds ?? [];
const setSelectedIds = useCallback(
(next: string[]) => {
updateState({ selectedCommunicationMethodIds: next });
},
[updateState],
);
const sampleCards = useMemo( const sampleCards = useMemo(
() => () =>
COMMUNICATION_CARD_ORDER.map((id) => { COMMUNICATION_CARD_ORDER.map((id) => {
@@ -154,9 +174,25 @@ export function CardsScreen() {
); );
const title = expanded ? comm.page.expandedTitle : comm.page.compactTitle; const title = expanded ? comm.page.expandedTitle : comm.page.compactTitle;
const description = expanded
? comm.page.expandedDescription const description = expanded ? (
: comm.page.compactDescription; comm.page.expandedDescription
) : (
<>
{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)]"
onClick={() => {
markCreateFlowInteraction();
setExpanded(true);
}}
>
{comm.page.compactDescriptionLinkLabel}
</button>
{comm.page.compactDescriptionAfter}
</>
);
const modalConfig = const modalConfig =
pendingCardId && pendingCardId in comm.modals pendingCardId && pendingCardId in comm.modals
@@ -198,13 +234,15 @@ export function CardsScreen() {
const handleCreateModalConfirm = useCallback(() => { const handleCreateModalConfirm = useCallback(() => {
markCreateFlowInteraction(); markCreateFlowInteraction();
if (pendingCardId) { if (pendingCardId) {
setSelectedIds((prev) => setSelectedIds(
prev.includes(pendingCardId) ? prev : [...prev, pendingCardId], selectedIds.includes(pendingCardId)
? selectedIds
: [...selectedIds, pendingCardId],
); );
} }
setCreateModalOpen(false); setCreateModalOpen(false);
setPendingCardId(null); setPendingCardId(null);
}, [markCreateFlowInteraction, pendingCardId]); }, [markCreateFlowInteraction, pendingCardId, selectedIds, setSelectedIds]);
return ( return (
<CreateFlowStepShell <CreateFlowStepShell
@@ -219,7 +257,7 @@ export function CardsScreen() {
justification="center" justification="center"
/> />
</div> </div>
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}> <div className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}>
<CardStack <CardStack
cards={sampleCards} cards={sampleCards}
selectedIds={selectedIds} selectedIds={selectedIds}
@@ -231,6 +269,8 @@ export function CardsScreen() {
}} }}
hasMore={true} hasMore={true}
toggleLabel={comm.page.seeAllLink} toggleLabel={comm.page.seeAllLink}
compactRecommendedLimit={3}
compactDesktopLayout="flexWrap"
headerLockupSize={mdUp ? "L" : "M"} headerLockupSize={mdUp ? "L" : "M"}
/> />
</div> </div>
@@ -0,0 +1,179 @@
"use client";
/**
* `conflict-management` step — Figma compact card stack (node `20879-15979`).
* Registry: `CREATE_FLOW_SCREEN_REGISTRY["conflict-management"]`.
*/
import { useState, useCallback, useMemo } from "react";
import { useMessages } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import CardStack from "../../../components/utility/CardStack";
import Create from "../../../components/modals/Create";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
const CONFLICT_CARD_ORDER = [
"peer-mediation",
"conflict-resolution-council",
"facilitated-negotiation",
"ad-hoc-arbitration",
"conflict-workshops",
"6",
"7",
"8",
] as const;
export function ConflictManagementScreen() {
const m = useMessages();
const cm = m.create.conflictManagement;
const mdUp = useCreateFlowMdUp();
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const [expanded, setExpanded] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
const selectedIds = state.selectedConflictManagementIds ?? [];
const setSelectedIds = useCallback(
(next: string[]) => {
updateState({ selectedConflictManagementIds: next });
},
[updateState],
);
const sampleCards = useMemo(
() =>
CONFLICT_CARD_ORDER.map((id) => {
const row = cm.cards[id as keyof typeof cm.cards];
return {
id,
label: row.label,
supportText: row.supportText,
recommended: true,
};
}),
[cm],
);
const title = expanded ? cm.page.expandedTitle : cm.page.compactTitle;
const description = expanded ? (
cm.page.expandedDescription
) : (
<>
{cm.page.compactDescriptionBefore}
<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);
}}
>
{cm.page.compactDescriptionLinkLabel}
</button>
{cm.page.compactDescriptionAfter}
</>
);
const modalConfig =
pendingCardId && pendingCardId in cm.modals
? (() => {
const modal = cm.modals[pendingCardId as keyof typeof cm.modals];
return {
title: modal.title,
description: modal.description,
nextButtonText: cm.confirmModal.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
})()
: {
title: cm.confirmModal.title,
description: cm.confirmModal.description,
nextButtonText: cm.confirmModal.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
const handleCardClick = useCallback(
(id: string) => {
markCreateFlowInteraction();
setPendingCardId(id);
setCreateModalOpen(true);
},
[markCreateFlowInteraction],
);
const handleCreateModalClose = useCallback(() => {
setCreateModalOpen(false);
setPendingCardId(null);
}, []);
const handleCreateModalConfirm = useCallback(() => {
markCreateFlowInteraction();
if (pendingCardId) {
setSelectedIds(
selectedIds.includes(pendingCardId)
? selectedIds
: [...selectedIds, pendingCardId],
);
}
setCreateModalOpen(false);
setPendingCardId(null);
}, [markCreateFlowInteraction, pendingCardId, selectedIds, setSelectedIds]);
return (
<CreateFlowStepShell
variant="wideGridLoosePadding"
contentTopBelowMd="space-800"
>
<div className="flex w-full min-w-0 flex-col items-center gap-6">
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
<CreateFlowHeaderLockup
title={title}
description={description}
justification="center"
/>
</div>
<div className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}>
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardClick}
expanded={expanded}
onToggleExpand={() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}}
hasMore={true}
toggleLabel={cm.page.seeAllLink}
compactRecommendedLimit={5}
compactDesktopLayout="pyramidFive"
headerLockupSize={mdUp ? "L" : "M"}
/>
</div>
</div>
<Create
isOpen={createModalOpen}
onClose={handleCreateModalClose}
onNext={handleCreateModalConfirm}
title={modalConfig.title}
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
showBackButton={modalConfig.showBackButton}
currentStep={modalConfig.currentStep}
totalSteps={modalConfig.totalSteps}
/>
</CreateFlowStepShell>
);
}
@@ -0,0 +1,179 @@
"use client";
/**
* `membership-methods` step — Figma compact card stack (node `20858-13947`).
* Registry: `CREATE_FLOW_SCREEN_REGISTRY["membership-methods"]`.
*/
import { useState, useCallback, useMemo } from "react";
import { useMessages } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import CardStack from "../../../components/utility/CardStack";
import Create from "../../../components/modals/Create";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
const MEMBERSHIP_CARD_ORDER = [
"open-access",
"orientation-required",
"invitation-only",
"contribution-based",
"mentorship",
"6",
"7",
"8",
] as const;
export function MembershipMethodsScreen() {
const m = useMessages();
const mem = m.create.membership;
const mdUp = useCreateFlowMdUp();
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const [expanded, setExpanded] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
const selectedIds = state.selectedMembershipMethodIds ?? [];
const setSelectedIds = useCallback(
(next: string[]) => {
updateState({ selectedMembershipMethodIds: next });
},
[updateState],
);
const sampleCards = useMemo(
() =>
MEMBERSHIP_CARD_ORDER.map((id) => {
const row = mem.cards[id as keyof typeof mem.cards];
return {
id,
label: row.label,
supportText: row.supportText,
recommended: true,
};
}),
[mem],
);
const title = expanded ? mem.page.expandedTitle : mem.page.compactTitle;
const description = expanded ? (
mem.page.expandedDescription
) : (
<>
{mem.page.compactDescriptionBefore}
<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);
}}
>
{mem.page.compactDescriptionLinkLabel}
</button>
{mem.page.compactDescriptionAfter}
</>
);
const modalConfig =
pendingCardId && pendingCardId in mem.modals
? (() => {
const modal = mem.modals[pendingCardId as keyof typeof mem.modals];
return {
title: modal.title,
description: modal.description,
nextButtonText: mem.confirmModal.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
})()
: {
title: mem.confirmModal.title,
description: mem.confirmModal.description,
nextButtonText: mem.confirmModal.nextButtonText,
showBackButton: false as const,
currentStep: undefined,
totalSteps: undefined,
};
const handleCardClick = useCallback(
(id: string) => {
markCreateFlowInteraction();
setPendingCardId(id);
setCreateModalOpen(true);
},
[markCreateFlowInteraction],
);
const handleCreateModalClose = useCallback(() => {
setCreateModalOpen(false);
setPendingCardId(null);
}, []);
const handleCreateModalConfirm = useCallback(() => {
markCreateFlowInteraction();
if (pendingCardId) {
setSelectedIds(
selectedIds.includes(pendingCardId)
? selectedIds
: [...selectedIds, pendingCardId],
);
}
setCreateModalOpen(false);
setPendingCardId(null);
}, [markCreateFlowInteraction, pendingCardId, selectedIds, setSelectedIds]);
return (
<CreateFlowStepShell
variant="wideGridLoosePadding"
contentTopBelowMd="space-800"
>
<div className="flex w-full min-w-0 flex-col items-center gap-6">
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
<CreateFlowHeaderLockup
title={title}
description={description}
justification="center"
/>
</div>
<div className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}>
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardClick}
expanded={expanded}
onToggleExpand={() => {
markCreateFlowInteraction();
setExpanded((prev) => !prev);
}}
hasMore={true}
toggleLabel={mem.page.seeAllLink}
compactRecommendedLimit={5}
compactDesktopLayout="pyramidFive"
headerLockupSize={mdUp ? "L" : "M"}
/>
</div>
</div>
<Create
isOpen={createModalOpen}
onClose={handleCreateModalClose}
onNext={handleCreateModalConfirm}
title={modalConfig.title}
description={modalConfig.description}
nextButtonText={modalConfig.nextButtonText}
showBackButton={modalConfig.showBackButton}
currentStep={modalConfig.currentStep}
totalSteps={modalConfig.totalSteps}
/>
</CreateFlowStepShell>
);
}
@@ -1,5 +1,13 @@
"use client"; "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 { useState, useCallback, useMemo } from "react";
import DecisionMakingSidebar from "../../../components/utility/DecisionMakingSidebar"; import DecisionMakingSidebar from "../../../components/utility/DecisionMakingSidebar";
import CardStack from "../../../components/utility/CardStack"; import CardStack from "../../../components/utility/CardStack";
@@ -8,22 +16,27 @@ import type { CardStackItem } from "../../../components/utility/CardStack/CardSt
import { useMessages } from "../../../contexts/MessagesContext"; import { useMessages } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext"; import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp"; import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
} from "../../components/createFlowLayoutTokens";
export function RightRailScreen() { export function RightRailScreen() {
const m = useMessages(); const m = useMessages();
const rr = m.create.rightRail; const rr = m.create.rightRail;
const mdUp = useCreateFlowMdUp(); const mdUp = useCreateFlowMdUp();
const { markCreateFlowInteraction } = useCreateFlow(); const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState<string[]>( const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState<string[]>(
[], [],
); );
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const selectedIds = state.selectedDecisionApproachIds ?? [];
const setSelectedIds = useCallback(
(next: string[]) => {
updateState({ selectedDecisionApproachIds: next });
},
[updateState],
);
const messageBoxItems: InfoMessageBoxItem[] = useMemo( const messageBoxItems: InfoMessageBoxItem[] = useMemo(
() => () =>
rr.messageBox.items.map((item) => ({ rr.messageBox.items.map((item) => ({
@@ -47,7 +60,16 @@ export function RightRailScreen() {
const sidebarDescription = ( const sidebarDescription = (
<> <>
{rr.sidebar.descriptionBefore} {rr.sidebar.descriptionBefore}
<span className="underline">{rr.sidebar.descriptionLink}</span> <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} {rr.sidebar.descriptionAfter}
</> </>
); );
@@ -65,11 +87,13 @@ export function RightRailScreen() {
const handleCardSelect = useCallback( const handleCardSelect = useCallback(
(id: string) => { (id: string) => {
markCreateFlowInteraction(); markCreateFlowInteraction();
setSelectedIds((prev) => setSelectedIds(
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], selectedIds.includes(id)
? selectedIds.filter((x) => x !== id)
: [...selectedIds, id],
); );
}, },
[markCreateFlowInteraction], [markCreateFlowInteraction, selectedIds, setSelectedIds],
); );
const handleToggleExpand = useCallback(() => { const handleToggleExpand = useCallback(() => {
@@ -78,48 +102,40 @@ export function RightRailScreen() {
}, [markCreateFlowInteraction]); }, [markCreateFlowInteraction]);
return ( return (
<div className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden md:h-full"> <CreateFlowTwoColumnSelectShell
<div className="flex min-h-0 flex-1 overflow-hidden px-5 max-md:overflow-y-auto md:px-12"> contentTopBelowMd="space-800"
<div lgVerticalAlign="start"
className={`mx-auto grid h-auto min-h-0 w-full shrink-0 grid-cols-1 gap-6 min-w-0 max-md:pt-[var(--space-800)] max-md:pb-8 md:h-full md:grid-cols-2 md:justify-items-center md:gap-12 md:pb-8 ${CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS}`} header={
> <DecisionMakingSidebar
<div title={rr.sidebar.title}
className={`flex flex-col items-stretch justify-start overflow-hidden md:justify-center ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`} description={sidebarDescription}
> messageBoxTitle={rr.messageBox.title}
<DecisionMakingSidebar messageBoxItems={messageBoxItems}
title={rr.sidebar.title} messageBoxCheckedIds={messageBoxCheckedIds}
description={sidebarDescription} onMessageBoxCheckboxChange={handleMessageBoxCheckboxChange}
messageBoxTitle={rr.messageBox.title} size={mdUp ? "L" : "M"}
messageBoxItems={messageBoxItems} justification={mdUp ? "left" : "center"}
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
</div> cards={sampleCards}
<div selectedIds={selectedIds}
className={`scrollbar-hide relative flex min-h-0 flex-col overflow-x-hidden max-md:overflow-visible md:overflow-y-auto ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`} onCardSelect={handleCardSelect}
> expanded={expanded}
<div className="flex w-full min-w-0 flex-col items-center gap-6 py-0 md:pb-8"> onToggleExpand={handleToggleExpand}
<CardStack hasMore={true}
cards={sampleCards} toggleLabel={rr.cardStack.toggleSeeAll}
selectedIds={selectedIds} showLessLabel={rr.cardStack.toggleShowLess}
onCardSelect={handleCardSelect} title=""
expanded={expanded} description=""
onToggleExpand={handleToggleExpand} layout="singleStack"
hasMore={true} compactRecommendedLimit={5}
toggleLabel={rr.cardStack.toggleSeeAll} className="w-full"
showLessLabel={rr.cardStack.toggleShowLess} headerLockupSize={mdUp ? "L" : "M"}
title={rr.cardStack.emptyTitle} />
description={rr.cardStack.emptyDescription}
layout="singleStack"
className="w-full"
headerLockupSize={mdUp ? "L" : "M"}
/>
</div>
</div>
</div>
</div> </div>
</div> </CreateFlowTwoColumnSelectShell>
); );
} }
+12 -2
View File
@@ -19,8 +19,10 @@ export type CreateFlowStep =
| "community-save" | "community-save"
| "review" | "review"
| "core-values" | "core-values"
| "cards" | "communication-methods"
| "right-rail" | "membership-methods"
| "decision-approaches"
| "conflict-management"
| "confirm-stakeholders" | "confirm-stakeholders"
| "final-review" | "final-review"
| "completed"; | "completed";
@@ -83,6 +85,14 @@ export interface CreateFlowState {
coreValuesChipsSnapshot?: CommunityStructureChipSnapshotRow[]; coreValuesChipsSnapshot?: CommunityStructureChipSnapshotRow[];
/** User-authored detail text keyed by chip id (preset ids or custom UUIDs). */ /** User-authored detail text keyed by chip id (preset ids or custom UUIDs). */
coreValueDetailsByChipId?: Record<string, CoreValueDetailEntry>; coreValueDetailsByChipId?: Record<string, CoreValueDetailEntry>;
/** Create Custom — communication methods step (`/create/communication-methods`); card ids from `create.communication` presets. */
selectedCommunicationMethodIds?: string[];
/** Create Custom — membership / join patterns (`/create/membership-methods`); card ids from `create.membership` presets. */
selectedMembershipMethodIds?: string[];
/** Create Custom — decision approaches (`/create/decision-approaches`); card ids from `create.rightRail` presets. */
selectedDecisionApproachIds?: string[];
/** Create Custom — conflict management (`/create/conflict-management`); card ids from `create.conflictManagement` presets. */
selectedConflictManagementIds?: string[];
currentStep?: CreateFlowStep; currentStep?: CreateFlowStep;
/** Section drafts; structure will tighten as steps persist real shapes. */ /** Section drafts; structure will tighten as steps persist real shapes. */
sections?: Record<string, unknown>[]; sections?: Record<string, unknown>[];
@@ -16,8 +16,10 @@ const PROPORTION_BY_STEP_INDEX: readonly ProportionBarState[] = [
"2-0", // community-save "2-0", // community-save
"2-0", // review (Figma Flow — Review `19706:12135`: same segment fill as end of Create Community) "2-0", // review (Figma Flow — Review `19706:12135`: same segment fill as end of Create Community)
"2-0", // core-values (same segment as review / end of Create Community) "2-0", // core-values (same segment as review / end of Create Community)
"2-2", // cards "2-1", // communication-methods (Figma — Compact Card Stack)
"3-0", // right-rail "2-2", // membership-methods (Figma — Compact Card Stack `20858:13947`)
"2-3", // decision-approaches (Figma Flow — Right Rail `20523:23509`)
"3-0", // conflict-management (Figma Flow — Compact Card Stack `20879:15979`; start of Review segment)
"3-1", // confirm-stakeholders "3-1", // confirm-stakeholders
"3-2", // final-review "3-2", // final-review
"3-2", // completed "3-2", // completed
+26 -5
View File
@@ -2,7 +2,8 @@ import type { CreateFlowStep } from "../types";
/** /**
* Figma layout families for the create flow (not encoded in the URL). * Figma layout families for the create flow (not encoded in the URL).
* Registry and `app/create/screens/` are organized by these kinds. * `app/create/screens/<kind>/` mirrors these names: e.g. `layoutKind: "select"` → `screens/select/`,
* `"card"` → `screens/card/` (compact card-stack frames, distinct from two-column chip selects).
*/ */
export type CreateFlowLayoutKind = export type CreateFlowLayoutKind =
| "informational" | "informational"
@@ -90,18 +91,30 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record<
messageNamespace: "create.coreValues", messageNamespace: "create.coreValues",
centeredBodyBelowMd: false, centeredBodyBelowMd: false,
}, },
cards: { "communication-methods": {
layoutKind: "card", layoutKind: "card",
figmaNodeId: "TBD-cards", figmaNodeId: "20246-15828",
messageNamespace: "create.communication", messageNamespace: "create.communication",
centeredBodyBelowMd: false, centeredBodyBelowMd: false,
}, },
"right-rail": { "membership-methods": {
layoutKind: "card",
figmaNodeId: "20858-13947",
messageNamespace: "create.membership",
centeredBodyBelowMd: false,
},
"decision-approaches": {
layoutKind: "right-rail", layoutKind: "right-rail",
figmaNodeId: "TBD-right-rail", figmaNodeId: "20523-23509",
messageNamespace: "create.rightRail", messageNamespace: "create.rightRail",
centeredBodyBelowMd: false, centeredBodyBelowMd: false,
}, },
"conflict-management": {
layoutKind: "card",
figmaNodeId: "20879-15979",
messageNamespace: "create.conflictManagement",
centeredBodyBelowMd: false,
},
"confirm-stakeholders": { "confirm-stakeholders": {
layoutKind: "select", layoutKind: "select",
figmaNodeId: "21104-46594", figmaNodeId: "21104-46594",
@@ -128,3 +141,11 @@ export function createFlowStepUsesCenteredTextLayout(
if (!step) return false; if (!step) return false;
return CREATE_FLOW_SCREEN_REGISTRY[step].centeredBodyBelowMd; return CREATE_FLOW_SCREEN_REGISTRY[step].centeredBodyBelowMd;
} }
/** Steps whose main area uses the CardStack-style layout (`layoutKind: "card"`). */
export function createFlowStepUsesCardLayout(
step: CreateFlowStep | null,
): boolean {
if (!step) return false;
return CREATE_FLOW_SCREEN_REGISTRY[step].layoutKind === "card";
}
+4 -2
View File
@@ -21,8 +21,10 @@ export const FLOW_STEP_ORDER: readonly CreateFlowStep[] = [
"community-save", "community-save",
"review", "review",
"core-values", "core-values",
"cards", "communication-methods",
"right-rail", "membership-methods",
"decision-approaches",
"conflict-management",
"confirm-stakeholders", "confirm-stakeholders",
"final-review", "final-review",
"completed", "completed",
+7 -6
View File
@@ -11,7 +11,7 @@ The Figma **Create Community** sequence is the **source of truth** for the first
| Stage (Figma) | Purpose (summary) | `CreateFlowStep` values (in order) | | Stage (Figma) | Purpose (summary) | `CreateFlowStep` values (in order) |
| --- | --- | --- | | --- | --- | --- |
| **Create Community** | Intro, naming, structure, context, size, upload, save progress (email), then community review. | `informational``community-name``community-structure``community-context``community-size``community-upload``community-save``review` | | **Create Community** | Intro, naming, structure, context, size, upload, save progress (email), then community review. | `informational``community-name``community-structure``community-context``community-size``community-upload``community-save``review` |
| **Create Custom CommunityRule** | Author the CommunityRule content and structure. | `cards``right-rail` | | **Create Custom CommunityRule** | Author the CommunityRule content and structure. | `core-values``communication-methods``right-rail` (further card-stack steps get their own `screenId` and `screens/card/*Screen.tsx`; `right-rail` uses `layoutKind: "right-rail"`) |
| **Review and complete** | Stakeholders, final card, publish, success. | `confirm-stakeholders``final-review``completed` | | **Review and complete** | Stakeholders, final card, publish, success. | `confirm-stakeholders``final-review``completed` |
Treat these stages as the **canonical product sections** when adding chrome (e.g. stage headers, progress copy), breaking work across teams, or reusing flows in other surfaces. **Layout kind** is **not** encoded in the URL; it lives in [`CREATE_FLOW_SCREEN_REGISTRY`](../app/create/utils/createFlowScreenRegistry.ts) (Figma node id + `layoutKind` per step). Figma defines eight layout kinds: **informational**, **text**, **select**, **upload**, **review**, **card**, **right-rail**, **completed**`CreateFlowLayoutKind` and [`app/create/screens/`](../app/create/screens/) mirror that list (one folder per kind; multiple steps may share a kind, e.g. several **select** screens). Treat these stages as the **canonical product sections** when adding chrome (e.g. stage headers, progress copy), breaking work across teams, or reusing flows in other surfaces. **Layout kind** is **not** encoded in the URL; it lives in [`CREATE_FLOW_SCREEN_REGISTRY`](../app/create/utils/createFlowScreenRegistry.ts) (Figma node id + `layoutKind` per step). Figma defines eight layout kinds: **informational**, **text**, **select**, **upload**, **review**, **card**, **right-rail**, **completed**`CreateFlowLayoutKind` and [`app/create/screens/`](../app/create/screens/) mirror that list (one folder per kind; multiple steps may share a kind, e.g. several **select** screens).
@@ -34,11 +34,12 @@ Order is defined in code by [`FLOW_STEP_ORDER`](../app/create/utils/flowSteps.ts
| 6 | Create Community | `community-upload` | `/create/community-upload` | | 6 | Create Community | `community-upload` | `/create/community-upload` |
| 7 | Create Community | `community-save` | `/create/community-save` | | 7 | Create Community | `community-save` | `/create/community-save` |
| 8 | Create Community (review frame) | `review` | `/create/review` | | 8 | Create Community (review frame) | `review` | `/create/review` |
| 9 | Create Custom CommunityRule | `cards` | `/create/cards` | | 9 | Create Custom CommunityRule | `core-values` | `/create/core-values` |
| 10 | Create Custom CommunityRule | `right-rail` | `/create/right-rail` | | 10 | Create Custom CommunityRule | `communication-methods` | `/create/communication-methods` |
| 11 | Review and complete | `confirm-stakeholders` | `/create/confirm-stakeholders` | | 11 | Create Custom CommunityRule | `right-rail` | `/create/right-rail` |
| 12 | Review and complete | `final-review` | `/create/final-review` | | 12 | Review and complete | `confirm-stakeholders` | `/create/confirm-stakeholders` |
| 13 | Review and complete | `completed` | `/create/completed` | | 13 | Review and complete | `final-review` | `/create/final-review` |
| 14 | Review and complete | `completed` | `/create/completed` |
**Primary entry:** marketing header “Create rule” navigates to **`/create`**, which redirects to **`/create/informational`** (see [`TopNav.container.tsx`](../app/components/navigation/TopNav/TopNav.container.tsx)). **Primary entry:** marketing header “Create rule” navigates to **`/create`**, which redirects to **`/create/informational`** (see [`TopNav.container.tsx`](../app/components/navigation/TopNav/TopNav.container.tsx)).
+13 -14
View File
@@ -1,25 +1,24 @@
import type { CreateFlowState } from "../../app/create/types"; import type { CreateFlowState } from "../../app/create/types";
/** Legacy `currentStep` values mapped to the current `CreateFlowStep` id. */
const LEGACY_CREATE_FLOW_STEP_RENAMES: Readonly<Record<string, string>> = {
"right-rail": "decision-approaches",
};
/** /**
* Maps pre-rename draft keys and step ids (`community-reflection` → `community-save`). * Normalizes parsed draft JSON before merging into create-flow context.
* Safe to run on any parsed draft payload before merging into context. * Renames deprecated step ids so old drafts and bookmarks stay valid.
*/ */
export function migrateLegacyCreateFlowState( export function migrateLegacyCreateFlowState(
raw: Record<string, unknown> | null | undefined, raw: Record<string, unknown> | null | undefined,
): CreateFlowState { ): CreateFlowState {
if (!raw || typeof raw !== "object") return {}; if (!raw || typeof raw !== "object") return {};
const next: Record<string, unknown> = { ...raw }; const step = raw.currentStep;
if (typeof next.communityReflection === "string") { if (typeof step === "string") {
if ( const next = LEGACY_CREATE_FLOW_STEP_RENAMES[step];
next.communitySaveEmail === undefined || if (next !== undefined) {
next.communitySaveEmail === "" return { ...raw, currentStep: next } as CreateFlowState;
) {
next.communitySaveEmail = next.communityReflection;
} }
} }
delete next.communityReflection; return raw as CreateFlowState;
if (next.currentStep === "community-reflection") {
next.currentStep = "community-save";
}
return next as CreateFlowState;
} }
@@ -63,6 +63,10 @@ export const createFlowStateSchema = z
coreValueDetailsByChipId: z coreValueDetailsByChipId: z
.record(coreValueDetailEntrySchema) .record(coreValueDetailEntrySchema)
.optional(), .optional(),
selectedCommunicationMethodIds: z.array(z.string()).max(200).optional(),
selectedMembershipMethodIds: z.array(z.string()).max(200).optional(),
selectedDecisionApproachIds: z.array(z.string()).max(200).optional(),
selectedConflictManagementIds: z.array(z.string()).max(200).optional(),
currentStep: createFlowStepSchema.optional(), currentStep: createFlowStepSchema.optional(),
sections: z.array(z.unknown()).optional(), sections: z.array(z.unknown()).optional(),
stakeholders: z.array(z.unknown()).optional(), stakeholders: z.array(z.unknown()).optional(),
+3 -1
View File
@@ -2,7 +2,9 @@
"_comment": "Create flow communication step: page, cards, and add-platform modals", "_comment": "Create flow communication step: page, cards, and add-platform modals",
"page": { "page": {
"compactTitle": "How should this community communicate with each-other?", "compactTitle": "How should this community communicate with each-other?",
"compactDescription": "You can select multiple methods for different needs or add your own", "compactDescriptionBefore": "You can select multiple methods for different needs or ",
"compactDescriptionLinkLabel": "add",
"compactDescriptionAfter": " your own",
"expandedTitle": "What method should this community use to communicate with eachother?", "expandedTitle": "What method should this community use to communicate with eachother?",
"expandedDescription": "You can select multiple methods for different needs or add your own", "expandedDescription": "You can select multiple methods for different needs or add your own",
"seeAllLink": "See all communication approaches" "seeAllLink": "See all communication approaches"
@@ -0,0 +1,76 @@
{
"_comment": "Create flow — conflict management (Figma Flow — Compact Card Stack `20879:15979`)",
"page": {
"compactTitle": "How should conflicts be managed\nin your group?",
"compactDescriptionBefore": "You can also combine or ",
"compactDescriptionLinkLabel": "add",
"compactDescriptionAfter": " new approaches to the list",
"expandedTitle": "How should conflicts be managed in your group?",
"expandedDescription": "You can also combine or add new approaches to the list",
"seeAllLink": "See all conflict management approaches"
},
"confirmModal": {
"title": "Confirm selection",
"description": "Confirm to select this option.",
"nextButtonText": "Confirm"
},
"cards": {
"peer-mediation": {
"label": "Peer Mediation",
"supportText": "Trained members within the organization mediate disputes among peers."
},
"conflict-resolution-council": {
"label": "Conflict Resolution Council",
"supportText": "Senior members with institutional knowledge provide guidance or decisions."
},
"facilitated-negotiation": {
"label": "Facilitated Negotiation",
"supportText": "A neutral facilitator helps guide the negotiation process."
},
"ad-hoc-arbitration": {
"label": "Ad Hoc Arbitration",
"supportText": "Arbitrators are chosen specifically for a particular case."
},
"conflict-workshops": {
"label": "Conflict Workshops",
"supportText": "Structured sessions where parties collaboratively resolve disputes and improve future interactions."
},
"6": {
"label": "Label",
"supportText": "Additional conflict management approach."
},
"7": {
"label": "Label",
"supportText": "Additional conflict management approach."
},
"8": {
"label": "Label",
"supportText": "Additional conflict management approach."
}
},
"modals": {
"peer-mediation": {
"title": "Peer Mediation",
"description": "Trained members within the organization mediate disputes among peers."
},
"conflict-resolution-council": {
"title": "Conflict Resolution Council",
"description": "Senior members with institutional knowledge provide guidance or decisions."
},
"facilitated-negotiation": {
"title": "Facilitated Negotiation",
"description": "A neutral facilitator helps guide the negotiation process."
},
"ad-hoc-arbitration": {
"title": "Ad Hoc Arbitration",
"description": "Arbitrators are chosen specifically for a particular case."
},
"conflict-workshops": {
"title": "Conflict Workshops",
"description": "Structured sessions where parties collaboratively resolve disputes and improve future interactions."
},
"6": { "title": "Label", "description": "Additional conflict management approach." },
"7": { "title": "Label", "description": "Additional conflict management approach." },
"8": { "title": "Label", "description": "Additional conflict management approach." }
}
}
+5 -1
View File
@@ -11,5 +11,9 @@
"confirmMembers": "Confirm members", "confirmMembers": "Confirm members",
"finalizeCommunityRule": "Finalize CommunityRule", "finalizeCommunityRule": "Finalize CommunityRule",
"confirmStakeholders": "Confirm Stakeholders", "confirmStakeholders": "Confirm Stakeholders",
"confirmCoreValues": "Confirm values" "confirmCoreValues": "Confirm values",
"confirmCommunication": "Confirm",
"confirmMembership": "Confirm",
"confirmRightRail": "Confirm",
"confirmConflictManagement": "Confirm"
} }
+76
View File
@@ -0,0 +1,76 @@
{
"_comment": "Create flow — membership / how members join (compact card stack)",
"page": {
"compactTitle": "How do new members join\nand get connected?",
"compactDescriptionBefore": "You can select multiple methods for different needs or ",
"compactDescriptionLinkLabel": "add",
"compactDescriptionAfter": " your own",
"expandedTitle": "How should new members join and get connected?",
"expandedDescription": "You can select multiple methods for different needs or add your own",
"seeAllLink": "See all membership approaches"
},
"confirmModal": {
"title": "Confirm selection",
"description": "Confirm to select this option.",
"nextButtonText": "Confirm"
},
"cards": {
"open-access": {
"label": "Open Access",
"supportText": "Maximum inclusion. Anyone can join immediately by simply showing up."
},
"orientation-required": {
"label": "Orientation Required",
"supportText": "Newcomers must attend a training or orientation session."
},
"invitation-only": {
"label": "Invitation Only",
"supportText": "New members can only join if they are 'vouched for' by existing members."
},
"contribution-based": {
"label": "Contribution Based",
"supportText": "Membership is reserved for people contributing their labor."
},
"mentorship": {
"label": "Mentorship",
"supportText": "New members are paired with 'Mentors' to guide them through a probationary period."
},
"6": {
"label": "Label",
"supportText": "Additional membership approach."
},
"7": {
"label": "Label",
"supportText": "Additional membership approach."
},
"8": {
"label": "Label",
"supportText": "Additional membership approach."
}
},
"modals": {
"open-access": {
"title": "Open Access",
"description": "Maximum inclusion. Anyone can join immediately by simply showing up."
},
"orientation-required": {
"title": "Orientation Required",
"description": "Newcomers must attend a training or orientation session."
},
"invitation-only": {
"title": "Invitation Only",
"description": "New members can only join if they are 'vouched for' by existing members."
},
"contribution-based": {
"title": "Contribution Based",
"description": "Membership is reserved for people contributing their labor."
},
"mentorship": {
"title": "Mentorship",
"description": "New members are paired with 'Mentors' to guide them through a probationary period."
},
"6": { "title": "Label", "description": "Additional membership approach." },
"7": { "title": "Label", "description": "Additional membership approach." },
"8": { "title": "Label", "description": "Additional membership approach." }
}
}
+31 -32
View File
@@ -1,9 +1,10 @@
{ {
"_comment": "Create flow — right rail / decision approaches (Figma Flow — Right Rail `20523:23509`)",
"sidebar": { "sidebar": {
"title": "How should conflicts be resolved?", "title": "How should this community make difficult decisions?",
"descriptionBefore": "You can also combine or ", "descriptionBefore": "Select as many as you need to describe how your group makes decisions. You can also ",
"descriptionLink": "add", "descriptionLinkLabel": "add",
"descriptionAfter": " new approaches to the list" "descriptionAfter": " new decision making approaches or interact with the categories below to filter."
}, },
"messageBox": { "messageBox": {
"title": "Consider defining approaches to steward key resources:", "title": "Consider defining approaches to steward key resources:",
@@ -16,99 +17,97 @@
}, },
"cardStack": { "cardStack": {
"toggleSeeAll": "See all decision approaches", "toggleSeeAll": "See all decision approaches",
"toggleShowLess": "Show less", "toggleShowLess": "Show less"
"emptyTitle": "",
"emptyDescription": ""
}, },
"cards": [ "cards": [
{ {
"id": "mediation", "id": "lazy-consensus",
"label": "Mediation", "label": "Lazy Consensus",
"supportText": "Collaborative work to reach a resolution that all parties can agree upon.", "supportText": "A decision is assumed approved unless objections are raised within a specified timeframe.",
"recommended": true "recommended": true
}, },
{ {
"id": "facilitation", "id": "do-ocracy",
"label": "Facilitated dialogue", "label": "Do-ocracy",
"supportText": "Structured sessions where parties collaboratively resolve disputes.", "supportText": "Decisions are made by those who take initiative and carry out the work.",
"recommended": true "recommended": true
}, },
{ {
"id": "invite-only", "id": "consensus-decision-making",
"label": "Invite-only", "label": "Consensus Decision-Making",
"supportText": "Private discussions with selected participants.", "supportText": "All members must agree. Best for important decisions in small groups. Does not work well for low stakes decisions.",
"recommended": true "recommended": true
}, },
{ {
"id": "arbitration", "id": "rotational-leadership",
"label": "Arbitration", "label": "Rotational Leadership",
"supportText": "Arbitrators are chosen specifically for a particular case.", "supportText": "Decision-making responsibilities rotate among members.",
"recommended": true "recommended": true
}, },
{ {
"id": "direct-dialogue", "id": "modified-consensus",
"label": "Direct dialogue", "label": "Modified Consensus",
"supportText": "Encouraging direct, respectful dialogue between those involved.", "supportText": "Attempts to reach full agreement first, but falls back to voting if consensus isnt possible.",
"recommended": true "recommended": true
}, },
{ {
"id": "label-1", "id": "label-1",
"label": "Label", "label": "Label",
"supportText": "", "supportText": "Additional decision approach.",
"recommended": false "recommended": false
}, },
{ {
"id": "label-2", "id": "label-2",
"label": "Label", "label": "Label",
"supportText": "", "supportText": "Additional decision approach.",
"recommended": false "recommended": false
}, },
{ {
"id": "label-3", "id": "label-3",
"label": "Label", "label": "Label",
"supportText": "", "supportText": "Additional decision approach.",
"recommended": false "recommended": false
}, },
{ {
"id": "label-4", "id": "label-4",
"label": "Label", "label": "Label",
"supportText": "", "supportText": "Additional decision approach.",
"recommended": false "recommended": false
}, },
{ {
"id": "label-5", "id": "label-5",
"label": "Label", "label": "Label",
"supportText": "", "supportText": "Additional decision approach.",
"recommended": false "recommended": false
}, },
{ {
"id": "label-6", "id": "label-6",
"label": "Label", "label": "Label",
"supportText": "", "supportText": "Additional decision approach.",
"recommended": false "recommended": false
}, },
{ {
"id": "label-7", "id": "label-7",
"label": "Label", "label": "Label",
"supportText": "", "supportText": "Additional decision approach.",
"recommended": false "recommended": false
}, },
{ {
"id": "label-8", "id": "label-8",
"label": "Label", "label": "Label",
"supportText": "", "supportText": "Additional decision approach.",
"recommended": false "recommended": false
}, },
{ {
"id": "label-9", "id": "label-9",
"label": "Label", "label": "Label",
"supportText": "", "supportText": "Additional decision approach.",
"recommended": false "recommended": false
}, },
{ {
"id": "label-10", "id": "label-10",
"label": "Label", "label": "Label",
"supportText": "", "supportText": "Additional decision approach.",
"recommended": false "recommended": false
} }
] ]
+4
View File
@@ -19,6 +19,8 @@ import profile from "./pages/profile.json";
import navigation from "./navigation.json"; import navigation from "./navigation.json";
import metadata from "./metadata.json"; import metadata from "./metadata.json";
import communication from "./create/communication.json"; import communication from "./create/communication.json";
import createMembership from "./create/membership.json";
import createConflictManagement from "./create/conflictManagement.json";
import createInformational from "./create/informational.json"; import createInformational from "./create/informational.json";
import createCommunityName from "./create/communityName.json"; import createCommunityName from "./create/communityName.json";
import createCommunitySize from "./create/communitySize.json"; import createCommunitySize from "./create/communitySize.json";
@@ -61,6 +63,8 @@ export default {
}, },
create: { create: {
communication, communication,
membership: createMembership,
conflictManagement: createConflictManagement,
informational: createInformational, informational: createInformational,
communityName: createCommunityName, communityName: createCommunityName,
communitySize: createCommunitySize, communitySize: createCommunitySize,
+9
View File
@@ -97,6 +97,15 @@ const nextConfig = {
return config; return config;
}, },
async redirects() {
return [
{
source: "/create/right-rail",
destination: "/create/decision-approaches",
permanent: true,
},
];
},
}; };
const withMDX = createMDX({ const withMDX = createMDX({
@@ -1,20 +1,20 @@
import { CardsScreen } from "../../app/create/screens/card/CardsScreen"; import { CommunicationMethodsScreen } from "../../app/create/screens/card/CommunicationMethodsScreen";
export default { export default {
title: "Pages/Create Flow/Cards", title: "Pages/Create Flow/Communication methods",
component: CardsScreen, component: CommunicationMethodsScreen,
parameters: { parameters: {
layout: "fullscreen", layout: "fullscreen",
docs: { docs: {
description: { description: {
component: component:
"Communication / card selection step with modals and responsive layout.", "Communication methods step (`/create/communication-methods`): card stack, modals, responsive layout.",
}, },
}, },
}, },
decorators: [ decorators: [
(Story) => ( (Story) => (
<div className="min-h-screen bg-black flex items-center justify-center"> <div className="flex min-h-screen items-center justify-center bg-black">
<Story /> <Story />
</div> </div>
), ),
@@ -31,6 +31,7 @@ export default {
"2-0", "2-0",
"2-1", "2-1",
"2-2", "2-2",
"2-3",
"3-0", "3-0",
"3-1", "3-1",
"3-2", "3-2",
@@ -86,6 +87,7 @@ export const AllStates = {
<ProportionBar {..._args} progress="2-0" /> <ProportionBar {..._args} progress="2-0" />
<ProportionBar {..._args} progress="2-1" /> <ProportionBar {..._args} progress="2-1" />
<ProportionBar {..._args} progress="2-2" /> <ProportionBar {..._args} progress="2-2" />
<ProportionBar {..._args} progress="2-3" />
<ProportionBar {..._args} progress="3-0" /> <ProportionBar {..._args} progress="3-0" />
<ProportionBar {..._args} progress="3-1" /> <ProportionBar {..._args} progress="3-1" />
<ProportionBar {..._args} progress="3-2" /> <ProportionBar {..._args} progress="3-2" />
+4 -3
View File
@@ -40,8 +40,8 @@ describe("ProportionBar (behavioral tests)", () => {
it("renders proportion bar with correct progress value", () => { it("renders proportion bar with correct progress value", () => {
render(<ProportionBar progress="2-1" />); render(<ProportionBar progress="2-1" />);
const progressbar = screen.getByRole("progressbar"); const progressbar = screen.getByRole("progressbar");
// 2-1: First section full (1) + second section 1/3 filled = 1 + 1/3 ≈ 1.333 // 2-1 (Figma `17861:33241`): first section full + second section 1/4 filled = 1.25.
expect(progressbar).toHaveAttribute("aria-valuenow", "1.3333333333333333"); expect(progressbar).toHaveAttribute("aria-valuenow", "1.25");
expect(progressbar).toHaveAttribute("aria-valuemin", "0"); expect(progressbar).toHaveAttribute("aria-valuemin", "0");
expect(progressbar).toHaveAttribute("aria-valuemax", "3"); expect(progressbar).toHaveAttribute("aria-valuemax", "3");
}); });
@@ -63,7 +63,8 @@ describe("ProportionBar (behavioral tests)", () => {
{ progress: "1-0" as const, expected: 1 / 6 }, // First section 1/6 filled { progress: "1-0" as const, expected: 1 / 6 }, // First section 1/6 filled
{ progress: "1-5" as const, expected: 1 }, // First section 6/6 filled (fully filled) { progress: "1-5" as const, expected: 1 }, // First section 6/6 filled (fully filled)
{ progress: "2-0" as const, expected: 1 }, // First section full, second empty { progress: "2-0" as const, expected: 1 }, // First section full, second empty
{ progress: "2-2" as const, expected: 1 + 2 / 3 }, // First section full, second section 2/3 filled { progress: "2-2" as const, expected: 1 + 1 / 2 }, // 1 + 1/2 per Figma `18861:15250`
{ progress: "2-3" as const, expected: 1 + 3 / 4 }, // 1 + 3/4 per Figma `21434:17632`
{ progress: "3-0" as const, expected: 2 }, // First two sections full, third empty { progress: "3-0" as const, expected: 2 }, // First two sections full, third empty
{ progress: "3-2" as const, expected: 2 + 2 / 3 }, // First two sections full, third section 2/3 filled { progress: "3-2" as const, expected: 2 + 2 / 3 }, // First two sections full, third section 2/3 filled
]; ];
@@ -6,16 +6,16 @@ import {
} from "../utils/test-utils"; } from "../utils/test-utils";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { describe, test, expect, afterEach } from "vitest"; import { describe, test, expect, afterEach } from "vitest";
import { CardsScreen } from "../../app/create/screens/card/CardsScreen"; import { CommunicationMethodsScreen } from "../../app/create/screens/card/CommunicationMethodsScreen";
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
}); });
describe("Create flow cards page", () => { describe("Create flow communication-methods page", () => {
test("clicking a card opens the Create modal", async () => { test("clicking a card opens the Create modal", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<CardsScreen />); render(<CommunicationMethodsScreen />);
const signalCards = screen.getAllByRole("button", { const signalCards = screen.getAllByRole("button", {
name: /Signal: Encrypted messaging/, name: /Signal: Encrypted messaging/,
@@ -29,7 +29,7 @@ describe("Create flow cards page", () => {
}); });
test("renders without error", () => { test("renders without error", () => {
render(<CardsScreen />); render(<CommunicationMethodsScreen />);
expect( expect(
screen.getByText( screen.getByText(
@@ -39,13 +39,12 @@ describe("Create flow cards page", () => {
}); });
test("renders HeaderLockup and CardStack content", () => { test("renders HeaderLockup and CardStack content", () => {
render(<CardsScreen />); render(<CommunicationMethodsScreen />);
expect( expect(
screen.getByText( screen.getByText(/You can select multiple methods for different needs or/),
"You can select multiple methods for different needs or add your own",
),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByRole("button", { name: "add" })).toBeInTheDocument();
expect( expect(
screen.getByRole("button", { name: "See all communication approaches" }), screen.getByRole("button", { name: "See all communication approaches" }),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -53,7 +52,7 @@ describe("Create flow cards page", () => {
test("toggle expands and shows Show less", async () => { test("toggle expands and shows Show less", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<CardsScreen />); render(<CommunicationMethodsScreen />);
const toggle = screen.getByRole("button", { const toggle = screen.getByRole("button", {
name: "See all communication approaches", name: "See all communication approaches",
+9 -9
View File
@@ -18,7 +18,7 @@ describe("Create flow right-rail page", () => {
expect( expect(
screen.getByRole("heading", { screen.getByRole("heading", {
name: "How should conflicts be resolved?", name: "How should this community make difficult decisions?",
}), }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@@ -30,9 +30,9 @@ describe("Create flow right-rail page", () => {
if (element?.tagName !== "P") return false; if (element?.tagName !== "P") return false;
const text = element.textContent ?? ""; const text = element.textContent ?? "";
return ( return (
text.includes("You can also combine or") && text.includes("Select as many as you need") &&
text.includes("add") && text.includes("add") &&
text.includes("new approaches to the list") text.includes("new decision making approaches")
); );
}); });
expect(description).toBeInTheDocument(); expect(description).toBeInTheDocument();
@@ -77,17 +77,17 @@ describe("Create flow right-rail page", () => {
expect( expect(
screen.getByRole("button", { screen.getByRole("button", {
name: /Mediation: Collaborative work to reach a resolution/, name: /Lazy Consensus: A decision is assumed approved/,
}), }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.getByRole("button", { screen.getByRole("button", {
name: /Facilitated dialogue: Structured sessions/, name: /Do-ocracy: Decisions are made by those who take initiative/,
}), }),
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.getByRole("button", { screen.getByRole("button", {
name: /Invite-only: Private discussions with selected participants/, name: /Consensus Decision-Making: All members must agree/,
}), }),
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@@ -123,10 +123,10 @@ describe("Create flow right-rail page", () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<RightRailScreen />); render(<RightRailScreen />);
const mediationCard = screen.getByRole("button", { const card = screen.getByRole("button", {
name: /Mediation: Collaborative work to reach a resolution/, name: /Lazy Consensus: A decision is assumed approved/,
}); });
await user.click(mediationCard); await user.click(card);
expect(screen.getByText("SELECTED")).toBeInTheDocument(); expect(screen.getByText("SELECTED")).toBeInTheDocument();
}); });
@@ -26,4 +26,28 @@ describe("getProportionBarProgressForCreateFlowStep", () => {
"2-0", "2-0",
); );
}); });
it("uses 2-1 on communication-methods", () => {
expect(
getProportionBarProgressForCreateFlowStep("communication-methods"),
).toBe("2-1");
});
it("uses 2-2 on membership-methods", () => {
expect(
getProportionBarProgressForCreateFlowStep("membership-methods"),
).toBe("2-2");
});
it("uses 2-3 on decision-approaches (Figma Flow — Right Rail)", () => {
expect(
getProportionBarProgressForCreateFlowStep("decision-approaches"),
).toBe("2-3");
});
it("uses 3-0 on conflict-management (start of Review segment)", () => {
expect(
getProportionBarProgressForCreateFlowStep("conflict-management"),
).toBe("3-0");
});
}); });
+1 -1
View File
@@ -56,7 +56,7 @@ describe("createFlowStateSchema", () => {
it("accepts known fields and passthrough keys", () => { it("accepts known fields and passthrough keys", () => {
const r = createFlowStateSchema.safeParse({ const r = createFlowStateSchema.safeParse({
title: "My rule", title: "My rule",
currentStep: "cards", currentStep: "communication-methods",
customField: { nested: [1, 2] }, customField: { nested: [1, 2] },
}); });
expect(r.success).toBe(true); expect(r.success).toBe(true);
+5 -2
View File
@@ -16,7 +16,10 @@ describe("flowSteps", () => {
}); });
it("getNextStep returns next step in order", () => { it("getNextStep returns next step in order", () => {
expect(getNextStep("right-rail")).toBe("confirm-stakeholders"); expect(getNextStep("communication-methods")).toBe("membership-methods");
expect(getNextStep("membership-methods")).toBe("decision-approaches");
expect(getNextStep("decision-approaches")).toBe("conflict-management");
expect(getNextStep("conflict-management")).toBe("confirm-stakeholders");
expect(getNextStep("confirm-stakeholders")).toBe("final-review"); expect(getNextStep("confirm-stakeholders")).toBe("final-review");
}); });
@@ -67,6 +70,6 @@ describe("flowSteps", () => {
const opts = { skipCommunitySave: true } as const; const opts = { skipCommunitySave: true } as const;
expect(getNextStep("community-size", opts)).toBe("community-upload"); expect(getNextStep("community-size", opts)).toBe("community-upload");
expect(getNextStep("review", opts)).toBe("core-values"); expect(getNextStep("review", opts)).toBe("core-values");
expect(getPreviousStep("cards", opts)).toBe("core-values"); expect(getPreviousStep("communication-methods", opts)).toBe("core-values");
}); });
}); });
+12 -18
View File
@@ -2,27 +2,12 @@ import { describe, it, expect } from "vitest";
import { migrateLegacyCreateFlowState } from "../../lib/create/migrateLegacyCreateFlowState"; import { migrateLegacyCreateFlowState } from "../../lib/create/migrateLegacyCreateFlowState";
describe("migrateLegacyCreateFlowState", () => { describe("migrateLegacyCreateFlowState", () => {
it("maps communityReflection to communitySaveEmail when save email empty", () => { it("passes through object payloads", () => {
const out = migrateLegacyCreateFlowState({ const out = migrateLegacyCreateFlowState({
title: "T", title: "T",
communityReflection: "old@example.com", currentStep: "community-save",
});
expect(out.communitySaveEmail).toBe("old@example.com");
expect("communityReflection" in out).toBe(false);
});
it("does not overwrite existing communitySaveEmail", () => {
const out = migrateLegacyCreateFlowState({
communityReflection: "old@example.com",
communitySaveEmail: "kept@example.com",
});
expect(out.communitySaveEmail).toBe("kept@example.com");
});
it("rewrites currentStep slug", () => {
const out = migrateLegacyCreateFlowState({
currentStep: "community-reflection",
}); });
expect(out.title).toBe("T");
expect(out.currentStep).toBe("community-save"); expect(out.currentStep).toBe("community-save");
}); });
@@ -30,4 +15,13 @@ describe("migrateLegacyCreateFlowState", () => {
expect(migrateLegacyCreateFlowState(null)).toEqual({}); expect(migrateLegacyCreateFlowState(null)).toEqual({});
expect(migrateLegacyCreateFlowState(undefined)).toEqual({}); expect(migrateLegacyCreateFlowState(undefined)).toEqual({});
}); });
it("renames legacy right-rail step to decision-approaches", () => {
const out = migrateLegacyCreateFlowState({
currentStep: "right-rail",
title: "T",
});
expect(out.currentStep).toBe("decision-approaches");
expect(out.title).toBe("T");
});
}); });