From db278ab67d314c7d9e84e36553cad299b5ca10ea Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:13:10 -0700 Subject: [PATCH] Offer the engine's five size bands as exclusive ranges, so a count of 100 has one home and a group cannot continue without a size. Co-authored-by: Cursor --- app/(app)/create/CreateFlowLayoutClient.tsx | 12 ++- .../select/CommunitySizeSelectScreen.tsx | 87 ++++++++++--------- app/(app)/create/types.ts | 2 +- .../create/utils/createFlowFooterLabels.ts | 10 ++- .../create/utils/createFlowScreenRegistry.ts | 2 +- docs/guides/template-recommendation-matrix.md | 2 +- .../en/create/community/communitySize.json | 12 +-- messages/en/create/footer.json | 2 +- tests/components/SelectPage.test.tsx | 46 ++++++++-- tests/unit/communitySizeBands.test.ts | 66 ++++++++++++++ tests/unit/createFooterMessages.test.ts | 4 +- 11 files changed, 184 insertions(+), 61 deletions(-) create mode 100644 tests/unit/communitySizeBands.test.ts diff --git a/app/(app)/create/CreateFlowLayoutClient.tsx b/app/(app)/create/CreateFlowLayoutClient.tsx index b9c0ca0..f537c74 100644 --- a/app/(app)/create/CreateFlowLayoutClient.tsx +++ b/app/(app)/create/CreateFlowLayoutClient.tsx @@ -85,7 +85,10 @@ import { methodCardFacetSectionForConfirmStep, type CustomRuleConfirmFooterStep, } from "./utils/customRuleConfirmFooterSteps"; -import { getDefaultFooterLabel } from "./utils/createFlowFooterLabels"; +import { + getDefaultFooterLabel, + isCommunitySizeStepBlocked, +} from "./utils/createFlowFooterLabels"; import { useAuthModal } from "../../contexts/AuthModalContext"; import { useMessages, useTranslation } from "../../contexts/MessagesContext"; import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer"; @@ -985,7 +988,12 @@ function CreateFlowLayoutContent({ palette="default" size="xsmall" disabled={ - isPublishing || (isFinalReviewLike && !sessionResolved) + isPublishing || + (isFinalReviewLike && !sessionResolved) || + isCommunitySizeStepBlocked( + currentStep, + state.selectedCommunitySizeIds, + ) } className={CREATE_FLOW_FOOTER_BUTTON_CLASS} onClick={() => { diff --git a/app/(app)/create/screens/select/CommunitySizeSelectScreen.tsx b/app/(app)/create/screens/select/CommunitySizeSelectScreen.tsx index 842e5ee..1c88fec 100644 --- a/app/(app)/create/screens/select/CommunitySizeSelectScreen.tsx +++ b/app/(app)/create/screens/select/CommunitySizeSelectScreen.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useLayoutEffect } from "react"; import MultiSelect from "../../../../components/controls/MultiSelect"; import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types"; import { useMessages } from "../../../../contexts/MessagesContext"; @@ -18,13 +18,32 @@ function chipRowsFromLabels( })); } -function selectedIdsFromOptions(options: ChipOption[]): string[] { - return options - .filter((o) => o.state === "selected") - .map((o) => o.id); +/** The first saved id. A count belongs to one band, so later ids are dropped. */ +function soleSizeId(ids: readonly string[] | undefined): string | null { + for (const id of ids ?? []) { + if (id.length > 0) return id; + } + return null; } -/** Create Community — Figma `20094:41317`, chips only (layout tokens shared with structure select). */ +function withSoleSelection( + options: ChipOption[], + selectedId: string | null, +): ChipOption[] { + return options.map((opt) => + opt.state === "custom" + ? opt + : { + ...opt, + state: + opt.id === selectedId + ? ("selected" as const) + : ("unselected" as const), + }, + ); +} + +/** Create Community — Figma `24715:26346`, chips size M (one question). */ export function CommunitySizeSelectScreen() { const m = useMessages(); const cs = m.create.community.communitySize; @@ -32,52 +51,40 @@ export function CommunitySizeSelectScreen() { const [communitySizeOptions, setCommunitySizeOptions] = useState< ChipOption[] - >(() => { - const base = chipRowsFromLabels(cs.communitySizes); - const selected = new Set(state.selectedCommunitySizeIds ?? []); - return base.map((opt) => ({ - ...opt, - state: selected.has(opt.id) ? ("selected" as const) : ("unselected" as const), - })); - }); + >(() => + withSoleSelection( + chipRowsFromLabels(cs.communitySizes), + soleSizeId(state.selectedCommunitySizeIds), + ), + ); + + useLayoutEffect(() => { + const ids = state.selectedCommunitySizeIds; + if (!ids || ids.length === 0) return; + const first = soleSizeId(ids); + if (ids.length === 1 && ids[0] === first) return; + updateState({ selectedCommunitySizeIds: first ? [first] : [] }); + }, [state.selectedCommunitySizeIds, updateState]); useEffect(() => { - const selected = new Set(state.selectedCommunitySizeIds ?? []); - setCommunitySizeOptions((prev) => - prev.map((opt) => - opt.state === "custom" - ? opt - : { - ...opt, - state: selected.has(opt.id) - ? ("selected" as const) - : ("unselected" as const), - }, - ), - ); + const selectedId = soleSizeId(state.selectedCommunitySizeIds); + setCommunitySizeOptions((prev) => withSoleSelection(prev, selectedId)); }, [state.selectedCommunitySizeIds]); const persistSelection = (next: ChipOption[]) => { markCreateFlowInteraction(); setCommunitySizeOptions(next); updateState({ - selectedCommunitySizeIds: selectedIdsFromOptions(next), + selectedCommunitySizeIds: next + .filter((o) => o.state === "selected") + .map((o) => o.id), }); }; const handleCommunitySizeClick = (chipId: string) => { - const next: ChipOption[] = communitySizeOptions.map((opt) => - opt.id === chipId - ? { - ...opt, - state: - opt.state === "selected" - ? ("unselected" as const) - : ("selected" as const), - } - : opt, - ); - persistSelection(next); + const current = communitySizeOptions.find((opt) => opt.state === "selected"); + const nextId = current?.id === chipId ? null : chipId; + persistSelection(withSoleSelection(communitySizeOptions, nextId)); }; const multiSelectBlock = ( diff --git a/app/(app)/create/types.ts b/app/(app)/create/types.ts index 3cc1dd8..0df2cc8 100644 --- a/app/(app)/create/types.ts +++ b/app/(app)/create/types.ts @@ -123,7 +123,7 @@ export interface CreateFlowState { * Picked once and kept on the draft so reload does not change it. */ previewSurfaceColor?: string; - /** Selected chip ids from `community-size` (MultiSelect). */ + /** The size band from `community-size`. One id; 100 is `thirteenToOneHundred`. */ selectedCommunitySizeIds?: string[]; /** Selected chip ids from `community-structure` (organization types). */ selectedOrganizationTypeIds?: string[]; diff --git a/app/(app)/create/utils/createFlowFooterLabels.ts b/app/(app)/create/utils/createFlowFooterLabels.ts index 2d045b7..fe5d2c3 100644 --- a/app/(app)/create/utils/createFlowFooterLabels.ts +++ b/app/(app)/create/utils/createFlowFooterLabels.ts @@ -20,9 +20,17 @@ const DEFAULT_FOOTER_LABEL_BY_STEP: ReadonlyMap< ["confirm-stakeholders", "confirmStakeholders"], ["community-context", "confirmDescription"], ["community-structure", "confirmDetails"], - ["community-size", "confirmMembers"], + ["community-size", "confirmSize"], ]); +export function isCommunitySizeStepBlocked( + step: CreateFlowStep | null | undefined, + selectedCommunitySizeIds: readonly string[] | undefined, +): boolean { + if (step !== "community-size") return false; + return !(selectedCommunitySizeIds ?? []).some((id) => id.length > 0); +} + /** * Resolve the localized label for the default "next-step" footer button. * Returns the per-step override when one is registered, otherwise diff --git a/app/(app)/create/utils/createFlowScreenRegistry.ts b/app/(app)/create/utils/createFlowScreenRegistry.ts index 82743c2..8f0117e 100644 --- a/app/(app)/create/utils/createFlowScreenRegistry.ts +++ b/app/(app)/create/utils/createFlowScreenRegistry.ts @@ -53,7 +53,7 @@ export const CREATE_FLOW_SCREEN_REGISTRY: Record< }, "community-size": { layoutKind: "select", - figmaNodeId: "20094-41317", + figmaNodeId: "24715-26346", messageNamespace: "create.community.communitySize", centeredBodyBelowMd: false, }, diff --git a/docs/guides/template-recommendation-matrix.md b/docs/guides/template-recommendation-matrix.md index c896ba5..053f328 100644 --- a/docs/guides/template-recommendation-matrix.md +++ b/docs/guides/template-recommendation-matrix.md @@ -76,7 +76,7 @@ These are the only dimensions the recommendation engine scores against. | Group | Value `id`s (canonical lowercase keys) | Wizard chip labels | `CreateFlowState` field | | --- | --- | --- | --- | -| `size` | `oneMember`, `twoToFive`, `sixToTwelve`, `thirteenToOneHundred`, `oneHundredToOneHundredK` | `1 member`, `2-5 members`, `6-12 members`, `13-100 members`, `100-100,000 members` | `selectedCommunitySizeIds` | +| `size` | `oneMember`, `twoToFive`, `sixToTwelve`, `thirteenToOneHundred`, `oneHundredToOneHundredK` | `One member`, `2 to 5`, `6 to 12`, `13 to 100`, `More than 100` | `selectedCommunitySizeIds` | | `orgType` | `dao`, `forProfit`, `nonprofit`, `openSource`, `mutualAid`, `workersCoop` | `DAO`, `For profit business`, `Nonprofit`, `Open source project`, `Mutual aid`, `Worker's coop` | `selectedOrganizationTypeIds` | | `scale` | `global`, `national`, `regional`, `local` | `Global`, `National`, `Regional`, `Local` | `selectedScaleIds` | | `maturity` | `earlyStage`, `growthStage`, `established`, `enterprise` | `Early stage`, `Growth stage`, `Established`, `Enterprise` | `selectedMaturityIds` | diff --git a/messages/en/create/community/communitySize.json b/messages/en/create/community/communitySize.json index a50131d..19f4842 100644 --- a/messages/en/create/community/communitySize.json +++ b/messages/en/create/community/communitySize.json @@ -1,13 +1,13 @@ { "header": { "title": "How many people will be in your community in the near term?", - "description": "Choose how many people you think will be in your community in the next year or two. Your selection here will determine what governance patterns are recommended later in the process." + "description": "Count the people who have a say in your big decisions. If others take part without a vote, you can say so later in setup." }, "communitySizes": [ - { "id": "oneMember", "label": "1 member" }, - { "id": "twoToFive", "label": "2-5 members" }, - { "id": "sixToTwelve", "label": "6-12 members" }, - { "id": "thirteenToOneHundred", "label": "13-100 members" }, - { "id": "oneHundredToOneHundredK", "label": "100-100,000 members" } + { "id": "oneMember", "label": "One member" }, + { "id": "twoToFive", "label": "2 to 5" }, + { "id": "sixToTwelve", "label": "6 to 12" }, + { "id": "thirteenToOneHundred", "label": "13 to 100" }, + { "id": "oneHundredToOneHundredK", "label": "More than 100" } ] } diff --git a/messages/en/create/footer.json b/messages/en/create/footer.json index 2f228e0..9c9b3dd 100644 --- a/messages/en/create/footer.json +++ b/messages/en/create/footer.json @@ -8,7 +8,7 @@ "confirmName": "Confirm name", "confirmDetails": "Confirm details", "confirmDescription": "Confirm description", - "confirmMembers": "Confirm members", + "confirmSize": "Confirm size", "publishCommunityRule": "Publish CommunityRule", "confirmStakeholders": "Confirm Stakeholders", "confirmCoreValues": "Confirm values", diff --git a/tests/components/SelectPage.test.tsx b/tests/components/SelectPage.test.tsx index def5888..5ccce7e 100644 --- a/tests/components/SelectPage.test.tsx +++ b/tests/components/SelectPage.test.tsx @@ -1,8 +1,17 @@ import { describe, it, expect } from "vitest"; +import userEvent from "@testing-library/user-event"; import { renderWithProviders as render, screen } from "../utils/test-utils"; import "@testing-library/jest-dom/vitest"; import { CommunitySizeSelectScreen } from "../../app/(app)/create/screens/select/CommunitySizeSelectScreen"; +const BANDS = [ + "One member", + "2 to 5", + "6 to 12", + "13 to 100", + "More than 100", +] as const; + describe("CommunitySizeSelectScreen", () => { it("renders HeaderLockup title", () => { render(); @@ -13,19 +22,44 @@ describe("CommunitySizeSelectScreen", () => { ).toBeInTheDocument(); }); - it("renders preset size chips", () => { + it("asks groups to count people who have a say", () => { render(); - expect(screen.getByText("1 member")).toBeInTheDocument(); - expect(screen.getByText("2-5 members")).toBeInTheDocument(); + expect( + screen.getByText( + "Count the people who have a say in your big decisions. If others take part without a vote, you can say so later in setup.", + ), + ).toBeInTheDocument(); }); - it("gives size chips Select/Deselect accessible names", () => { + it("renders the five size bands", () => { render(); + for (const label of BANDS) { + expect( + screen.getByRole("button", { name: `Select ${label}` }), + ).toBeInTheDocument(); + } + }); + + it("keeps a single band selected", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Select 13 to 100" })); expect( - screen.getByRole("button", { name: "Select 1 member" }), + screen.getByRole("button", { name: "Deselect 13 to 100" }), ).toBeInTheDocument(); expect( - screen.getByRole("button", { name: "Select 2-5 members" }), + screen.getByRole("button", { name: "Select More than 100" }), + ).toBeInTheDocument(); + + await user.click( + screen.getByRole("button", { name: "Select More than 100" }), + ); + expect( + screen.getByRole("button", { name: "Deselect More than 100" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Select 13 to 100" }), ).toBeInTheDocument(); }); }); diff --git a/tests/unit/communitySizeBands.test.ts b/tests/unit/communitySizeBands.test.ts new file mode 100644 index 0000000..df66084 --- /dev/null +++ b/tests/unit/communitySizeBands.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import communitySize from "../../messages/en/create/community/communitySize.json"; +import { SIZE_BANDS } from "../../lib/create/deckTypes"; +import { isCommunitySizeStepBlocked } from "../../app/(app)/create/utils/createFlowFooterLabels"; + +/** + * Inclusive bounds. 100 stays in 13 to 100. More than 100 starts at 101. + * The last band has no upper bound. + */ +const BOUNDS: Record<(typeof SIZE_BANDS)[number], { min: number; max: number | null }> = { + oneMember: { min: 1, max: 1 }, + twoToFive: { min: 2, max: 5 }, + sixToTwelve: { min: 6, max: 12 }, + thirteenToOneHundred: { min: 13, max: 100 }, + oneHundredToOneHundredK: { min: 101, max: null }, +}; + +function bandsForCount(count: number): string[] { + return SIZE_BANDS.filter((id) => { + const bound = BOUNDS[id]; + return count >= bound.min && (bound.max == null || count <= bound.max); + }); +} + +describe("community size bands", () => { + it("offers the five engine bands, with exclusive labels", () => { + expect(communitySize.header.description).toBe( + "Count the people who have a say in your big decisions. If others take part without a vote, you can say so later in setup.", + ); + expect(communitySize.communitySizes).toEqual([ + { id: "oneMember", label: "One member" }, + { id: "twoToFive", label: "2 to 5" }, + { id: "sixToTwelve", label: "6 to 12" }, + { id: "thirteenToOneHundred", label: "13 to 100" }, + { id: "oneHundredToOneHundredK", label: "More than 100" }, + ]); + expect(communitySize.communitySizes.map((row) => row.id)).toEqual([ + ...SIZE_BANDS, + ]); + }); + + it("puts each count in exactly one band, with 100 in 13 to 100", () => { + expect(bandsForCount(1)).toEqual(["oneMember"]); + expect(bandsForCount(5)).toEqual(["twoToFive"]); + expect(bandsForCount(6)).toEqual(["sixToTwelve"]); + expect(bandsForCount(12)).toEqual(["sixToTwelve"]); + expect(bandsForCount(13)).toEqual(["thirteenToOneHundred"]); + expect(bandsForCount(100)).toEqual(["thirteenToOneHundred"]); + expect(bandsForCount(101)).toEqual(["oneHundredToOneHundredK"]); + for (let count = 1; count <= 500; count += 1) { + expect(bandsForCount(count)).toHaveLength(1); + } + }); + + it("blocks continue on the size step until one band is chosen", () => { + expect(isCommunitySizeStepBlocked("community-size", undefined)).toBe(true); + expect(isCommunitySizeStepBlocked("community-size", [])).toBe(true); + expect(isCommunitySizeStepBlocked("community-size", [""])).toBe(true); + expect( + isCommunitySizeStepBlocked("community-size", ["thirteenToOneHundred"]), + ).toBe(false); + expect( + isCommunitySizeStepBlocked("community-structure", undefined), + ).toBe(false); + }); +}); diff --git a/tests/unit/createFooterMessages.test.ts b/tests/unit/createFooterMessages.test.ts index 12dd0ef..87b4e56 100644 --- a/tests/unit/createFooterMessages.test.ts +++ b/tests/unit/createFooterMessages.test.ts @@ -16,8 +16,8 @@ describe("create footer messages", () => { ); }); - it("exposes confirmMembers for the community-size footer CTA", () => { - expect(messages.create.footer.confirmMembers).toBe("Confirm members"); + it("exposes confirmSize for the community-size footer CTA", () => { + expect(messages.create.footer.confirmSize).toBe("Confirm size"); }); it("exposes publishCommunityRule for the final-review footer CTA", () => { -- 2.53.0