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)
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
@@ -10,6 +10,7 @@ export type ProportionBarState =
| "2-0"
| "2-1"
| "2-2"
| "2-3"
| "3-0"
| "3-1"
| "3-2";
@@ -20,7 +21,9 @@ export interface ProportionBarProps {
progress?: ProportionBarState;
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;
}
@@ -1,24 +1,41 @@
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({
progress,
className,
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) {
// Proportion bar type
const [fullSegments, partialSegment] = progress.split("-").map(Number);
const segmented = variant === "segmented";
// Calculate total progress:
// - 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
// Max is 3 full segments = 9 units
let totalProgress = 0;
if (fullSegments === 1) {
totalProgress = (partialSegment + 1) / 6; // 1/6 to 6/6 of first section
} 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) {
totalProgress = 2 + partialSegment / 3; // 2 full + 0/3 to 2/3 of third
}
@@ -55,33 +72,32 @@ export function ProportionBarView({
</div>
{/* 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">
{/* First section - for 1-X: (X+1)/6 filled, for 2-X and 3-X: fully filled */}
<div className="flex-1 h-full relative">
{fullSegments === 1 ? (
<div
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()}
className="absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)] rounded-l-[var(--radius-full)]"
style={{ width: `${((partialSegment + 1) / 6) * 100}%` }}
/>
) : fullSegments >= 2 ? (
<div className="absolute inset-0 bg-[var(--color-content-default-brand-primary)] rounded-l-[var(--radius-full)]" />
) : null}
</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">
{fullSegments === 2 ? (
partialSegment > 0 ? (
<div
className={`absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)] ${
segmented
? "rounded-l-[var(--radius-full)] rounded-r-[var(--radius-full)]"
: ""
}`.trim()}
style={{ width: `${(partialSegment / 3) * 100}%` }}
className="absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)]"
style={{
width: `${getSecondSegmentFillRatio(partialSegment) * 100}%`,
}}
/>
) : null
) : fullSegments >= 3 ? (
@@ -89,16 +105,12 @@ export function ProportionBarView({
) : null}
</div>
{/* 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">
{fullSegments === 3 && partialSegment > 0 ? (
<div
className={`absolute inset-y-0 left-0 bg-[var(--color-content-default-brand-primary)] ${
segmented
? "rounded-l-[var(--radius-full)] rounded-r-[var(--radius-full)]"
: partialSegment >= 3
? "rounded-r-[var(--radius-full)]"
: ""
partialSegment >= 3 ? "rounded-r-[var(--radius-full)]" : ""
}`.trim()}
style={{ width: `${Math.min((partialSegment / 3) * 100, 100)}%` }}
/>
@@ -21,7 +21,10 @@ const CardStackContainer = memo<CardStackProps>(
title = "",
description = "",
layout = "default",
compactRecommendedLimit = 5,
compactDesktopLayout: compactDesktopLayoutProp = "grid",
headerLockupSize,
toggleAlignment = "center",
className = "",
}) => {
const [internalExpanded, setInternalExpanded] = useState(false);
@@ -75,7 +78,10 @@ const CardStackContainer = memo<CardStackProps>(
title={title}
description={description}
layout={layout}
compactRecommendedLimit={compactRecommendedLimit}
compactDesktopLayout={compactDesktopLayoutProp}
headerLockupSize={headerLockupSize}
toggleAlignment={toggleAlignment}
className={className}
/>
);
@@ -21,8 +21,19 @@ export interface CardStackProps {
description?: string;
/** "default" = compact grid/column + expanded grid; "singleStack" = always one column, expand shows more in same stack */
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`. */
headerLockupSize?: HeaderLockupSizeValue;
/** Alignment of the expand/collapse control in `singleStack` layout (Figma right-rail: end). */
toggleAlignment?: "center" | "end";
className?: string;
}
@@ -38,6 +49,9 @@ export interface CardStackViewProps {
title: string;
description: string;
layout: "default" | "singleStack";
compactRecommendedLimit: number;
compactDesktopLayout: "grid" | "flexWrap" | "pyramidFive";
headerLockupSize: HeaderLockupSizeValue | undefined;
toggleAlignment: "center" | "end";
className: string;
}
@@ -16,13 +16,18 @@ export function CardStackView({
title,
description,
layout,
compactRecommendedLimit,
compactDesktopLayout,
headerLockupSize,
toggleAlignment,
className,
}: CardStackViewProps) {
const lockupSize = headerLockupSize ?? "L";
const isSelected = (id: string) => selectedIds.includes(id);
// Compact: recommended only (up to 5). Expanded: all cards.
const compactCards = cards.filter((c) => c.recommended ?? false).slice(0, 5);
// Compact: recommended only (default up to 5). Expanded: all cards.
const compactCards = cards
.filter((c) => c.recommended ?? false)
.slice(0, compactRecommendedLimit);
// Single stack: always one column; expand reveals more in same stack (scrollable)
if (layout === "singleStack") {
@@ -39,7 +44,7 @@ export function CardStackView({
/>
</div>
) : 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) => (
<Card
key={item.id}
@@ -58,7 +63,9 @@ export function CardStackView({
<button
type="button"
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}
</button>
@@ -81,7 +88,7 @@ export function CardStackView({
) : null}
{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) => (
<Card
key={item.id}
@@ -96,10 +103,215 @@ export function CardStackView({
/>
))}
</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 */}
<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) => (
<Card
key={item.id}
+81 -16
View File
@@ -14,7 +14,10 @@ import { useCreateFlowExit } from "./hooks/useCreateFlowExit";
import CreateFlowTopNav from "../components/utility/CreateFlowTopNav";
import { getNextStep, getStepIndex } from "./utils/flowSteps";
import { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress";
import { createFlowStepUsesCenteredTextLayout } from "./utils/createFlowScreenRegistry";
import {
createFlowStepUsesCenteredTextLayout,
createFlowStepUsesCardLayout,
} from "./utils/createFlowScreenRegistry";
import CreateFlowFooter from "../components/utility/CreateFlowFooter";
import Button from "../components/buttons/Button";
import { buildPublishPayload } from "../../lib/create/buildPublishPayload";
@@ -299,36 +302,34 @@ function CreateFlowLayoutContent({
}, [state.communitySaveEmail, tLogin, updateState]);
const isCompletedStep = currentStep === "completed";
const isRightRailStep = currentStep === "right-rail";
const isRightRailStep = currentStep === "decision-approaches";
const isFinalReviewStep = currentStep === "final-review";
const isCardsStep = currentStep === "cards";
/** Two-column select steps: at `lg+` only the right column scrolls; main must not scroll the full page. */
const isCardLayoutStep = createFlowStepUsesCardLayout(currentStep);
/** Two-column select / right-rail: below `lg` main scrolls; at `lg+` only the right column scrolls. */
const isSelectSplitScrollStep =
currentStep === "community-size" ||
currentStep === "community-structure" ||
currentStep === "core-values";
currentStep === "core-values" ||
currentStep === "decision-approaches";
const stepIdx = currentStep != null ? getStepIndex(currentStep) : -1;
/** At `md+`, main cross-axis: center by default; exceptions stay top-aligned (see product spec). */
const mainContentClass = isCompletedStep
? "items-stretch overflow-y-auto md:overflow-hidden"
: isRightRailStep
? "items-stretch overflow-hidden"
: isSelectSplitScrollStep
? "items-start justify-start overflow-y-auto max-lg:overflow-y-auto lg:min-h-0 lg:items-stretch lg:overflow-hidden"
: isFinalReviewStep || isCardsStep || isTemplateReviewRoute
? "items-start justify-center overflow-y-auto"
: "items-start justify-center overflow-y-auto md:items-center";
: isSelectSplitScrollStep
? "items-start justify-start overflow-y-auto max-lg:overflow-y-auto lg:min-h-0 lg:items-stretch lg:overflow-hidden"
: isFinalReviewStep || isCardLayoutStep || isTemplateReviewRoute
? "items-start justify-center overflow-y-auto"
: "items-start justify-center overflow-y-auto md:items-center";
const isTextStep = createFlowStepUsesCenteredTextLayout(currentStep);
const mainMaxMdJustify =
isTextStep && !isCompletedStep && !isRightRailStep
? "max-md:justify-center"
: "max-md:justify-start";
const mainMaxMdCross =
isCompletedStep || isRightRailStep
? "max-md:flex-col max-md:items-stretch"
: "max-md:flex-col max-md:items-center";
const mainMaxMdCross = isCompletedStep
? "max-md:flex-col max-md:items-stretch"
: "max-md:flex-col max-md:items-center";
const mainResponsiveLayout = `${mainMaxMdCross} ${mainMaxMdJustify} md:flex-row md:justify-center`;
const saveDraftOnExit =
Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX;
@@ -575,6 +576,70 @@ function CreateFlowLayoutContent({
>
{footer.confirmCoreValues}
</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 ? (
<Button
buttonType="filled"
+11 -19
View File
@@ -1,33 +1,25 @@
"use client";
import { notFound, useRouter } from "next/navigation";
import { use, useEffect } from "react";
import { notFound } from "next/navigation";
import { CreateFlowScreenView } from "../screens/CreateFlowScreenView";
import { isValidStep } from "../utils/flowSteps";
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 {
params: Promise<{ screenId: string }>;
}
export default function CreateFlowScreenPage({ params }: PageProps) {
const { screenId: raw } = use(params);
const router = useRouter();
useEffect(() => {
if (raw === "community-reflection") {
router.replace("/create/community-save");
}
}, [raw, router]);
if (raw === "community-reflection") {
return null;
}
export default async function CreateFlowScreenPage({ params }: PageProps) {
const { screenId: raw } = await params;
if (!isValidStep(raw)) {
notFound();
}
const screenId = raw as CreateFlowStep;
return <CreateFlowScreenView screenId={screenId} />;
return <CreateFlowScreenView screenId={raw as CreateFlowStep} />;
}
@@ -1,7 +1,10 @@
"use client";
import type { ReactNode } from "react";
import { CreateFlowStepShell } from "./CreateFlowStepShell";
import {
CreateFlowStepShell,
type CreateFlowContentTopBelowMd,
} from "./CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "./createFlowLayoutTokens";
export type CreateFlowSelectShellLgVerticalAlign = "center" | "start";
@@ -9,23 +12,29 @@ export type CreateFlowSelectShellLgVerticalAlign = "center" | "start";
interface CreateFlowTwoColumnSelectShellProps {
header: ReactNode;
children: ReactNode;
/**
* Top padding below create-flow chrome. Select steps use `space-1400`; right-rail uses `space-800`
* (Figma Flow — Right Rail).
*/
contentTopBelowMd?: CreateFlowContentTopBelowMd;
/**
* At `lg+`, layout variant: `"center"` = vertically centered pair (community size/structure).
* `"start"` = top-weighted layout with a scrollable right column (core values): uses `items-stretch`
* `"start"` = top-weighted layout with a scrollable right column (core values, right-rail): uses `items-stretch`
* so the right column gets a bounded height; `items-start` would grow with content and break scroll.
*/
lgVerticalAlign?: CreateFlowSelectShellLgVerticalAlign;
}
/**
* Two-column layout for create-flow select steps (community size/structure, core values).
* Below `lg`, layout and scrolling match the previous single-column behavior (full page scroll).
* Two-column layout for create-flow select steps (community size/structure, core values) and
* {@link RightRailScreen} (decision approaches). Below `lg` (1024px), one column + main scrolls.
* At `lg+`, mirrors {@link CompletedScreen}: static header column + scrollable controls column
* (`min-h-0` + `overflow-y-auto` height chain; see completed page right rail).
*/
export function CreateFlowTwoColumnSelectShell({
header,
children,
contentTopBelowMd = "space-1400",
lgVerticalAlign = "center",
}: CreateFlowTwoColumnSelectShellProps) {
/** `stretch` is required for `min-h-0` + `overflow-y-auto` on the right column. */
@@ -38,7 +47,7 @@ export function CreateFlowTwoColumnSelectShell({
return (
<CreateFlowStepShell
variant="centeredNarrow"
contentTopBelowMd="space-1400"
contentTopBelowMd={contentTopBelowMd}
className={
/* Below `lg`: natural height — same as legacy select screens (main scrolls). */
/* 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. */
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 { CommunityReviewScreen } from "./review/CommunityReviewScreen";
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 { 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({
screenId,
@@ -65,10 +71,14 @@ export function CreateFlowScreenView({
return <CommunityReviewScreen />;
case "core-values":
return <CoreValuesSelectScreen />;
case "cards":
return <CardsScreen />;
case "right-rail":
case "communication-methods":
return <CommunicationMethodsScreen />;
case "membership-methods":
return <MembershipMethodsScreen />;
case "decision-approaches":
return <RightRailScreen />;
case "conflict-management":
return <ConflictManagementScreen />;
case "confirm-stakeholders":
return <ConfirmStakeholdersScreen />;
case "final-review":
@@ -1,5 +1,14 @@
"use client";
/**
* `communication-methods` step Figma Flow Compact Card Stack (node `20246-15828`).
* Registry: `layoutKind: "card"` (`CREATE_FLOW_SCREEN_REGISTRY["communication-methods"]`).
*
* Lives under `screens/card/` (not `select/`): Figma **card stack** layout is a distinct shell from
* two-column chip **select** frames. Future card-stack steps get their own `*Screen.tsx` here and
* reuse `CardStack` / `CreateFlowStepShell` as needed.
*/
import { useState, useCallback, useMemo } from "react";
import { useMessages } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
@@ -9,7 +18,10 @@ import CardStack from "../../../components/utility/CardStack";
import Create from "../../../components/modals/Create";
import TextArea from "../../../components/controls/TextArea";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import { CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS } from "../../components/createFlowLayoutTokens";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS,
} from "../../components/createFlowLayoutTokens";
const IN_PERSON_CARD_ID = "in-person-meetings";
const SIGNAL_CARD_ID = "signal";
@@ -129,16 +141,24 @@ function isAddPlatformCard(cardId: string | null): boolean {
);
}
export function CardsScreen() {
export function CommunicationMethodsScreen() {
const m = useMessages();
const comm = m.create.communication;
const mdUp = useCreateFlowMdUp();
const { markCreateFlowInteraction } = useCreateFlow();
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const [expanded, setExpanded] = useState(false);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [pendingCardId, setPendingCardId] = useState<string | null>(null);
const selectedIds = state.selectedCommunicationMethodIds ?? [];
const setSelectedIds = useCallback(
(next: string[]) => {
updateState({ selectedCommunicationMethodIds: next });
},
[updateState],
);
const sampleCards = useMemo(
() =>
COMMUNICATION_CARD_ORDER.map((id) => {
@@ -154,9 +174,25 @@ export function CardsScreen() {
);
const title = expanded ? comm.page.expandedTitle : comm.page.compactTitle;
const description = expanded
? comm.page.expandedDescription
: comm.page.compactDescription;
const description = expanded ? (
comm.page.expandedDescription
) : (
<>
{comm.page.compactDescriptionBefore}
<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 =
pendingCardId && pendingCardId in comm.modals
@@ -198,13 +234,15 @@ export function CardsScreen() {
const handleCreateModalConfirm = useCallback(() => {
markCreateFlowInteraction();
if (pendingCardId) {
setSelectedIds((prev) =>
prev.includes(pendingCardId) ? prev : [...prev, pendingCardId],
setSelectedIds(
selectedIds.includes(pendingCardId)
? selectedIds
: [...selectedIds, pendingCardId],
);
}
setCreateModalOpen(false);
setPendingCardId(null);
}, [markCreateFlowInteraction, pendingCardId]);
}, [markCreateFlowInteraction, pendingCardId, selectedIds, setSelectedIds]);
return (
<CreateFlowStepShell
@@ -219,7 +257,7 @@ export function CardsScreen() {
justification="center"
/>
</div>
<div className={CREATE_FLOW_MD_UP_COLUMN_MAX_CLASS}>
<div className={CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS}>
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
@@ -231,6 +269,8 @@ export function CardsScreen() {
}}
hasMore={true}
toggleLabel={comm.page.seeAllLink}
compactRecommendedLimit={3}
compactDesktopLayout="flexWrap"
headerLockupSize={mdUp ? "L" : "M"}
/>
</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";
/**
* `decision-approaches` step — Figma “Flow — Right Rail” (node `20523-23509`).
* Registry: `CREATE_FLOW_SCREEN_REGISTRY["decision-approaches"]` (`layoutKind: "right-rail"`).
*
* Layout matches {@link CreateFlowTwoColumnSelectShell}: one column below `lg` (1024px), two columns
* at `lg+` with a scrollable rail — same breakpoint and height chain as select steps, distinct content.
*/
import { useState, useCallback, useMemo } from "react";
import DecisionMakingSidebar from "../../../components/utility/DecisionMakingSidebar";
import CardStack from "../../../components/utility/CardStack";
@@ -8,22 +16,27 @@ import type { CardStackItem } from "../../../components/utility/CardStack/CardSt
import { useMessages } from "../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import {
CREATE_FLOW_MD_UP_GRID_CELL_CLASS,
CREATE_FLOW_TWO_COLUMN_MAX_WIDTH_CLASS,
} from "../../components/createFlowLayoutTokens";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
export function RightRailScreen() {
const m = useMessages();
const rr = m.create.rightRail;
const mdUp = useCreateFlowMdUp();
const { markCreateFlowInteraction } = useCreateFlow();
const { state, updateState, markCreateFlowInteraction } = useCreateFlow();
const [messageBoxCheckedIds, setMessageBoxCheckedIds] = useState<string[]>(
[],
);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [expanded, setExpanded] = useState(false);
const selectedIds = state.selectedDecisionApproachIds ?? [];
const setSelectedIds = useCallback(
(next: string[]) => {
updateState({ selectedDecisionApproachIds: next });
},
[updateState],
);
const messageBoxItems: InfoMessageBoxItem[] = useMemo(
() =>
rr.messageBox.items.map((item) => ({
@@ -47,7 +60,16 @@ export function RightRailScreen() {
const sidebarDescription = (
<>
{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}
</>
);
@@ -65,11 +87,13 @@ export function RightRailScreen() {
const handleCardSelect = useCallback(
(id: string) => {
markCreateFlowInteraction();
setSelectedIds((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
setSelectedIds(
selectedIds.includes(id)
? selectedIds.filter((x) => x !== id)
: [...selectedIds, id],
);
},
[markCreateFlowInteraction],
[markCreateFlowInteraction, selectedIds, setSelectedIds],
);
const handleToggleExpand = useCallback(() => {
@@ -78,48 +102,40 @@ export function RightRailScreen() {
}, [markCreateFlowInteraction]);
return (
<div className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden md:h-full">
<div className="flex min-h-0 flex-1 overflow-hidden px-5 max-md:overflow-y-auto md:px-12">
<div
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}`}
>
<div
className={`flex flex-col items-stretch justify-start overflow-hidden md:justify-center ${CREATE_FLOW_MD_UP_GRID_CELL_CLASS}`}
>
<DecisionMakingSidebar
title={rr.sidebar.title}
description={sidebarDescription}
messageBoxTitle={rr.messageBox.title}
messageBoxItems={messageBoxItems}
messageBoxCheckedIds={messageBoxCheckedIds}
onMessageBoxCheckboxChange={handleMessageBoxCheckboxChange}
size={mdUp ? "L" : "M"}
justification={mdUp ? "left" : "center"}
/>
</div>
<div
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}`}
>
<div className="flex w-full min-w-0 flex-col items-center gap-6 py-0 md:pb-8">
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardSelect}
expanded={expanded}
onToggleExpand={handleToggleExpand}
hasMore={true}
toggleLabel={rr.cardStack.toggleSeeAll}
showLessLabel={rr.cardStack.toggleShowLess}
title={rr.cardStack.emptyTitle}
description={rr.cardStack.emptyDescription}
layout="singleStack"
className="w-full"
headerLockupSize={mdUp ? "L" : "M"}
/>
</div>
</div>
</div>
<CreateFlowTwoColumnSelectShell
contentTopBelowMd="space-800"
lgVerticalAlign="start"
header={
<DecisionMakingSidebar
title={rr.sidebar.title}
description={sidebarDescription}
messageBoxTitle={rr.messageBox.title}
messageBoxItems={messageBoxItems}
messageBoxCheckedIds={messageBoxCheckedIds}
onMessageBoxCheckboxChange={handleMessageBoxCheckboxChange}
size={mdUp ? "L" : "M"}
justification={mdUp ? "left" : "center"}
/>
}
>
<div className="flex w-full min-w-0 flex-col items-stretch gap-6 py-0">
<CardStack
cards={sampleCards}
selectedIds={selectedIds}
onCardSelect={handleCardSelect}
expanded={expanded}
onToggleExpand={handleToggleExpand}
hasMore={true}
toggleLabel={rr.cardStack.toggleSeeAll}
showLessLabel={rr.cardStack.toggleShowLess}
title=""
description=""
layout="singleStack"
compactRecommendedLimit={5}
className="w-full"
headerLockupSize={mdUp ? "L" : "M"}
/>
</div>
</div>
</CreateFlowTwoColumnSelectShell>
);
}
+12 -2
View File
@@ -19,8 +19,10 @@ export type CreateFlowStep =
| "community-save"
| "review"
| "core-values"
| "cards"
| "right-rail"
| "communication-methods"
| "membership-methods"
| "decision-approaches"
| "conflict-management"
| "confirm-stakeholders"
| "final-review"
| "completed";
@@ -83,6 +85,14 @@ export interface CreateFlowState {
coreValuesChipsSnapshot?: CommunityStructureChipSnapshotRow[];
/** User-authored detail text keyed by chip id (preset ids or custom UUIDs). */
coreValueDetailsByChipId?: Record<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;
/** Section drafts; structure will tighten as steps persist real shapes. */
sections?: Record<string, unknown>[];
@@ -16,8 +16,10 @@ const PROPORTION_BY_STEP_INDEX: readonly ProportionBarState[] = [
"2-0", // community-save
"2-0", // review (Figma Flow — Review `19706:12135`: same segment fill as end of Create Community)
"2-0", // core-values (same segment as review / end of Create Community)
"2-2", // cards
"3-0", // right-rail
"2-1", // communication-methods (Figma — Compact Card Stack)
"2-2", // membership-methods (Figma — Compact Card Stack `20858:13947`)
"2-3", // decision-approaches (Figma Flow — Right Rail `20523:23509`)
"3-0", // conflict-management (Figma Flow — Compact Card Stack `20879:15979`; start of Review segment)
"3-1", // confirm-stakeholders
"3-2", // final-review
"3-2", // completed
+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).
* 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 =
| "informational"
@@ -90,18 +91,30 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record<
messageNamespace: "create.coreValues",
centeredBodyBelowMd: false,
},
cards: {
"communication-methods": {
layoutKind: "card",
figmaNodeId: "TBD-cards",
figmaNodeId: "20246-15828",
messageNamespace: "create.communication",
centeredBodyBelowMd: false,
},
"right-rail": {
"membership-methods": {
layoutKind: "card",
figmaNodeId: "20858-13947",
messageNamespace: "create.membership",
centeredBodyBelowMd: false,
},
"decision-approaches": {
layoutKind: "right-rail",
figmaNodeId: "TBD-right-rail",
figmaNodeId: "20523-23509",
messageNamespace: "create.rightRail",
centeredBodyBelowMd: false,
},
"conflict-management": {
layoutKind: "card",
figmaNodeId: "20879-15979",
messageNamespace: "create.conflictManagement",
centeredBodyBelowMd: false,
},
"confirm-stakeholders": {
layoutKind: "select",
figmaNodeId: "21104-46594",
@@ -128,3 +141,11 @@ export function createFlowStepUsesCenteredTextLayout(
if (!step) return false;
return CREATE_FLOW_SCREEN_REGISTRY[step].centeredBodyBelowMd;
}
/** Steps whose main area uses the CardStack-style layout (`layoutKind: "card"`). */
export function createFlowStepUsesCardLayout(
step: CreateFlowStep | null,
): boolean {
if (!step) return false;
return CREATE_FLOW_SCREEN_REGISTRY[step].layoutKind === "card";
}
+4 -2
View File
@@ -21,8 +21,10 @@ export const FLOW_STEP_ORDER: readonly CreateFlowStep[] = [
"community-save",
"review",
"core-values",
"cards",
"right-rail",
"communication-methods",
"membership-methods",
"decision-approaches",
"conflict-management",
"confirm-stakeholders",
"final-review",
"completed",
+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) |
| --- | --- | --- |
| **Create Community** | Intro, naming, structure, context, size, upload, save progress (email), then community review. | `informational``community-name``community-structure``community-context``community-size``community-upload``community-save``review` |
| **Create Custom CommunityRule** | Author the CommunityRule content and structure. | `cards``right-rail` |
| **Create Custom CommunityRule** | Author the CommunityRule content and structure. | `core-values``communication-methods``right-rail` (further card-stack steps get their own `screenId` and `screens/card/*Screen.tsx`; `right-rail` uses `layoutKind: "right-rail"`) |
| **Review and complete** | Stakeholders, final card, publish, success. | `confirm-stakeholders``final-review``completed` |
Treat these stages as the **canonical product sections** when adding chrome (e.g. stage headers, progress copy), breaking work across teams, or reusing flows in other surfaces. **Layout kind** is **not** encoded in the URL; it lives in [`CREATE_FLOW_SCREEN_REGISTRY`](../app/create/utils/createFlowScreenRegistry.ts) (Figma node id + `layoutKind` per step). Figma defines eight layout kinds: **informational**, **text**, **select**, **upload**, **review**, **card**, **right-rail**, **completed**`CreateFlowLayoutKind` and [`app/create/screens/`](../app/create/screens/) mirror that list (one folder per kind; multiple steps may share a kind, e.g. several **select** screens).
@@ -34,11 +34,12 @@ Order is defined in code by [`FLOW_STEP_ORDER`](../app/create/utils/flowSteps.ts
| 6 | Create Community | `community-upload` | `/create/community-upload` |
| 7 | Create Community | `community-save` | `/create/community-save` |
| 8 | Create Community (review frame) | `review` | `/create/review` |
| 9 | Create Custom CommunityRule | `cards` | `/create/cards` |
| 10 | Create Custom CommunityRule | `right-rail` | `/create/right-rail` |
| 11 | Review and complete | `confirm-stakeholders` | `/create/confirm-stakeholders` |
| 12 | Review and complete | `final-review` | `/create/final-review` |
| 13 | Review and complete | `completed` | `/create/completed` |
| 9 | Create Custom CommunityRule | `core-values` | `/create/core-values` |
| 10 | Create Custom CommunityRule | `communication-methods` | `/create/communication-methods` |
| 11 | Create Custom CommunityRule | `right-rail` | `/create/right-rail` |
| 12 | Review and complete | `confirm-stakeholders` | `/create/confirm-stakeholders` |
| 13 | Review and complete | `final-review` | `/create/final-review` |
| 14 | Review and complete | `completed` | `/create/completed` |
**Primary entry:** marketing header “Create rule” navigates to **`/create`**, which redirects to **`/create/informational`** (see [`TopNav.container.tsx`](../app/components/navigation/TopNav/TopNav.container.tsx)).
+13 -14
View File
@@ -1,25 +1,24 @@
import type { CreateFlowState } from "../../app/create/types";
/** Legacy `currentStep` values mapped to the current `CreateFlowStep` id. */
const LEGACY_CREATE_FLOW_STEP_RENAMES: Readonly<Record<string, string>> = {
"right-rail": "decision-approaches",
};
/**
* Maps pre-rename draft keys and step ids (`community-reflection` → `community-save`).
* Safe to run on any parsed draft payload before merging into context.
* Normalizes parsed draft JSON before merging into create-flow context.
* Renames deprecated step ids so old drafts and bookmarks stay valid.
*/
export function migrateLegacyCreateFlowState(
raw: Record<string, unknown> | null | undefined,
): CreateFlowState {
if (!raw || typeof raw !== "object") return {};
const next: Record<string, unknown> = { ...raw };
if (typeof next.communityReflection === "string") {
if (
next.communitySaveEmail === undefined ||
next.communitySaveEmail === ""
) {
next.communitySaveEmail = next.communityReflection;
const step = raw.currentStep;
if (typeof step === "string") {
const next = LEGACY_CREATE_FLOW_STEP_RENAMES[step];
if (next !== undefined) {
return { ...raw, currentStep: next } as CreateFlowState;
}
}
delete next.communityReflection;
if (next.currentStep === "community-reflection") {
next.currentStep = "community-save";
}
return next as CreateFlowState;
return raw as CreateFlowState;
}
@@ -63,6 +63,10 @@ export const createFlowStateSchema = z
coreValueDetailsByChipId: z
.record(coreValueDetailEntrySchema)
.optional(),
selectedCommunicationMethodIds: z.array(z.string()).max(200).optional(),
selectedMembershipMethodIds: z.array(z.string()).max(200).optional(),
selectedDecisionApproachIds: z.array(z.string()).max(200).optional(),
selectedConflictManagementIds: z.array(z.string()).max(200).optional(),
currentStep: createFlowStepSchema.optional(),
sections: z.array(z.unknown()).optional(),
stakeholders: z.array(z.unknown()).optional(),
+3 -1
View File
@@ -2,7 +2,9 @@
"_comment": "Create flow communication step: page, cards, and add-platform modals",
"page": {
"compactTitle": "How should this community communicate with each-other?",
"compactDescription": "You can select multiple methods for different needs or add your own",
"compactDescriptionBefore": "You can select multiple methods for different needs or ",
"compactDescriptionLinkLabel": "add",
"compactDescriptionAfter": " your own",
"expandedTitle": "What method should this community use to communicate with eachother?",
"expandedDescription": "You can select multiple methods for different needs or add your own",
"seeAllLink": "See all communication approaches"
@@ -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",
"finalizeCommunityRule": "Finalize CommunityRule",
"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": {
"title": "How should conflicts be resolved?",
"descriptionBefore": "You can also combine or ",
"descriptionLink": "add",
"descriptionAfter": " new approaches to the list"
"title": "How should this community make difficult decisions?",
"descriptionBefore": "Select as many as you need to describe how your group makes decisions. You can also ",
"descriptionLinkLabel": "add",
"descriptionAfter": " new decision making approaches or interact with the categories below to filter."
},
"messageBox": {
"title": "Consider defining approaches to steward key resources:",
@@ -16,99 +17,97 @@
},
"cardStack": {
"toggleSeeAll": "See all decision approaches",
"toggleShowLess": "Show less",
"emptyTitle": "",
"emptyDescription": ""
"toggleShowLess": "Show less"
},
"cards": [
{
"id": "mediation",
"label": "Mediation",
"supportText": "Collaborative work to reach a resolution that all parties can agree upon.",
"id": "lazy-consensus",
"label": "Lazy Consensus",
"supportText": "A decision is assumed approved unless objections are raised within a specified timeframe.",
"recommended": true
},
{
"id": "facilitation",
"label": "Facilitated dialogue",
"supportText": "Structured sessions where parties collaboratively resolve disputes.",
"id": "do-ocracy",
"label": "Do-ocracy",
"supportText": "Decisions are made by those who take initiative and carry out the work.",
"recommended": true
},
{
"id": "invite-only",
"label": "Invite-only",
"supportText": "Private discussions with selected participants.",
"id": "consensus-decision-making",
"label": "Consensus Decision-Making",
"supportText": "All members must agree. Best for important decisions in small groups. Does not work well for low stakes decisions.",
"recommended": true
},
{
"id": "arbitration",
"label": "Arbitration",
"supportText": "Arbitrators are chosen specifically for a particular case.",
"id": "rotational-leadership",
"label": "Rotational Leadership",
"supportText": "Decision-making responsibilities rotate among members.",
"recommended": true
},
{
"id": "direct-dialogue",
"label": "Direct dialogue",
"supportText": "Encouraging direct, respectful dialogue between those involved.",
"id": "modified-consensus",
"label": "Modified Consensus",
"supportText": "Attempts to reach full agreement first, but falls back to voting if consensus isnt possible.",
"recommended": true
},
{
"id": "label-1",
"label": "Label",
"supportText": "",
"supportText": "Additional decision approach.",
"recommended": false
},
{
"id": "label-2",
"label": "Label",
"supportText": "",
"supportText": "Additional decision approach.",
"recommended": false
},
{
"id": "label-3",
"label": "Label",
"supportText": "",
"supportText": "Additional decision approach.",
"recommended": false
},
{
"id": "label-4",
"label": "Label",
"supportText": "",
"supportText": "Additional decision approach.",
"recommended": false
},
{
"id": "label-5",
"label": "Label",
"supportText": "",
"supportText": "Additional decision approach.",
"recommended": false
},
{
"id": "label-6",
"label": "Label",
"supportText": "",
"supportText": "Additional decision approach.",
"recommended": false
},
{
"id": "label-7",
"label": "Label",
"supportText": "",
"supportText": "Additional decision approach.",
"recommended": false
},
{
"id": "label-8",
"label": "Label",
"supportText": "",
"supportText": "Additional decision approach.",
"recommended": false
},
{
"id": "label-9",
"label": "Label",
"supportText": "",
"supportText": "Additional decision approach.",
"recommended": false
},
{
"id": "label-10",
"label": "Label",
"supportText": "",
"supportText": "Additional decision approach.",
"recommended": false
}
]
+4
View File
@@ -19,6 +19,8 @@ import profile from "./pages/profile.json";
import navigation from "./navigation.json";
import metadata from "./metadata.json";
import communication from "./create/communication.json";
import createMembership from "./create/membership.json";
import createConflictManagement from "./create/conflictManagement.json";
import createInformational from "./create/informational.json";
import createCommunityName from "./create/communityName.json";
import createCommunitySize from "./create/communitySize.json";
@@ -61,6 +63,8 @@ export default {
},
create: {
communication,
membership: createMembership,
conflictManagement: createConflictManagement,
informational: createInformational,
communityName: createCommunityName,
communitySize: createCommunitySize,
+9
View File
@@ -97,6 +97,15 @@ const nextConfig = {
return config;
},
async redirects() {
return [
{
source: "/create/right-rail",
destination: "/create/decision-approaches",
permanent: true,
},
];
},
};
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 {
title: "Pages/Create Flow/Cards",
component: CardsScreen,
title: "Pages/Create Flow/Communication methods",
component: CommunicationMethodsScreen,
parameters: {
layout: "fullscreen",
docs: {
description: {
component:
"Communication / card selection step with modals and responsive layout.",
"Communication methods step (`/create/communication-methods`): card stack, modals, responsive layout.",
},
},
},
decorators: [
(Story) => (
<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 />
</div>
),
@@ -31,6 +31,7 @@ export default {
"2-0",
"2-1",
"2-2",
"2-3",
"3-0",
"3-1",
"3-2",
@@ -86,6 +87,7 @@ export const AllStates = {
<ProportionBar {..._args} progress="2-0" />
<ProportionBar {..._args} progress="2-1" />
<ProportionBar {..._args} progress="2-2" />
<ProportionBar {..._args} progress="2-3" />
<ProportionBar {..._args} progress="3-0" />
<ProportionBar {..._args} progress="3-1" />
<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", () => {
render(<ProportionBar progress="2-1" />);
const progressbar = screen.getByRole("progressbar");
// 2-1: First section full (1) + second section 1/3 filled = 1 + 1/3 ≈ 1.333
expect(progressbar).toHaveAttribute("aria-valuenow", "1.3333333333333333");
// 2-1 (Figma `17861:33241`): first section full + second section 1/4 filled = 1.25.
expect(progressbar).toHaveAttribute("aria-valuenow", "1.25");
expect(progressbar).toHaveAttribute("aria-valuemin", "0");
expect(progressbar).toHaveAttribute("aria-valuemax", "3");
});
@@ -63,7 +63,8 @@ describe("ProportionBar (behavioral tests)", () => {
{ progress: "1-0" as const, expected: 1 / 6 }, // First section 1/6 filled
{ progress: "1-5" as const, expected: 1 }, // First section 6/6 filled (fully filled)
{ progress: "2-0" as const, expected: 1 }, // First section full, second empty
{ progress: "2-2" as const, expected: 1 + 2 / 3 }, // First section full, second section 2/3 filled
{ progress: "2-2" as const, expected: 1 + 1 / 2 }, // 1 + 1/2 per Figma `18861:15250`
{ progress: "2-3" as const, expected: 1 + 3 / 4 }, // 1 + 3/4 per Figma `21434:17632`
{ progress: "3-0" as const, expected: 2 }, // First two sections full, third empty
{ progress: "3-2" as const, expected: 2 + 2 / 3 }, // First two sections full, third section 2/3 filled
];
@@ -6,16 +6,16 @@ import {
} from "../utils/test-utils";
import userEvent from "@testing-library/user-event";
import { describe, test, expect, afterEach } from "vitest";
import { CardsScreen } from "../../app/create/screens/card/CardsScreen";
import { CommunicationMethodsScreen } from "../../app/create/screens/card/CommunicationMethodsScreen";
afterEach(() => {
cleanup();
});
describe("Create flow cards page", () => {
describe("Create flow communication-methods page", () => {
test("clicking a card opens the Create modal", async () => {
const user = userEvent.setup();
render(<CardsScreen />);
render(<CommunicationMethodsScreen />);
const signalCards = screen.getAllByRole("button", {
name: /Signal: Encrypted messaging/,
@@ -29,7 +29,7 @@ describe("Create flow cards page", () => {
});
test("renders without error", () => {
render(<CardsScreen />);
render(<CommunicationMethodsScreen />);
expect(
screen.getByText(
@@ -39,13 +39,12 @@ describe("Create flow cards page", () => {
});
test("renders HeaderLockup and CardStack content", () => {
render(<CardsScreen />);
render(<CommunicationMethodsScreen />);
expect(
screen.getByText(
"You can select multiple methods for different needs or add your own",
),
screen.getByText(/You can select multiple methods for different needs or/),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "add" })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "See all communication approaches" }),
).toBeInTheDocument();
@@ -53,7 +52,7 @@ describe("Create flow cards page", () => {
test("toggle expands and shows Show less", async () => {
const user = userEvent.setup();
render(<CardsScreen />);
render(<CommunicationMethodsScreen />);
const toggle = screen.getByRole("button", {
name: "See all communication approaches",
+9 -9
View File
@@ -18,7 +18,7 @@ describe("Create flow right-rail page", () => {
expect(
screen.getByRole("heading", {
name: "How should conflicts be resolved?",
name: "How should this community make difficult decisions?",
}),
).toBeInTheDocument();
});
@@ -30,9 +30,9 @@ describe("Create flow right-rail page", () => {
if (element?.tagName !== "P") return false;
const text = element.textContent ?? "";
return (
text.includes("You can also combine or") &&
text.includes("Select as many as you need") &&
text.includes("add") &&
text.includes("new approaches to the list")
text.includes("new decision making approaches")
);
});
expect(description).toBeInTheDocument();
@@ -77,17 +77,17 @@ describe("Create flow right-rail page", () => {
expect(
screen.getByRole("button", {
name: /Mediation: Collaborative work to reach a resolution/,
name: /Lazy Consensus: A decision is assumed approved/,
}),
).toBeInTheDocument();
expect(
screen.getByRole("button", {
name: /Facilitated dialogue: Structured sessions/,
name: /Do-ocracy: Decisions are made by those who take initiative/,
}),
).toBeInTheDocument();
expect(
screen.getByRole("button", {
name: /Invite-only: Private discussions with selected participants/,
name: /Consensus Decision-Making: All members must agree/,
}),
).toBeInTheDocument();
});
@@ -123,10 +123,10 @@ describe("Create flow right-rail page", () => {
const user = userEvent.setup();
render(<RightRailScreen />);
const mediationCard = screen.getByRole("button", {
name: /Mediation: Collaborative work to reach a resolution/,
const card = screen.getByRole("button", {
name: /Lazy Consensus: A decision is assumed approved/,
});
await user.click(mediationCard);
await user.click(card);
expect(screen.getByText("SELECTED")).toBeInTheDocument();
});
@@ -26,4 +26,28 @@ describe("getProportionBarProgressForCreateFlowStep", () => {
"2-0",
);
});
it("uses 2-1 on communication-methods", () => {
expect(
getProportionBarProgressForCreateFlowStep("communication-methods"),
).toBe("2-1");
});
it("uses 2-2 on membership-methods", () => {
expect(
getProportionBarProgressForCreateFlowStep("membership-methods"),
).toBe("2-2");
});
it("uses 2-3 on decision-approaches (Figma Flow — Right Rail)", () => {
expect(
getProportionBarProgressForCreateFlowStep("decision-approaches"),
).toBe("2-3");
});
it("uses 3-0 on conflict-management (start of Review segment)", () => {
expect(
getProportionBarProgressForCreateFlowStep("conflict-management"),
).toBe("3-0");
});
});
+1 -1
View File
@@ -56,7 +56,7 @@ describe("createFlowStateSchema", () => {
it("accepts known fields and passthrough keys", () => {
const r = createFlowStateSchema.safeParse({
title: "My rule",
currentStep: "cards",
currentStep: "communication-methods",
customField: { nested: [1, 2] },
});
expect(r.success).toBe(true);
+5 -2
View File
@@ -16,7 +16,10 @@ describe("flowSteps", () => {
});
it("getNextStep returns next step in order", () => {
expect(getNextStep("right-rail")).toBe("confirm-stakeholders");
expect(getNextStep("communication-methods")).toBe("membership-methods");
expect(getNextStep("membership-methods")).toBe("decision-approaches");
expect(getNextStep("decision-approaches")).toBe("conflict-management");
expect(getNextStep("conflict-management")).toBe("confirm-stakeholders");
expect(getNextStep("confirm-stakeholders")).toBe("final-review");
});
@@ -67,6 +70,6 @@ describe("flowSteps", () => {
const opts = { skipCommunitySave: true } as const;
expect(getNextStep("community-size", opts)).toBe("community-upload");
expect(getNextStep("review", opts)).toBe("core-values");
expect(getPreviousStep("cards", opts)).toBe("core-values");
expect(getPreviousStep("communication-methods", opts)).toBe("core-values");
});
});
+12 -18
View File
@@ -2,27 +2,12 @@ import { describe, it, expect } from "vitest";
import { migrateLegacyCreateFlowState } from "../../lib/create/migrateLegacyCreateFlowState";
describe("migrateLegacyCreateFlowState", () => {
it("maps communityReflection to communitySaveEmail when save email empty", () => {
it("passes through object payloads", () => {
const out = migrateLegacyCreateFlowState({
title: "T",
communityReflection: "old@example.com",
});
expect(out.communitySaveEmail).toBe("old@example.com");
expect("communityReflection" in out).toBe(false);
});
it("does not overwrite existing communitySaveEmail", () => {
const out = migrateLegacyCreateFlowState({
communityReflection: "old@example.com",
communitySaveEmail: "kept@example.com",
});
expect(out.communitySaveEmail).toBe("kept@example.com");
});
it("rewrites currentStep slug", () => {
const out = migrateLegacyCreateFlowState({
currentStep: "community-reflection",
currentStep: "community-save",
});
expect(out.title).toBe("T");
expect(out.currentStep).toBe("community-save");
});
@@ -30,4 +15,13 @@ describe("migrateLegacyCreateFlowState", () => {
expect(migrateLegacyCreateFlowState(null)).toEqual({});
expect(migrateLegacyCreateFlowState(undefined)).toEqual({});
});
it("renames legacy right-rail step to decision-approaches", () => {
const out = migrateLegacyCreateFlowState({
currentStep: "right-rail",
title: "T",
});
expect(out.currentStep).toBe("decision-approaches");
expect(out.title).toBe("T");
});
});