App reorganization

This commit is contained in:
adilallo
2026-04-18 14:12:49 -06:00
parent f866d11ff8
commit e9dab04b34
288 changed files with 2698 additions and 5029 deletions
+30 -38
View File
@@ -1,59 +1,51 @@
---
description: Figma ↔ codebase prop alignment & normalization for design-system components
description: Component prop conventions — lowercase-canonical enums, Figma traceability
globs: app/components/**/*.{ts,tsx}
alwaysApply: false
---
# Component prop alignment
Figma emits PascalCase enum values (`"Standard"`, `"Inverse"`); the codebase
stores lowercase (`"standard"`, `"inverse"`). Components must accept **both**,
normalize to lowercase internally, and never break existing consumer call
sites.
Figma is the source of truth for component **design** (existence, variants,
visual specification). The codebase implements those components using
idiomatic TypeScript naming. Enum props are **lowercase** in code; PascalCase
is a Figma-side concern only.
## Scope
## Enum prop convention
Applies to enum-like string props: `variant`, `size`, `state`, `mode`, `type`,
`position`, `alignment`, `status`, `color`, `palette`. **Skip** for free-form
strings (URLs, classNames), booleans, numbers, or internal-only props.
## Pattern
- Types use lowercase string unions: `"small" | "medium" | "large"`.
- Do NOT add PascalCase variants to type unions.
- Do NOT call normalizers in containers. The container layer is for `memo`,
derived state, prop defaults, and bound logic — not for casing translation.
- Each enum prop has a sibling `<COMPONENT>_<PROP>_OPTIONS as const` array
exported alongside the type. Storybook `argTypes` and any runtime guard
consume that array as the single source of valid values.
```typescript
// Type accepts both formats
export type ComponentSizeValue = "small" | "medium" | "Small" | "Medium";
// Container normalizes via helpers from lib/propNormalization.ts
import { normalizeSize } from "../../../lib/propNormalization";
const ComponentContainer = ({ size: sizeProp = "small" }: Props) => {
const size = normalizeSize(sizeProp, SIZE_OPTIONS, "small");
return <ComponentView size={size} />;
};
export const CHIP_PALETTE_OPTIONS = ["primary", "secondary"] as const;
export type ChipPaletteValue = (typeof CHIP_PALETTE_OPTIONS)[number];
```
## Normalizers
Use an existing helper from `lib/propNormalization.ts` before writing a new
one. Generic: `normalizeMode`, `normalizeState`, `normalizeInputState`,
`normalizeSize`, `normalizeAlignment`, `normalizeSmallMediumLargeSize`,
`normalizeLabelVariant`. Component-specific variants are named
`normalize<Component><Prop>` (e.g. `normalizeChipPalette`,
`normalizeButtonState`) — add one there rather than inlining a switch in your
container.
## Figma traceability
Every DS component's container docstring cites its Figma origin so designers
and engineers can jump between the two. Format: `Figma: "<Component Path>"
(<node-id>)`.
- Container docstring (required on every DS container): `Figma:
"<Component Path>" (<node-id>)`.
- View root element: `data-figma-node="<id>"` when the view maps to a
distinct Figma node.
- For create-flow screens, node ids come from `CREATE_FLOW_SCREEN_REGISTRY`
in `app/(app)/create/utils/createFlowScreenRegistry.ts`. For everything else,
pull the node id from the Figma file directly. Use `TODO(figma)` as a
placeholder rather than omitting the docstring entirely.
```typescript
/**
* Figma: "Control / Incrementer" (`17857:30943`). A compact [ - value + ]
* row used for numeric step inputs
* Figma: "Control / Incrementer" (17857:30943). A compact [ - value + ]
* row used for numeric step inputs.
*/
```
When the view renders a distinct Figma node, add `data-figma-node="<id>"` on
the root element for quick DOM-to-design lookup.
## Pasting from Figma
Figma's "Inspect → Code" output emits PascalCase. When importing a snippet,
lowercase the enum values before committing — same pattern as removing
inline pixel values in favor of design tokens.
+17 -14
View File
@@ -22,24 +22,27 @@ app/components/controls/<Name>/
**Container** (`<Name>.container.tsx`):
- Marked `"use client"`.
- Receives `<Name>Props`; normalizes PascalCase enums via
`lib/propNormalization.ts`; computes derived state (clamps, ids, bounds).
- Renders `<<Name>View />` with already-normalized `<Name>ViewProps`.
- Receives `<Name>Props`; computes derived state (clamps, ids, bounds, prop
defaults) and bound event handlers.
- Renders `<<Name>View />`. Containers do **not** translate prop casing —
enum props are lowercase end-to-end (see `component-props.mdc`).
- Default export: `memo(<Name>Container)` with `.displayName = "<Name>"`.
- Carries the Figma docstring (`Figma: "<Path>" (<node-id>)`).
**View** (`<Name>.view.tsx`):
- Marked `"use client"`.
- Pure render of `<Name>ViewProps`. No prop normalization, no data fetching,
no derived business logic.
- Default export: `memo(<Name>View)` with
`.displayName = "<Name>View"`.
- Pure render of `<Name>ViewProps`. No data fetching, no derived business
logic, no enum casing translation.
- Default export: `memo(<Name>View)` with `.displayName = "<Name>View"`.
**Types** (`<Name>.types.ts`):
- Export `<Name>Props` (consumer-facing, accepts PascalCase + lowercase).
- Export `<Name>ViewProps` (already-normalized shape the view consumes).
- Export any locally-defined value types (`<Name>SizeValue`, etc.).
- Export `<Name>Props` (consumer-facing).
- Export `<Name>ViewProps` (the shape the view consumes — typically a
resolved superset of `<Name>Props`).
- Export any locally-defined value types (`<Name>SizeValue`, etc.) sourced
from the matching `*_OPTIONS` array in `lib/propNormalization.ts`.
**Index** (`index.tsx`):
@@ -51,10 +54,10 @@ export type { <Name>Props } from "./<Name>.types";
## Single-file pattern (exception)
`app/components/buttons/*.tsx` and other trivially-presentational components
can stay as a single file when they have **no enum prop normalization and no
derived state** (e.g. `Button.tsx`, `InlineTextButton.tsx`). If you find
yourself adding state, enum normalization, or more than a handful of props,
promote it to the split pattern.
can stay as a single file when they have **no derived state and only a
handful of props** (e.g. `Button.tsx`, `InlineTextButton.tsx`). If you find
yourself adding state, side effects, or enum logic, promote it to the split
pattern.
## Wrapper / group components
+6 -6
View File
@@ -1,6 +1,6 @@
---
description: Create-flow structure & design-system reuse guardrails
globs: app/create/**/*.{ts,tsx},messages/en/create/**/*.json
globs: app/(app)/create/**/*.{ts,tsx},messages/en/create/**/*.json
alwaysApply: false
---
@@ -8,15 +8,15 @@ alwaysApply: false
## Folder & file layout
- Screens live in `app/create/screens/<layoutKind>/<StepIdPascal>Screen.tsx`
- Screens live in `app/(app)/create/screens/<layoutKind>/<StepIdPascal>Screen.tsx`
where `<layoutKind>` mirrors `CreateFlowLayoutKind` (`card`, `select`,
`right-rail`, `completed`, …). File + export name is the **step id**, never
the layout kind (e.g. `DecisionApproachesScreen`, not `RightRailScreen`).
- Step id ↔ layout kind mapping is declared in
`app/create/utils/createFlowScreenRegistry.ts`. Never branch on layout kind
`app/(app)/create/utils/createFlowScreenRegistry.ts`. Never branch on layout kind
inside a screen — pick the matching shell (`CreateFlowStepShell` /
`CreateFlowTwoColumnSelectShell`).
- Shared create-flow pieces go in `app/create/components/` (layout shells,
- Shared create-flow pieces go in `app/(app)/create/components/` (layout shells,
field composites). Generic primitives go in `app/components/`.
## Use the design system — don't hand-roll
@@ -25,8 +25,8 @@ Reach for these before writing new markup:
| Need | Component |
| --- | --- |
| Labelled text-area section in a modal | `app/create/components/ModalTextAreaField` |
| Toggle-chip row + inline "+ Add" input | `app/create/components/ApplicableScopeField` |
| Labelled text-area section in a modal | `app/(app)/create/components/ModalTextAreaField` |
| Toggle-chip row + inline "+ Add" input | `app/(app)/create/components/ApplicableScopeField` |
| `[ value +]` numeric stepper (± label) | `app/components/controls/Incrementer` / `IncrementerBlock` |
| Mid-paragraph "expand / see all" link button | `app/components/buttons/InlineTextButton` |
| Help-icon + label above a control | `app/components/utility/InputLabel` (`helpIcon` prop) |
+59
View File
@@ -0,0 +1,59 @@
---
description: Custom hooks live in app/hooks; co-locate logic, document via TSDoc.
globs: app/hooks/**/*.{ts,tsx}
alwaysApply: false
---
# Custom hooks
Reusable component logic lives in `app/hooks/`. Each hook is a small, focused
module with a TSDoc block that doubles as the API reference (no separate doc
file).
## File layout
- One file per hook: `app/hooks/use<Name>.ts`.
- Re-export from `app/hooks/index.ts`. Consumers import from the barrel:
`import { useFoo } from "../hooks";`.
- Companion unit test (when there is non-trivial logic): `tests/unit/hooks/`.
## Authoring rules
- Marked as a regular function (`export function useFoo() {}`); React handles
the `use*` naming convention.
- Wrap exposed callbacks in `useCallback` and computed values in `useMemo`
so consumers can list them in dependency arrays without churn.
- Read DOM/browser APIs only inside `useEffect` so the hook stays SSR-safe.
- Never throw on missing globals (e.g. `window`, `gtag`); guard and no-op.
## TSDoc — the only reference
Every exported hook gets a TSDoc block with:
- 12 sentence summary.
- `@param` per argument and `@returns` describing the shape.
- `@example` showing the typical call site.
```ts
/**
* Detect clicks outside a set of elements (e.g. close a dropdown).
*
* @param refs Elements that should NOT trigger the handler.
* @param handler Invoked when a click lands outside every ref.
* @param enabled Toggle without unmounting the consumer (default true).
*
* @example
* useClickOutside([menuRef, buttonRef], () => setOpen(false), open);
*/
export function useClickOutside(
refs: Array<RefObject<HTMLElement>>,
handler: (event: MouseEvent | TouchEvent) => void,
enabled = true,
): void { /* ... */ }
```
## Container/view consumption
Hooks belong in **container** files (per `component-structure.mdc`). Views
stay pure and read derived values via props — never call hooks that touch
state or side effects from a view.
+88
View File
@@ -0,0 +1,88 @@
---
description: App Router route organization (groups, layouts, chrome composition)
globs: app/**/*.{ts,tsx}
alwaysApply: false
---
# Route organization
Top-level routes live inside **route groups** so each surface owns its own
layout and chrome. Groups are wrapping folders in `(parens)` — they organize
the file tree without affecting URLs.
## Group map
| Group | URL surface | Audience | Chrome |
|---|---|---|---|
| `app/(marketing)/` | `/`, `/learn`, `/blog`, `/templates`, future public pages | Public, indexable | TopNav (via root) + marketing `<Footer />` |
| `app/(app)/` | `/create/*`, `/login`, `/profile`, future signed-in surfaces | Authenticated product | TopNav (via root) — no footer |
| `app/(admin)/` | `/monitor`, future ops dashboards | Operators | TopNav (via root) — no footer |
| `app/(dev)/` | `/components-preview`, future dev previews | Local dev (NODE_ENV gated) | TopNav (via root) — no footer |
| `app/api/` | API routes | n/a | n/a |
Route folders **must not** sit loose at the top level of `app/`. If a new
surface doesn't fit an existing group, add a new group rather than dropping
the folder next to `(marketing)/`.
## Layout responsibilities
- **`app/layout.tsx`** — `<html>`, `<body>`, providers (`MessagesProvider`,
`AuthModalProvider`), fonts, and `ConditionalNavigation`. Renders
`{children}` directly inside the flex column. **Does not** render
`<main>` — each group layout owns that.
- **`app/(marketing)/layout.tsx`** — wraps with `<main className="flex-1">`
and appends the public `<Footer />`.
- **`app/(app)/layout.tsx`** / **`(admin)/layout.tsx`** / **`(dev)/layout.tsx`** —
wrap with `<main className="flex-1">`. No footer.
- **Nested layouts** (e.g. `(app)/create/layout.tsx`) compose feature-specific
chrome inside the group's `<main>` — never render `<html>`, `<body>`,
`<main>`, or providers.
If a route needs different chrome than its group provides, prefer adding a
**nested layout** under that route — don't introduce pathname-sniffing
client components. (`ConditionalNavigation` is the lone tolerated exception
because it carries SSR session state; do not add new pathname-conditional
chrome components.)
## Co-located component folders
Page-private server/client components that are **only** used by routes in a
given group go in `_components/` inside that group:
```
app/(marketing)/_components/MarketingRuleStackSection.tsx
```
The leading underscore makes Next.js treat the folder as **private** — it's
ignored by the router. Use this instead of letting page-only files sit next
to `page.tsx`.
Components reused across groups belong in `app/components/<category>/`
(see `component-structure.mdc`).
## Adding a new route
1. **Choose the group** by audience: marketing (public), app (signed-in),
admin (operators), dev (local-only). When in doubt, ask whether the
public marketing footer should appear — if yes, it's `(marketing)`.
2. Create `app/(<group>)/<route>/page.tsx`. URLs do **not** include the
group name.
3. If the route needs its own chrome (e.g. a wizard header), add
`app/(<group>)/<route>/layout.tsx`.
4. If the route ships private helpers, put them in
`app/(<group>)/<route>/_components/` (or
`app/(<group>)/_components/` for group-wide page components).
## Splitting a group
Promote a sub-cluster to its own group only when **both** are true:
- It will hold ≥2 routes that share a layout, **or** it has a clearly
distinct audience/access model (e.g. a future `(auth)/` for
signup/forgot/verify alongside login).
- Moving the routes pays for itself by replacing existing pathname
conditionals or by composing real shared chrome — not just by tidying
the folder list.
YAGNI applies: a group with one route and no shared layout is just a
folder with parens.
+19 -10
View File
@@ -6,14 +6,22 @@ alwaysApply: false
# Where stories live
All stories live in the top-level `stories/` folder, mirroring the
`app/components/` directory tree:
All stories live in the top-level `stories/` folder. Two layout rules:
| Source | Story location |
| --------------------------------- | -------------------------------------- |
| `app/components/controls/Chip` | `stories/controls/Chip.stories.js` |
| `app/components/buttons/Button` | `stories/buttons/Button.stories.js` |
| `app/create/screens/.../FooScreen`| `stories/pages/FooPage.stories.js` |
- **Design-system components** mirror `app/components/`. A component at
`app/components/<bucket>/<Name>` gets `stories/<bucket>/<Name>.stories.js`.
- **Create-flow material** has two carve-outs:
- `stories/create-flow/` — shared create-flow pieces that aren't in
`app/components/` (e.g. composed wizard fragments).
- `stories/pages/` — integration stories that exercise an entire
`app/(app)/create/screens/<...>` screen as it appears in the wizard.
| Source | Story location |
| --------------------------------- | --------------------------------------- |
| `app/components/controls/Chip` | `stories/controls/Chip.stories.js` |
| `app/components/buttons/Button` | `stories/buttons/Button.stories.js` |
| `app/(app)/create/screens/.../FooScreen`| `stories/pages/FooPage.stories.js` |
| Shared create-flow fragment | `stories/create-flow/<Name>.stories.js` |
Do **not** colocate `*.stories.*` next to components. The Storybook config
(`.storybook/main.js`) only globs `stories/**`.
@@ -65,8 +73,9 @@ export const Default = { args: { variant: "filled" } };
## `argTypes`
For every Figma enum prop (`variant`, `size`, `state`, `mode`, `palette`,
…) expose a `select` control listing the **lowercase** option set — even
though the prop accepts PascalCase too. See `.cursor/rules/component-props.mdc`.
…) expose a `select` control listing the **lowercase** option set, sourced
from the matching `*_OPTIONS` const in `lib/propNormalization.ts`. See
`.cursor/rules/component-props.mdc`.
# Rely on the global preview — don't re-wrap
@@ -101,5 +110,5 @@ export const Interactive = {
# Coverage expectation
Every new component in `app/components/**` ships with a story. Screens in
`app/create/screens/**` ship with a `stories/pages/<Name>Page.stories.js`
`app/(app)/create/screens/**` ship with a `stories/pages/<Name>Page.stories.js`
entry. A new component without a story is considered incomplete.
+82
View File
@@ -0,0 +1,82 @@
# Agent guide
Orientation for AI coding agents working in this repo. Per-file conventions
live in `.cursor/rules/*.mdc` (auto-loaded by Cursor; other agents should
read them on demand). This file is the **map** — load it first, then load
the rule(s) matching the file you're editing.
## What this is
Next.js 16 / React 19 app for community decision-making and governance.
Single-locale (English) today; designed for i18n via `messages/`.
## Read before editing
| If you're touching… | Load this rule |
| --- | --- |
| `app/components/**` | `component-structure.mdc`, `component-props.mdc`, `tailwind-styling.mdc` |
| `app/(app)/create/**` | `create-flow.mdc` (+ component rules) |
| `app/api/**` | `api-routes.mdc` |
| `app/hooks/**` | `hooks.mdc` |
| `app/**/page.tsx` or `app/**/layout.tsx` | `routes.mdc` |
| `messages/**` or any user-visible string | `localization.mdc` |
| `tests/**` | `testing.mdc` |
| `stories/**` | `storybook.mdc` |
When in doubt about file structure or naming, the rules win over your
priors — they reflect deliberate decisions.
## Cross-cutting principles (no single rule owns these)
1. **Figma is the source of truth for design.** Container files carry a
`Figma: "<Path>" (<node-id>)` docstring; views render Figma intent.
Codebase naming uses lowercase conventions (see `component-props.mdc`)
even when Figma uses PascalCase enum values.
2. **Container / view split is the component pattern.** Never put state
or side effects in a `*.view.tsx`. Hooks belong in containers.
3. **All user-visible text lives in `messages/`.** Hardcoded strings in
components are a bug — even for placeholders.
4. **Tests live in `tests/`, not co-located.** Mirror the source path
(`app/components/Foo``tests/components/Foo.test.tsx`).
5. **Routes live inside groups**`(marketing)`, `(app)`, `(admin)`,
`(dev)`. Don't drop a new route folder loose at the top of `app/`.
6. **No new pathname-sniffing chrome.** Compose chrome via group/nested
layouts, not `usePathname()` checks. (`ConditionalNavigation` is the
sole tolerated exception — it carries SSR session state.)
## Legacy / scaffolding
Some code exists temporarily while backend services are stood up:
- `NEXT_PUBLIC_ENABLE_BACKEND_SYNC` gating
- `migrateLegacyCreateFlowState`, `LEGACY_LIVE_KEY`, `LEGACY_DRAFT_KEY`
- `/create/right-rail` redirect
- `docs/guides/backend-roadmap.md`, `backend-linear-tickets.md`,
`template-recommendation-matrix.md`
**Do not delete** without an explicit ask. Do not add new code in this
shape — when adding scaffolding, leave a `// TODO(legacy): …` with the
removal trigger.
## Verification recipe
Run these (in order) before declaring a change done. They mirror CI:
```bash
rm -rf .next # only if you moved/renamed routes or layouts
npx tsc --noEmit # type check
npx vitest run # unit + component (101 files / ~700 tests)
npx next build # production build + route manifest
```
For UI-only changes, also: `npm run storybook` and visually confirm.
For E2E-relevant changes: `npm run e2e`.
## Where else to look
- [README.md](README.md) — human onboarding, scripts, project layout.
- [CONTRIBUTING.md](CONTRIBUTING.md) — local Postgres + Prisma + magic-link
setup, PR workflow.
- [docs/README.md](docs/README.md) — index of user-facing docs.
- [docs/create-flow.md](docs/create-flow.md) — wizard URL/persistence canon
(read alongside `create-flow.mdc`).
+58 -33
View File
@@ -1,51 +1,76 @@
# Contributing
## Backend (local)
## Local backend
1. Copy [`.env.example`](.env.example) to `.env` and set `SESSION_SECRET` (at least 16 characters).
2. `docker compose up -d postgres mailhog` — omit `mailhog` if you only need Postgres; with `SMTP_URL` unset, the **magic-link verify URL** is printed in the dev server log (see `.env.example`).
3. Install dependencies: `npm ci`
4. Apply migrations: `npx prisma migrate dev`
5. (Optional) Seed curated rule templates: `npx prisma db seed` — requires `DATABASE_URL` and applied migrations. Safe to re-run; rows are upserted by `slug` so duplicates are not created.
6. Run the app: `npm run dev`
1. Copy [`.env.example`](.env.example) to `.env` and set `SESSION_SECRET`
(at least 16 characters).
2. `docker compose up -d postgres mailhog` — omit `mailhog` if you only
need Postgres. Without `SMTP_URL`, the **magic-link verify URL** is
printed in the dev server log.
3. `npm ci`
4. `npx prisma migrate dev`
5. *(Optional)* `npx prisma db seed` — seeds curated rule templates.
Idempotent; rows upsert by `slug`.
6. `npm run dev`
Use `npx prisma studio` to inspect the database.
### Prisma migrations (important)
### Prisma migrations
- **Do not edit** migration files that have **already been applied** to **staging, production, or any shared database**. Changing history breaks `migrate deploy` and other environments.
- To fix a bad migration, add a **new** migration that corrects the schema. See [docs/backend-roadmap.md](docs/backend-roadmap.md) §8 for the full policy.
- **Never edit** a migration that has already been applied to staging,
production, or any shared database. Add a **new** migration that
corrects the schema instead. Full policy:
[docs/guides/backend-roadmap.md](docs/guides/backend-roadmap.md) §8.
### API routes (overview)
### API routes
| Method | Path | Purpose |
| ---------- | ------------------------------ | --------------------------------------------- |
| GET | `/api/health` | Liveness / DB check |
| GET | `/api/auth/session` | Current user or null |
| POST | `/api/auth/magic-link/request` | Send sign-in link email |
| GET | `/api/auth/magic-link/verify` | Validate token, set session cookie, redirect |
| POST | `/api/auth/logout` | Clear session |
| GET / PUT | `/api/drafts/me` | Load or save create-flow JSON (authenticated) |
| GET / POST | `/api/rules` | List or publish rules (each **Finalize** creates a new published row until an update/edit-published API exists) |
| GET | `/api/templates` | List curated templates |
| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/api/health` | Liveness / DB check. |
| GET | `/api/auth/session` | Current user or null. |
| POST | `/api/auth/magic-link/request` | Send sign-in link email. |
| GET | `/api/auth/magic-link/verify` | Validate token, set cookie, redirect. |
| POST | `/api/auth/logout` | Clear session. |
| GET / PUT | `/api/drafts/me` | Load or save the create-flow draft. |
| GET / POST | `/api/rules` | List or publish rules. |
| GET | `/api/templates` | List curated templates. |
### Email magic link (sign-in)
### Magic-link sign-in
- Open **[http://localhost:3000/login](http://localhost:3000/login)** or use **Log in** in the site header (modal or full page).
- Enter email and request a link. Complete sign-in by opening the link in the **same browser** you use for the app (session cookie).
- **No `SMTP_URL`:** the full **`GET /api/auth/magic-link/verify?...`** URL is printed in the **dev server terminal** — paste it into the browser address bar.
- **Mailhog:** with Compose Mailhog running, set `SMTP_URL=smtp://localhost:1025` and open the link from the message in the Mailhog UI ([http://localhost:8025](http://localhost:8025)).
**Staging / production:** Sign-in links use the apps origin. Ensure your reverse proxy sets **`Host`** (and TLS) so links in email match the URL users open. See [docs/backend-roadmap.md](docs/backend-roadmap.md) §9.
- Visit **[/login](http://localhost:3000/login)** or use **Log in** in the
site header.
- Without `SMTP_URL`: copy the verify URL from the dev server terminal.
- With Mailhog: set `SMTP_URL=smtp://localhost:1025` and open the message
at [http://localhost:8025](http://localhost:8025).
- Open the link in the **same browser** as the app (session cookie).
### Optional draft sync
Set `NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` in `.env` so **signed-in** users can persist create-flow drafts to Postgres via **Save & Exit** and so **anonymous** progress can be **uploaded after magic-link sign-in** from the save-progress exit modal. Without it, server **PUT** `/api/drafts/me` is skipped; anonymous work stays in **browser `localStorage`**, but after sign-in with a `?syncDraft=1` return URL the app still **merges that local draft into the in-memory create flow** (no server write) so you can continue and publish.
`NEXT_PUBLIC_ENABLE_BACKEND_SYNC=true` enables Postgres draft persistence
via `PUT /api/drafts/me` for signed-in users and post-sign-in upload of
anonymous drafts. Without it, anonymous progress stays in `localStorage`
and signed-in progress stays in memory until **Save & Exit**.
### Create flow URLs (custom wizard)
### Create flow
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).
The custom wizard lives under `/create/…`. Step order, URLs, and Figma
stage mapping are canon in [docs/create-flow.md](docs/create-flow.md).
Engineering tracking: Linear **CR-89** /
[docs/guides/backend-linear-tickets.md](docs/guides/backend-linear-tickets.md)
Ticket 17.
## Frontend / tests
## Frontend & tests
See [docs/TESTING_GUIDE.md](docs/TESTING_GUIDE.md) and the root [README.md](README.md).
- Code conventions are enforced by `.cursor/rules/*.mdc` — Cursor surfaces
the relevant rule when editing matching files.
- See [docs/testing-guide.md](docs/testing-guide.md) for testing
philosophy and `.cursor/rules/testing.mdc` for layout/helpers.
## Pull request workflow
1. Branch from `main`: `git checkout -b feature/<short-name>`.
2. Make the change and add/update tests.
3. `npm test && npm run e2e` (and `npm run storybook:build` if you touched
stories).
4. Commit using a clear message (`feat:`, `fix:`, `chore:`, …).
5. Open a PR; CI runs unit, E2E, visual regression, and Lighthouse.
+42 -230
View File
@@ -1,252 +1,64 @@
# Community Rule
A Next.js application for community decision-making and governance documentation.
A Next.js application for community decision-making and governance
documentation.
## 📋 Requirements
## Requirements
- **Node.js**: 20.0.0 or higher (LTS recommended)
- **npm**: 10.0.0 or higher
- Node.js **20+** (LTS)
- npm **10+**
## 🚀 Getting Started
Run the development server:
## Getting started
```bash
npm ci
npm run dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
Open [http://localhost:3000](http://localhost:3000).
Backend (Postgres, Prisma, API routes) setup is documented in [CONTRIBUTING.md](CONTRIBUTING.md).
Backend setup (Postgres, Prisma, magic-link auth) is documented in
[CONTRIBUTING.md](CONTRIBUTING.md).
## 🧪 Testing Framework
## Common scripts
This project uses a simplified, componentfirst testing model:
| Command | What it does |
| --- | --- |
| `npm run dev` | Next.js dev server (Turbopack). |
| `npm run build` / `npm start` | Production build / serve. |
| `npm test` | Vitest unit + component tests with coverage. |
| `npm run test:component` | Faster inner loop — components only. |
| `npm run e2e` | Playwright E2E + visual regression. |
| `npm run storybook` | Storybook on port 6006. |
| `npm run lhci` | Lighthouse CI performance pass. |
- **Component tests (Vitest + RTL)** live in `tests/components/` with a single file per component.
- **E2E tests (Playwright)** cover critical user journeys and visual regression.
## Project layout
### Quick Test Commands
```bash
# All component tests with coverage
npm test
# Component tests only (new structure)
npm run test:component
# E2E tests only
npm run test:e2e
```text
app/ Next.js app router (routes, components, hooks, contexts)
lib/ Shared library code (i18n, validation, utilities)
messages/en/ Localized UI copy (see docs/guides/i18n-translation-workflow.md)
prisma/ Database schema, migrations, seed
public/ Static assets
stories/ Storybook stories
tests/ Vitest + Playwright suites
docs/ User-facing documentation (start with docs/README.md)
.cursor/rules/ Implementation conventions enforced by Cursor
```
### Test Coverage
## Tech stack
-**428 Unit Tests** (94.88% coverage - exceeds 85% target)
-**92 E2E Tests** across 4 browsers
-**23 Visual Regression Tests** per browser
-**Performance Budgets** with Lighthouse CI
-**WCAG 2.1 AA Compliance** with automated testing
-**Bundle Analysis** with automated monitoring
-**Web Vitals Tracking** with real-time metrics
Next.js 16 · React 19 · TypeScript · Tailwind CSS 4 · Prisma · Vitest ·
Playwright · Storybook 10 · Lighthouse CI.
### CI/CD Pipeline
## Documentation
- **Gitea Actions** with 7 parallel jobs
- **Cross-browser testing** (Chromium, Firefox, WebKit, Mobile)
- **Visual regression testing**
- **Performance monitoring**
- **Code coverage reporting**
- [docs/README.md](docs/README.md) — index of guides and rules.
- [docs/create-flow.md](docs/create-flow.md) — create-rule wizard canon.
- [docs/testing-guide.md](docs/testing-guide.md) — testing philosophy.
- [CONTRIBUTING.md](CONTRIBUTING.md) — local backend, API routes, PR
workflow.
📖 **For detailed testing documentation, see `docs/TESTING_GUIDE.md` and [docs/README.md](docs/README.md)**
## License
## ⚡ Performance Optimizations
This project includes comprehensive performance optimizations for sub-2-second load times:
### Frontend Optimizations
- **✅ Code Splitting**: Dynamic imports for non-critical components
- **✅ React.memo**: Applied to all 30+ components to prevent unnecessary re-renders
- **✅ Image Optimization**: Enhanced `next/image` with lazy loading and blur placeholders
- **✅ Font Optimization**: Preloading and fallbacks for all fonts
- **✅ Bundle Analysis**: Real-time monitoring with performance budgets
- **✅ Error Boundaries**: Comprehensive error handling
### Performance Monitoring
Performance testing is handled by:
- **Lighthouse CI** (`.lighthouserc.json`): Comprehensive performance testing in CI
```bash
npm run lhci # Run Lighthouse CI
npm run lhci:mobile # Mobile preset
npm run lhci:desktop # Desktop preset
npm run performance:budget # With performance budgets
```
- **E2E Performance Tests** (`tests/e2e/performance.spec.ts`): Essential performance checks
```bash
npm run e2e:performance # Run E2E performance tests
```
- **Bundle Analysis**: Analyze bundle sizes
```bash
npm run bundle:analyze # Analyze bundle sizes
```
### Performance Targets
- **Bundle Size**: <250KB gzipped (currently 101KB) ✅
- **Core Web Vitals**: All metrics in "Good" range ✅
- **Lighthouse Score**: >90 on all critical pages ✅
- **Load Time**: <2 seconds on 3G connections ✅
## 📚 Storybook Development
This project includes Storybook for component development and documentation. The setup automatically detects the environment and applies the appropriate configuration.
### Local Development
For local Storybook development:
```bash
npm run storybook:local
# or simply
npm run storybook
```
This will:
- Start Storybook at `http://localhost:6006`
- Use relative paths for assets (no base path)
### GitHub Pages Deployment
For GitHub Pages deployment with base path:
```bash
npm run storybook:build:github
```
This will:
- Build Storybook with `/communityrulestorybook/` base path
- Generate files ready for GitHub Pages deployment
### CI/CD Integration
The CI pipeline automatically uses the GitHub Pages configuration when building Storybook.
### Configuration
The Storybook configuration automatically detects the environment:
- **Local development**: No base path, relative assets
- **CI/Production**: Base path `/communityrulestorybook/` for GitHub Pages
## 📋 Available Scripts
### Development
- `npm run dev` - Start Next.js development server
- `npm run build` - Build Next.js application for production
- `npm run start` - Start Next.js production server
### Testing
- `npm test` - Run all component tests with coverage
- `npm run test:component` - Run tests in `tests/components/` only
- `npm run test:watch` - Run tests in watch mode
- `npm run test:ui` - Run tests with UI
- `npm run test:e2e` - Run E2E tests only
- `npm run e2e` - Alias for Playwright E2E tests
- `npm run e2e:ui` - Run E2E tests with UI
- `npm run e2e:serve` - Start dev server and run E2E tests
- `npm run lhci` - Run performance tests
### Storybook
- `npm run storybook:local` - Start Storybook for local development
- `npm run storybook:github` - Start Storybook with GitHub Pages configuration
- `npm run storybook:build` - Build Storybook for local deployment
- `npm run storybook:build:github` - Build Storybook for GitHub Pages
- `npm run storybook` - Start Storybook with current configuration
## 🏗️ Project Structure
```
community-rule/
├── app/ # Next.js app directory
│ ├── components/ # React components
│ ├── hooks/ # Custom React hooks
│ ├── layout.tsx # Root layout
│ └── page.tsx # Homepage
├── config/ # Project-specific configuration
│ ├── gitea-runner.yaml # Gitea runner configuration
│ └── runner-config.yaml # Runner configuration
├── docs/ # Documentation
│ ├── README.md # Documentation index
│ ├── TESTING_GUIDE.md # Testing guide
│ ├── CUSTOM_HOOKS.md # Custom hooks documentation
│ └── guides/ # Guides
│ └── content-creation.md # Content creation guide
├── scripts/ # Utility scripts
│ ├── start-runner.sh # Start Gitea runner
│ ├── status-runner.sh # Check runner status
│ └── stop-runner.sh # Stop Gitea runner
├── tests/ # Test files
│ ├── components/ # Component tests (Vitest + RTL)
│ ├── pages/ # Page-level tests
│ ├── e2e/ # E2E tests (Playwright)
│ ├── utils/ # Test utilities (componentTestSuite, etc.)
│ ├── msw/ # MSW server setup
│ └── accessibility/ # E2E accessibility checks
├── .storybook/ # Storybook configuration
├── .gitea/ # Gitea Actions workflows
│ └── workflows/
│ └── ci.yaml # CI/CD pipeline
└── public/ # Static assets
```
## 🔧 Technology Stack
- **Framework**: Next.js 16 + React 19
- **Runtime**: Node.js 20+ (LTS)
- **Styling**: Tailwind CSS 4
- **Testing**: Vitest + Playwright + Lighthouse CI
- **Documentation**: Storybook 10
- **CI/CD**: Gitea Actions
- **Hosting**: Gitea (Git hosting)
## 📖 Documentation
- **[Documentation Index](docs/README.md)** - Complete documentation guide
- **[Testing Guide](docs/TESTING_GUIDE.md)** - Testing strategy, component tests, E2E tests, and accessibility
- **[Custom Hooks](docs/CUSTOM_HOOKS.md)** - Documentation for custom React hooks
- **[Content Creation Guide](docs/guides/content-creation.md)** - Guide for creating blog content
- **[Storybook](http://localhost:6006)** - Component documentation (local)
## 🤝 Contributing
1. **Fork the repository**
2. **Create a feature branch**: `git checkout -b feature/amazing-feature`
3. **Write tests first** (see [Testing Guide](docs/TESTING_GUIDE.md))
4. **Make your changes**
5. **Run tests**: `npm test && npm run e2e`
6. **Commit changes**: `git commit -m "feat: add amazing feature"`
7. **Push to branch**: `git push origin feature/amazing-feature`
8. **Create Pull Request**
### Development Workflow
- All changes must have tests
- CI pipeline runs automatically on PRs
- Visual regression tests ensure UI consistency
- Performance budgets must be met
- Accessibility standards must be maintained
## 📄 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
[MIT](LICENSE).
+7
View File
@@ -0,0 +1,7 @@
import type { ReactNode } from "react";
// Operator/admin dashboards (e.g. `/monitor`) intentionally render without the
// public marketing footer. Auth/access is enforced upstream.
export default function AdminLayout({ children }: { children: ReactNode }) {
return <main className="flex-1">{children}</main>;
}
+1 -1
View File
@@ -1,4 +1,4 @@
import WebVitalsDashboard from "../../components/WebVitalsDashboard";
import WebVitalsDashboard from "../../components/sections/WebVitalsDashboard";
import TopNav from "../../components/navigation/TopNav";
import Footer from "../../components/navigation/Footer";
@@ -11,35 +11,35 @@ import { usePathname, useRouter } from "next/navigation";
import { CreateFlowProvider, useCreateFlow } from "./context/CreateFlowContext";
import { useCreateFlowNavigation } from "./hooks/useCreateFlowNavigation";
import { useCreateFlowExit } from "./hooks/useCreateFlowExit";
import CreateFlowTopNav from "../components/utility/CreateFlowTopNav";
import CreateFlowTopNav from "../../components/utility/CreateFlowTopNav";
import { getNextStep, getStepIndex } from "./utils/flowSteps";
import { getProportionBarProgressForCreateFlowStep } from "./utils/createFlowProportionProgress";
import {
createFlowStepUsesCenteredTextLayout,
createFlowStepUsesCardLayout,
} from "./utils/createFlowScreenRegistry";
import CreateFlowFooter from "../components/utility/CreateFlowFooter";
import Button from "../components/buttons/Button";
import { buildPublishPayload } from "../../lib/create/buildPublishPayload";
import { isValidCreateFlowSaveEmail } from "../../lib/create/isValidCreateFlowSaveEmail";
import CreateFlowFooter from "../../components/utility/CreateFlowFooter";
import Button from "../../components/buttons/Button";
import { buildPublishPayload } from "../../../lib/create/buildPublishPayload";
import { isValidCreateFlowSaveEmail } from "../../../lib/create/isValidCreateFlowSaveEmail";
import {
fetchAuthSession,
publishRule,
requestMagicLink,
} from "../../lib/create/api";
import { safeInternalPath } from "../../lib/safeInternalPath";
} from "../../../lib/create/api";
import { safeInternalPath } from "../../../lib/safeInternalPath";
import { setTransferPendingFlag } from "./utils/anonymousDraftStorage";
import { writeLastPublishedRule } from "../../lib/create/lastPublishedRule";
import { writeLastPublishedRule } from "../../../lib/create/lastPublishedRule";
import {
fetchTemplateBySlug,
type RuleTemplateDto,
} from "../../lib/create/fetchTemplates";
import messages from "../../messages/en/index";
import { useAuthModal } from "../contexts/AuthModalContext";
import { useMessages, useTranslation } from "../contexts/MessagesContext";
} from "../../../lib/create/fetchTemplates";
import messages from "../../../messages/en/index";
import { useAuthModal } from "../../contexts/AuthModalContext";
import { useMessages, useTranslation } from "../../contexts/MessagesContext";
import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
import { SignedInDraftHydration } from "./SignedInDraftHydration";
import Alert from "../components/modals/Alert";
import Alert from "../../components/modals/Alert";
import {
CreateFlowDraftSaveBannerProvider,
useCreateFlowDraftSaveBanner,
@@ -9,8 +9,8 @@ import {
} from "./utils/anonymousDraftStorage";
import { useCreateFlow } from "./context/CreateFlowContext";
import { parseCreateFlowScreenFromPathname } from "./utils/flowSteps";
import { saveDraftToServer } from "../../lib/create/api";
import messages from "../../messages/en/index";
import { saveDraftToServer } from "../../../lib/create/api";
import messages from "../../../messages/en/index";
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
@@ -3,15 +3,15 @@
import { useEffect, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import type { CreateFlowState } from "./types";
import { createFlowStateHasKeys } from "../../lib/create/draftHydrationUtils";
import { createFlowStateHasKeys } from "../../../lib/create/draftHydrationUtils";
import {
clearAnonymousCreateFlowStorage,
hasTransferPendingFlag,
readAnonymousCreateFlowState,
} from "./utils/anonymousDraftStorage";
import { useCreateFlow } from "./context/CreateFlowContext";
import { fetchDraftFromServer } from "../../lib/create/api";
import messages from "../../messages/en/index";
import { fetchDraftFromServer } from "../../../lib/create/api";
import messages from "../../../messages/en/index";
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
@@ -8,8 +8,8 @@
*/
import { memo, useState } from "react";
import Chip from "../../components/controls/Chip";
import InputLabel from "../../components/utility/InputLabel";
import Chip from "../../../components/controls/Chip";
import InputLabel from "../../../components/utility/InputLabel";
export interface ApplicableScopeFieldProps {
/** Label rendered above the capsule row. */
@@ -74,9 +74,9 @@ function ApplicableScopeFieldComponent({
<Chip
key={scope}
label={scope}
state={isSelected ? "Selected" : "Disabled"}
palette="Default"
size="S"
state={isSelected ? "selected" : "disabled"}
palette="default"
size="s"
disabled={false}
onClick={() => onToggleScope(scope)}
ariaLabel={`${isSelected ? "Deselect" : "Select"} ${scope}`}
@@ -1,7 +1,7 @@
"use client";
import HeaderLockup from "../../components/type/HeaderLockup";
import type { HeaderLockupProps } from "../../components/type/HeaderLockup/HeaderLockup.types";
import HeaderLockup from "../../../components/type/HeaderLockup";
import type { HeaderLockupProps } from "../../../components/type/HeaderLockup/HeaderLockup.types";
import { useCreateFlowMdUp } from "../hooks/useCreateFlowMdUp";
export type CreateFlowHeaderLockupProps = Omit<HeaderLockupProps, "size"> & {
@@ -7,8 +7,8 @@
*/
import { memo, useId } from "react";
import TextArea from "../../components/controls/TextArea";
import InputLabel from "../../components/utility/InputLabel";
import TextArea from "../../../components/controls/TextArea";
import InputLabel from "../../../components/utility/InputLabel";
export interface ModalTextAreaFieldProps {
/** Label rendered above the text area. */
@@ -2,8 +2,8 @@
import { useCallback } from "react";
import type { CreateFlowState, CreateFlowStep } from "../types";
import { saveDraftToServer } from "../../../lib/create/api";
import messages from "../../../messages/en/index";
import { saveDraftToServer } from "../../../../lib/create/api";
import messages from "../../../../messages/en/index";
const SYNC_ENABLED = process.env.NEXT_PUBLIC_ENABLE_BACKEND_SYNC === "true";
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useMediaQuery } from "../../hooks/useMediaQuery";
import { useMediaQuery } from "../../../hooks/useMediaQuery";
/** `--breakpoint-lg` (1024px); same SSR/first-paint pattern as `useCreateFlowMdUp`. */
const CREATE_FLOW_MIN_WIDTH_LG = "(min-width: 1024px)";
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useMediaQuery } from "../../hooks/useMediaQuery";
import { useMediaQuery } from "../../../hooks/useMediaQuery";
/** `--breakpoint-md` (640px); same SSR/first-paint pattern as `useCreateFlowLgUp`. */
const CREATE_FLOW_MIN_WIDTH_MD = "(min-width: 640px)";
@@ -1,15 +1,15 @@
"use client";
import { use, useEffect, useState } from "react";
import { TemplateReviewCard } from "../../../components/cards/TemplateReviewCard";
import { useTranslation } from "../../../contexts/MessagesContext";
import { TemplateReviewCard } from "../../../../components/cards/TemplateReviewCard";
import { useTranslation } from "../../../../contexts/MessagesContext";
import {
fetchTemplateBySlug,
isTemplatesFetchAborted,
type RuleTemplateDto,
} from "../../../../lib/create/fetchTemplates";
import messages from "../../../../messages/en/index";
import Alert from "../../../components/modals/Alert";
} from "../../../../../lib/create/fetchTemplates";
import messages from "../../../../../messages/en/index";
import Alert from "../../../../components/modals/Alert";
import {
CREATE_FLOW_REVIEW_RULE_CARD_LAYOUT_CLASS,
CreateFlowLockupCardStepShell,
@@ -10,13 +10,13 @@
*/
import { useState, useCallback, useMemo } from "react";
import { useMessages } from "../../../contexts/MessagesContext";
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 InlineTextButton from "../../../components/buttons/InlineTextButton";
import CardStack from "../../../../components/utility/CardStack";
import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
@@ -12,13 +12,13 @@
*/
import { useState, useCallback, useMemo } from "react";
import { useMessages } from "../../../contexts/MessagesContext";
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 InlineTextButton from "../../../components/buttons/InlineTextButton";
import CardStack from "../../../../components/utility/CardStack";
import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
@@ -12,13 +12,13 @@
*/
import { useState, useCallback, useMemo } from "react";
import { useMessages } from "../../../contexts/MessagesContext";
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 InlineTextButton from "../../../components/buttons/InlineTextButton";
import CardStack from "../../../../components/utility/CardStack";
import Create from "../../../../components/modals/Create";
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
import {
CREATE_FLOW_CARD_STACK_AREA_MAX_CLASS,
@@ -1,12 +1,12 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import CommunityRuleDocument from "../../../components/sections/CommunityRuleDocument";
import type { CommunityRuleDocumentSection } from "../../../components/sections/CommunityRuleDocument/CommunityRuleDocument.types";
import Alert from "../../../components/modals/Alert";
import { useMessages } from "../../../contexts/MessagesContext";
import { parseDocumentSectionsForDisplay } from "../../../../lib/create/buildPublishPayload";
import { readLastPublishedRule } from "../../../../lib/create/lastPublishedRule";
import CommunityRuleDocument from "../../../../components/sections/CommunityRuleDocument";
import type { CommunityRuleDocumentSection } from "../../../../components/sections/CommunityRuleDocument/CommunityRuleDocument.types";
import Alert from "../../../../components/modals/Alert";
import { useMessages } from "../../../../contexts/MessagesContext";
import { parseDocumentSectionsForDisplay } from "../../../../../lib/create/buildPublishPayload";
import { readLastPublishedRule } from "../../../../../lib/create/lastPublishedRule";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import {
@@ -1,8 +1,8 @@
"use client";
import type { ReactNode } from "react";
import NumberedList from "../../../components/type/NumberedList";
import { useMessages } from "../../../contexts/MessagesContext";
import NumberedList from "../../../../components/type/NumberedList";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
@@ -1,7 +1,7 @@
"use client";
import RuleCard from "../../../components/cards/RuleCard";
import { useTranslation } from "../../../contexts/MessagesContext";
import RuleCard from "../../../../components/cards/RuleCard";
import { useTranslation } from "../../../../contexts/MessagesContext";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowLgUp } from "../../hooks/useCreateFlowLgUp";
@@ -1,9 +1,9 @@
"use client";
import { useMemo } from "react";
import RuleCard from "../../../components/cards/RuleCard";
import type { Category } from "../../../components/cards/RuleCard/RuleCard.types";
import { useMessages, useTranslation } from "../../../contexts/MessagesContext";
import RuleCard from "../../../../components/cards/RuleCard";
import type { Category } from "../../../../components/cards/RuleCard/RuleCard.types";
import { useMessages, useTranslation } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import {
@@ -14,14 +14,14 @@
*/
import { useState, useCallback, useMemo } from "react";
import DecisionMakingSidebar from "../../../components/utility/DecisionMakingSidebar";
import CardStack from "../../../components/utility/CardStack";
import Create from "../../../components/modals/Create";
import IncrementerBlock from "../../../components/controls/IncrementerBlock";
import InlineTextButton from "../../../components/buttons/InlineTextButton";
import type { InfoMessageBoxItem } from "../../../components/utility/InfoMessageBox/InfoMessageBox.types";
import type { CardStackItem } from "../../../components/utility/CardStack/CardStack.types";
import { useMessages } from "../../../contexts/MessagesContext";
import DecisionMakingSidebar from "../../../../components/utility/DecisionMakingSidebar";
import CardStack from "../../../../components/utility/CardStack";
import Create from "../../../../components/modals/Create";
import IncrementerBlock from "../../../../components/controls/IncrementerBlock";
import InlineTextButton from "../../../../components/buttons/InlineTextButton";
import type { InfoMessageBoxItem } from "../../../../components/utility/InfoMessageBox/InfoMessageBox.types";
import type { CardStackItem } from "../../../../components/utility/CardStack/CardStack.types";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
@@ -1,9 +1,9 @@
"use client";
import { useState, useEffect } from "react";
import MultiSelect from "../../../components/controls/MultiSelect";
import type { ChipOption } from "../../../components/controls/MultiSelect/MultiSelect.types";
import { useMessages } from "../../../contexts/MessagesContext";
import MultiSelect from "../../../../components/controls/MultiSelect";
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoColumnSelectShell";
@@ -14,13 +14,13 @@ function chipRowsFromLabels(
return rows.map((row, i) => ({
id: String(i + 1),
label: row.label,
state: "Unselected" as const,
state: "unselected" as const,
}));
}
function selectedIdsFromOptions(options: ChipOption[]): string[] {
return options
.filter((o) => o.state === "Selected")
.filter((o) => o.state === "selected")
.map((o) => o.id);
}
@@ -37,7 +37,7 @@ export function CommunitySizeSelectScreen() {
const selected = new Set(state.selectedCommunitySizeIds ?? []);
return base.map((opt) => ({
...opt,
state: selected.has(opt.id) ? ("Selected" as const) : ("Unselected" as const),
state: selected.has(opt.id) ? ("selected" as const) : ("unselected" as const),
}));
});
@@ -45,13 +45,13 @@ export function CommunitySizeSelectScreen() {
const selected = new Set(state.selectedCommunitySizeIds ?? []);
setCommunitySizeOptions((prev) =>
prev.map((opt) =>
opt.state === "Custom"
opt.state === "custom"
? opt
: {
...opt,
state: selected.has(opt.id)
? ("Selected" as const)
: ("Unselected" as const),
? ("selected" as const)
: ("unselected" as const),
},
),
);
@@ -71,9 +71,9 @@ export function CommunitySizeSelectScreen() {
? {
...opt,
state:
opt.state === "Selected"
? ("Unselected" as const)
: ("Selected" as const),
opt.state === "selected"
? ("unselected" as const)
: ("selected" as const),
}
: opt,
);
@@ -83,7 +83,7 @@ export function CommunitySizeSelectScreen() {
const multiSelectBlock = (
<MultiSelect
formHeader={false}
size="M"
size="m"
options={communitySizeOptions}
onChipClick={handleCommunitySizeClick}
addButton={false}
@@ -7,9 +7,9 @@ import {
type Dispatch,
type SetStateAction,
} from "react";
import MultiSelect from "../../../components/controls/MultiSelect";
import type { ChipOption } from "../../../components/controls/MultiSelect/MultiSelect.types";
import { useMessages } from "../../../contexts/MessagesContext";
import MultiSelect from "../../../../components/controls/MultiSelect";
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import type { CommunityStructureChipSnapshotRow } from "../../types";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
@@ -17,7 +17,7 @@ import { CreateFlowTwoColumnSelectShell } from "../../components/CreateFlowTwoCo
function createListCustomHandlers(
setList: Dispatch<SetStateAction<ChipOption[]>>,
confirmState: "Unselected" | "Selected",
confirmState: "unselected" | "selected",
onInteraction?: () => void,
) {
const touch = () => onInteraction?.();
@@ -26,7 +26,7 @@ function createListCustomHandlers(
touch();
setList((prev) => [
...prev,
{ id: crypto.randomUUID(), label: "", state: "Custom" },
{ id: crypto.randomUUID(), label: "", state: "custom" },
]);
},
onCustomChipConfirm: (chipId: string, value: string) => {
@@ -52,7 +52,7 @@ function chipRowsFromLabels(
return rows.map((row, i) => ({
id: String(i + 1),
label: row.label,
state: "Unselected" as const,
state: "unselected" as const,
}));
}
@@ -62,20 +62,20 @@ function applySavedSelection(
): ChipOption[] {
const selected = new Set(saved ?? []);
return options.map((opt) =>
opt.state === "Custom"
opt.state === "custom"
? opt
: {
...opt,
state: selected.has(opt.id)
? ("Selected" as const)
: ("Unselected" as const),
? ("selected" as const)
: ("unselected" as const),
},
);
}
function selectedIdsFromOptions(options: ChipOption[]): string[] {
return options
.filter((o) => o.state === "Selected")
.filter((o) => o.state === "selected")
.map((o) => o.id);
}
@@ -194,7 +194,7 @@ export function CommunityStructureSelectScreen() {
() =>
createListCustomHandlers(
setOrganizationTypeOptions,
"Unselected",
"unselected",
markCreateFlowInteraction,
),
[markCreateFlowInteraction],
@@ -203,7 +203,7 @@ export function CommunityStructureSelectScreen() {
() =>
createListCustomHandlers(
setScaleOptions,
"Unselected",
"unselected",
markCreateFlowInteraction,
),
[markCreateFlowInteraction],
@@ -212,7 +212,7 @@ export function CommunityStructureSelectScreen() {
() =>
createListCustomHandlers(
setMaturityOptions,
"Unselected",
"unselected",
markCreateFlowInteraction,
),
[markCreateFlowInteraction],
@@ -258,9 +258,9 @@ export function CommunityStructureSelectScreen() {
? {
...opt,
state:
opt.state === "Selected"
? ("Unselected" as const)
: ("Selected" as const),
opt.state === "selected"
? ("unselected" as const)
: ("selected" as const),
}
: opt,
),
@@ -274,9 +274,9 @@ export function CommunityStructureSelectScreen() {
? {
...opt,
state:
opt.state === "Selected"
? ("Unselected" as const)
: ("Selected" as const),
opt.state === "selected"
? ("unselected" as const)
: ("selected" as const),
}
: opt,
),
@@ -290,9 +290,9 @@ export function CommunityStructureSelectScreen() {
? {
...opt,
state:
opt.state === "Selected"
? ("Unselected" as const)
: ("Selected" as const),
opt.state === "selected"
? ("unselected" as const)
: ("selected" as const),
}
: opt,
),
@@ -304,7 +304,7 @@ export function CommunityStructureSelectScreen() {
<MultiSelect
label={cs.organizationMultiSelect.label}
showHelpIcon
size="S"
size="s"
options={organizationTypeOptions}
onChipClick={handleOrganizationTypeClick}
{...organizationCustomHandlers}
@@ -314,7 +314,7 @@ export function CommunityStructureSelectScreen() {
<MultiSelect
label={cs.scaleMultiSelect.label}
showHelpIcon
size="S"
size="s"
options={scaleOptions}
onChipClick={handleScaleClick}
{...scaleCustomHandlers}
@@ -324,7 +324,7 @@ export function CommunityStructureSelectScreen() {
<MultiSelect
label={cs.maturityMultiSelect.label}
showHelpIcon
size="S"
size="s"
options={maturityOptions}
onChipClick={handleMaturityClick}
{...maturityCustomHandlers}
@@ -1,10 +1,10 @@
"use client";
import { useState } from "react";
import MultiSelect from "../../../components/controls/MultiSelect";
import Alert from "../../../components/modals/Alert";
import type { ChipOption } from "../../../components/controls/MultiSelect/MultiSelect.types";
import { useTranslation } from "../../../contexts/MessagesContext";
import MultiSelect from "../../../../components/controls/MultiSelect";
import Alert from "../../../../components/modals/Alert";
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
import { useTranslation } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
@@ -22,7 +22,7 @@ export function ConfirmStakeholdersScreen() {
markCreateFlowInteraction();
setStakeholderOptions((prev) => [
...prev,
{ id: crypto.randomUUID(), label: "", state: "Custom" },
{ id: crypto.randomUUID(), label: "", state: "custom" },
]);
};
@@ -30,7 +30,7 @@ export function ConfirmStakeholdersScreen() {
markCreateFlowInteraction();
setStakeholderOptions((prev) =>
prev.map((opt) =>
opt.id === chipId ? { ...opt, label: value, state: "Selected" } : opt,
opt.id === chipId ? { ...opt, label: value, state: "selected" } : opt,
),
);
};
@@ -64,7 +64,7 @@ export function ConfirmStakeholdersScreen() {
<MultiSelect
formHeader={false}
showHelpIcon={false}
size="S"
size="s"
options={stakeholderOptions}
onChipClick={handleChipClick}
onAddClick={handleAddStakeholder}
@@ -1,12 +1,12 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import MultiSelect from "../../../components/controls/MultiSelect";
import type { ChipOption } from "../../../components/controls/MultiSelect/MultiSelect.types";
import TextArea from "../../../components/controls/TextArea";
import Create from "../../../components/modals/Create";
import ContentLockup from "../../../components/type/ContentLockup";
import { useMessages } from "../../../contexts/MessagesContext";
import MultiSelect from "../../../../components/controls/MultiSelect";
import type { ChipOption } from "../../../../components/controls/MultiSelect/MultiSelect.types";
import TextArea from "../../../../components/controls/TextArea";
import Create from "../../../../components/modals/Create";
import ContentLockup from "../../../../components/type/ContentLockup";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import type { CommunityStructureChipSnapshotRow } from "../../types";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
@@ -46,7 +46,7 @@ function chipRowsFromPresets(presets: readonly CoreValuePreset[]): ChipOption[]
return presets.map((row, i) => ({
id: String(i + 1),
label: row.label,
state: "Unselected" as const,
state: "unselected" as const,
}));
}
@@ -56,20 +56,20 @@ function applySavedSelection(
): ChipOption[] {
const selected = new Set(saved ?? []);
return options.map((opt) =>
opt.state === "Custom"
opt.state === "custom"
? opt
: {
...opt,
state: selected.has(opt.id)
? ("Selected" as const)
: ("Unselected" as const),
? ("selected" as const)
: ("unselected" as const),
},
);
}
function selectedIdsFromOptions(options: ChipOption[]): string[] {
return options
.filter((o) => o.state === "Selected")
.filter((o) => o.state === "selected")
.map((o) => o.id);
}
@@ -194,7 +194,7 @@ export function CoreValuesSelectScreen() {
if (activeModalChipId && modalSession === "pending") {
const next = coreValueOptions.map((opt) =>
opt.id === activeModalChipId
? { ...opt, state: "Unselected" as const }
? { ...opt, state: "unselected" as const }
: opt,
);
persistCoreValues(next);
@@ -226,16 +226,16 @@ export function CoreValuesSelectScreen() {
const handleChipClick = (chipId: string) => {
const target = coreValueOptions.find((o) => o.id === chipId);
if (!target || target.state === "Custom") return;
if (!target || target.state === "custom") return;
const selectedCount = coreValueOptions.filter(
(o) => o.state === "Selected",
(o) => o.state === "selected",
).length;
if (target.state === "Selected") {
if (target.state === "selected") {
const next: ChipOption[] = coreValueOptions.map((opt) =>
opt.id === chipId
? { ...opt, state: "Unselected" as const }
? { ...opt, state: "unselected" as const }
: opt,
);
persistCoreValues(next);
@@ -246,7 +246,7 @@ export function CoreValuesSelectScreen() {
const next: ChipOption[] = coreValueOptions.map((opt) =>
opt.id === chipId
? { ...opt, state: "Selected" as const }
? { ...opt, state: "selected" as const }
: opt,
);
persistCoreValues(next);
@@ -259,7 +259,7 @@ export function CoreValuesSelectScreen() {
setCoreValueOptions((prev) => {
const next: ChipOption[] = [
...prev,
{ id: crypto.randomUUID(), label: "", state: "Custom" },
{ id: crypto.randomUUID(), label: "", state: "custom" },
];
queueMicrotask(() => syncCoreValuesToDraft(next));
return next;
@@ -270,17 +270,17 @@ export function CoreValuesSelectScreen() {
setCoreValueOptions((prev) => {
const withLabel = prev.map((opt) =>
opt.id === chipId
? { ...opt, label: value, state: "Unselected" as const }
? { ...opt, label: value, state: "unselected" as const }
: opt,
);
const selectedCount = withLabel.filter(
(o) => o.state === "Selected",
(o) => o.state === "selected",
).length;
const canSelect = selectedCount < MAX_CORE_VALUES;
const next = canSelect
? withLabel.map((opt) =>
opt.id === chipId
? { ...opt, state: "Selected" as const }
? { ...opt, state: "selected" as const }
: opt,
)
: withLabel;
@@ -343,7 +343,7 @@ export function CoreValuesSelectScreen() {
>
<MultiSelect
formHeader={false}
size="M"
size="m"
options={coreValueOptions}
onChipClick={handleChipClick}
onAddClick={addHandlers.onAddClick}
@@ -1,9 +1,9 @@
"use client";
import { useState, useEffect, type HTMLInputTypeAttribute } from "react";
import TextInput from "../../../components/controls/TextInput";
import type { HeaderLockupJustificationValue } from "../../../components/type/HeaderLockup/HeaderLockup.types";
import { useTranslation } from "../../../contexts/MessagesContext";
import TextInput from "../../../../components/controls/TextInput";
import type { HeaderLockupJustificationValue } from "../../../../components/type/HeaderLockup/HeaderLockup.types";
import { useTranslation } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { useCreateFlowMdUp } from "../../hooks/useCreateFlowMdUp";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
@@ -1,7 +1,7 @@
"use client";
import Upload from "../../../components/controls/Upload";
import { useMessages } from "../../../contexts/MessagesContext";
import Upload from "../../../../components/controls/Upload";
import { useMessages } from "../../../../contexts/MessagesContext";
import { useCreateFlow } from "../../context/CreateFlowContext";
import { CreateFlowHeaderLockup } from "../../components/CreateFlowHeaderLockup";
import { CreateFlowStepShell } from "../../components/CreateFlowStepShell";
@@ -1,5 +1,5 @@
import type { CreateFlowState } from "../types";
import { migrateLegacyCreateFlowState } from "../../../lib/create/migrateLegacyCreateFlowState";
import { migrateLegacyCreateFlowState } from "../../../../lib/create/migrateLegacyCreateFlowState";
/** Anonymous in-progress create flow (local only until magic-link transfer). */
export const CREATE_FLOW_ANONYMOUS_KEY = "create-flow-anonymous" as const;
@@ -75,15 +75,6 @@ export function hasTransferPendingFlag(): boolean {
}
}
export function clearTransferPendingFlag(): void {
if (typeof window === "undefined") return;
try {
window.localStorage.removeItem(CREATE_FLOW_TRANSFER_PENDING_KEY);
} catch {
// ignore
}
}
/** One-time cleanup of preanonymous-draft keys. */
export function clearLegacyCreateFlowKeysOnce(): void {
if (typeof window === "undefined") return;
@@ -1,4 +1,4 @@
import type { ProportionBarState } from "../../components/progress/ProportionBar/ProportionBar.types";
import type { ProportionBarState } from "../../../components/progress/ProportionBar/ProportionBar.types";
import type { CreateFlowStep } from "../types";
import { FLOW_STEP_ORDER, getStepIndex } from "./flowSteps";
@@ -2,10 +2,10 @@ import type { CreateFlowStep } from "../types";
/**
* Figma layout families for the create flow (not encoded in the URL).
* `app/create/screens/<kind>/` mirrors these names: e.g. `layoutKind: "select"` `screens/select/`,
* `app/(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 =
type CreateFlowLayoutKind =
| "informational"
| "text"
| "select"
@@ -15,7 +15,7 @@ export type CreateFlowLayoutKind =
| "right-rail"
| "completed";
export interface CreateFlowScreenDefinition {
interface CreateFlowScreenDefinition {
layoutKind: CreateFlowLayoutKind;
/** Figma node id (file Community-Rule-System), dev mode. */
figmaNodeId: string;
+8
View File
@@ -0,0 +1,8 @@
import type { ReactNode } from "react";
// Signed-in product surfaces (`/create/*`, `/login`, `/profile`) intentionally
// run without the marketing footer. Per-route chrome (e.g. CreateFlow's own
// header/footer lockup) is composed in nested layouts.
export default function AppLayout({ children }: { children: ReactNode }) {
return <main className="flex-1">{children}</main>;
}
@@ -3,18 +3,19 @@
import { Suspense } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslation } from "../contexts/MessagesContext";
import Login from "../components/modals/Login";
import LoginForm from "../components/modals/Login/LoginForm";
import { useTranslation } from "../../contexts/MessagesContext";
import Login from "../../components/modals/Login";
import LoginForm from "../../components/modals/Login/LoginForm";
const loginPageBgClass =
"min-h-[100dvh] bg-[var(--color-surface-inverse-brand-primary)]";
function LoginLoadingFallback() {
const t = useTranslation("pages.login");
return (
<div className={`${loginPageBgClass} flex items-center justify-center`}>
<p className="font-inter text-[14px] text-[var(--color-content-default-primary)]">
Loading
{t("loadingFallback")}
</p>
</div>
);
@@ -1,9 +1,9 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "../contexts/MessagesContext";
import Button from "../components/buttons/Button";
import { fetchAuthSession, logout } from "../../lib/create/api";
import { useTranslation } from "../../contexts/MessagesContext";
import Button from "../../components/buttons/Button";
import { fetchAuthSession, logout } from "../../../lib/create/api";
export default function ProfilePageClient() {
const t = useTranslation("pages.profile");
+103 -104
View File
@@ -14,27 +14,26 @@ let ruleCardIdCounter = 0;
interface ChipData {
id: string;
label: string;
state: "Unselected" | "Selected" | "Custom";
palette: "Default" | "Inverse";
size: "S" | "M";
state: "unselected" | "selected" | "custom";
palette: "default" | "inverse";
size: "s" | "m";
}
// MultiSelect example component with state management
function MultiSelectExample({ size }: { size: "S" | "M" }) {
function MultiSelectExample({ size }: { size: "s" | "m" }) {
const [options, setOptions] = useState<
Array<{
id: string;
label: string;
state: "Unselected" | "Selected" | "Custom";
state: "unselected" | "selected" | "custom";
}>
>([
{ id: "1", label: "1 member", state: "Unselected" },
{ id: "2", label: "2-10 members", state: "Unselected" },
{ id: "3", label: "10-24 members", state: "Unselected" },
{ id: "4", label: "24-64 members", state: "Unselected" },
{ id: "5", label: "64-128 members", state: "Unselected" },
{ id: "6", label: "125-1000 members", state: "Unselected" },
{ id: "7", label: "1000+ members", state: "Unselected" },
{ id: "1", label: "1 member", state: "unselected" },
{ id: "2", label: "2-10 members", state: "unselected" },
{ id: "3", label: "10-24 members", state: "unselected" },
{ id: "4", label: "24-64 members", state: "unselected" },
{ id: "5", label: "64-128 members", state: "unselected" },
{ id: "6", label: "125-1000 members", state: "unselected" },
{ id: "7", label: "1000+ members", state: "unselected" },
]);
const handleChipClick = (chipId: string) => {
@@ -43,7 +42,7 @@ function MultiSelectExample({ size }: { size: "S" | "M" }) {
opt.id === chipId
? {
...opt,
state: opt.state === "Selected" ? "Unselected" : "Selected",
state: opt.state === "selected" ? "unselected" : "selected",
}
: opt,
),
@@ -52,14 +51,14 @@ function MultiSelectExample({ size }: { size: "S" | "M" }) {
const handleAddClick = () => {
const newId = `custom-${Date.now()}`;
setOptions((prev) => [...prev, { id: newId, label: "", state: "Custom" }]);
setOptions((prev) => [...prev, { id: newId, label: "", state: "custom" }]);
};
const handleCustomConfirm = (chipId: string, value: string) => {
setOptions((prev) =>
prev.map((opt) =>
opt.id === chipId
? { ...opt, label: value, state: "Selected" as const }
? { ...opt, label: value, state: "selected" as const }
: opt,
),
);
@@ -72,7 +71,7 @@ function MultiSelectExample({ size }: { size: "S" | "M" }) {
return (
<div className="space-y-[var(--spacing-scale-016)]">
<h3 className="font-inter text-[20px] leading-[24px] font-semibold text-[var(--color-content-default-primary)]">
{size === "S" ? "Small (S)" : "Medium (M)"}
{size === "s" ? "Small (S)" : "Medium (M)"}
</h3>
<MultiSelect
label="Label"
@@ -91,12 +90,12 @@ function MultiSelectExample({ size }: { size: "S" | "M" }) {
export default function ComponentsPreview() {
const [chipStates, setChipStates] = useState<
Record<string, "Unselected" | "Selected">
Record<string, "unselected" | "selected">
>({
"default-s": "Unselected",
"default-m": "Unselected",
"inverse-s": "Unselected",
"inverse-m": "Unselected",
"default-s": "unselected",
"default-m": "unselected",
"inverse-s": "unselected",
"inverse-m": "unselected",
});
// Manage custom chips separately
@@ -104,16 +103,16 @@ export default function ComponentsPreview() {
{
id: "custom-1",
label: "",
state: "Custom",
palette: "Default",
size: "S",
state: "custom",
palette: "default",
size: "s",
},
{
id: "custom-2",
label: "",
state: "Custom",
palette: "Default",
size: "M",
state: "custom",
palette: "default",
size: "m",
},
]);
@@ -124,7 +123,7 @@ export default function ComponentsPreview() {
chipOptions: Array<{
id: string;
label: string;
state: "Unselected" | "Selected" | "Custom";
state: "unselected" | "selected" | "custom";
}>;
onChipClick?: (_categoryName: string, _chipId: string) => void;
onAddClick?: (_categoryName: string) => void;
@@ -139,11 +138,11 @@ export default function ComponentsPreview() {
{
name: "Values",
chipOptions: [
{ id: "values-1", label: "Consciousness", state: "Unselected" },
{ id: "values-2", label: "Ecology", state: "Unselected" },
{ id: "values-3", label: "Abundance", state: "Unselected" },
{ id: "values-4", label: "Art", state: "Unselected" },
{ id: "values-5", label: "Decisiveness", state: "Unselected" },
{ id: "values-1", label: "Consciousness", state: "unselected" },
{ id: "values-2", label: "Ecology", state: "unselected" },
{ id: "values-3", label: "Abundance", state: "unselected" },
{ id: "values-4", label: "Art", state: "unselected" },
{ id: "values-5", label: "Decisiveness", state: "unselected" },
],
onChipClick: (categoryName: string, chipId: string) => {
setRuleCardCategories((prev) =>
@@ -156,9 +155,9 @@ export default function ComponentsPreview() {
? {
...opt,
state:
opt.state === "Selected"
? "Unselected"
: "Selected",
opt.state === "selected"
? "unselected"
: "selected",
}
: opt,
),
@@ -176,7 +175,7 @@ export default function ComponentsPreview() {
...cat,
chipOptions: [
...cat.chipOptions,
{ id: newId, label: "", state: "Custom" },
{ id: newId, label: "", state: "custom" },
],
}
: cat,
@@ -195,7 +194,7 @@ export default function ComponentsPreview() {
...cat,
chipOptions: cat.chipOptions.map((opt) =>
opt.id === chipId
? { ...opt, label: value, state: "Selected" }
? { ...opt, label: value, state: "selected" }
: opt,
),
}
@@ -220,7 +219,7 @@ export default function ComponentsPreview() {
},
{
name: "Communication",
chipOptions: [{ id: "comm-1", label: "Signal", state: "Unselected" }],
chipOptions: [{ id: "comm-1", label: "Signal", state: "unselected" }],
onChipClick: (categoryName: string, chipId: string) => {
setRuleCardCategories((prev) =>
prev.map((cat) =>
@@ -232,9 +231,9 @@ export default function ComponentsPreview() {
? {
...opt,
state:
opt.state === "Selected"
? "Unselected"
: "Selected",
opt.state === "selected"
? "unselected"
: "selected",
}
: opt,
),
@@ -252,7 +251,7 @@ export default function ComponentsPreview() {
...cat,
chipOptions: [
...cat.chipOptions,
{ id: newId, label: "", state: "Custom" },
{ id: newId, label: "", state: "custom" },
],
}
: cat,
@@ -271,7 +270,7 @@ export default function ComponentsPreview() {
...cat,
chipOptions: cat.chipOptions.map((opt) =>
opt.id === chipId
? { ...opt, label: value, state: "Selected" }
? { ...opt, label: value, state: "selected" }
: opt,
),
}
@@ -297,7 +296,7 @@ export default function ComponentsPreview() {
{
name: "Membership",
chipOptions: [
{ id: "membership-1", label: "Open Admission", state: "Unselected" },
{ id: "membership-1", label: "Open Admission", state: "unselected" },
],
onChipClick: (categoryName: string, chipId: string) => {
setRuleCardCategories((prev) =>
@@ -310,9 +309,9 @@ export default function ComponentsPreview() {
? {
...opt,
state:
opt.state === "Selected"
? "Unselected"
: "Selected",
opt.state === "selected"
? "unselected"
: "selected",
}
: opt,
),
@@ -330,7 +329,7 @@ export default function ComponentsPreview() {
...cat,
chipOptions: [
...cat.chipOptions,
{ id: newId, label: "", state: "Custom" },
{ id: newId, label: "", state: "custom" },
],
}
: cat,
@@ -349,7 +348,7 @@ export default function ComponentsPreview() {
...cat,
chipOptions: cat.chipOptions.map((opt) =>
opt.id === chipId
? { ...opt, label: value, state: "Selected" }
? { ...opt, label: value, state: "selected" }
: opt,
),
}
@@ -375,8 +374,8 @@ export default function ComponentsPreview() {
{
name: "Decision-making",
chipOptions: [
{ id: "decision-1", label: "Lazy Consensus", state: "Unselected" },
{ id: "decision-2", label: "Modified Consensus", state: "Unselected" },
{ id: "decision-1", label: "Lazy Consensus", state: "unselected" },
{ id: "decision-2", label: "Modified Consensus", state: "unselected" },
],
onChipClick: (categoryName: string, chipId: string) => {
setRuleCardCategories((prev) =>
@@ -389,9 +388,9 @@ export default function ComponentsPreview() {
? {
...opt,
state:
opt.state === "Selected"
? "Unselected"
: "Selected",
opt.state === "selected"
? "unselected"
: "selected",
}
: opt,
),
@@ -409,7 +408,7 @@ export default function ComponentsPreview() {
...cat,
chipOptions: [
...cat.chipOptions,
{ id: newId, label: "", state: "Custom" },
{ id: newId, label: "", state: "custom" },
],
}
: cat,
@@ -428,7 +427,7 @@ export default function ComponentsPreview() {
...cat,
chipOptions: cat.chipOptions.map((opt) =>
opt.id === chipId
? { ...opt, label: value, state: "Selected" }
? { ...opt, label: value, state: "selected" }
: opt,
),
}
@@ -454,8 +453,8 @@ export default function ComponentsPreview() {
{
name: "Conflict management",
chipOptions: [
{ id: "conflict-1", label: "Code of Conduct", state: "Unselected" },
{ id: "conflict-2", label: "Restorative Justice", state: "Unselected" },
{ id: "conflict-1", label: "Code of Conduct", state: "unselected" },
{ id: "conflict-2", label: "Restorative Justice", state: "unselected" },
],
onChipClick: (categoryName: string, chipId: string) => {
setRuleCardCategories((prev) =>
@@ -468,9 +467,9 @@ export default function ComponentsPreview() {
? {
...opt,
state:
opt.state === "Selected"
? "Unselected"
: "Selected",
opt.state === "selected"
? "unselected"
: "selected",
}
: opt,
),
@@ -488,7 +487,7 @@ export default function ComponentsPreview() {
...cat,
chipOptions: [
...cat.chipOptions,
{ id: newId, label: "", state: "Custom" },
{ id: newId, label: "", state: "custom" },
],
}
: cat,
@@ -507,7 +506,7 @@ export default function ComponentsPreview() {
...cat,
chipOptions: cat.chipOptions.map((opt) =>
opt.id === chipId
? { ...opt, label: value, state: "Selected" }
? { ...opt, label: value, state: "selected" }
: opt,
),
}
@@ -560,45 +559,45 @@ export default function ComponentsPreview() {
<Chip
label="Small"
state={chipStates["default-s"]}
palette="Default"
size="S"
palette="default"
size="s"
onClick={() =>
setChipStates((prev) => ({
...prev,
"default-s":
prev["default-s"] === "Selected"
? "Unselected"
: "Selected",
prev["default-s"] === "selected"
? "unselected"
: "selected",
}))
}
/>
<Chip
label="Medium"
state={chipStates["default-m"]}
palette="Default"
size="M"
palette="default"
size="m"
onClick={() =>
setChipStates((prev) => ({
...prev,
"default-m":
prev["default-m"] === "Selected"
? "Unselected"
: "Selected",
prev["default-m"] === "selected"
? "unselected"
: "selected",
}))
}
/>
<Chip
label="Disabled"
state="Disabled"
palette="Default"
size="S"
state="disabled"
palette="default"
size="s"
/>
{customChips
.filter((chip) => chip.palette === "Default")
.filter((chip) => chip.palette === "default")
.map((chip) => (
<Chip
key={chip.id}
label={chip.state === "Custom" ? "" : chip.label}
label={chip.state === "custom" ? "" : chip.label}
state={chip.state}
palette={chip.palette}
size={chip.size}
@@ -607,7 +606,7 @@ export default function ComponentsPreview() {
setCustomChips((prev) =>
prev.map((c) =>
c.id === chip.id
? { ...c, label: value, state: "Selected" }
? { ...c, label: value, state: "selected" }
: c,
),
);
@@ -622,8 +621,8 @@ export default function ComponentsPreview() {
e.stopPropagation();
// Only toggle if the chip is in Selected or Unselected state (not Custom)
if (
chip.state === "Selected" ||
chip.state === "Unselected"
chip.state === "selected" ||
chip.state === "unselected"
) {
setCustomChips((prev) =>
prev.map((c) =>
@@ -631,9 +630,9 @@ export default function ComponentsPreview() {
? {
...c,
state:
c.state === "Selected"
? "Unselected"
: "Selected",
c.state === "selected"
? "unselected"
: "selected",
}
: c,
),
@@ -652,9 +651,9 @@ export default function ComponentsPreview() {
{
id: newId,
label: "",
state: "Custom",
palette: "Default",
size: "S",
state: "custom",
palette: "default",
size: "s",
},
]);
}}
@@ -698,38 +697,38 @@ export default function ComponentsPreview() {
<Chip
label="Small"
state={chipStates["inverse-s"]}
palette="Inverse"
size="S"
palette="inverse"
size="s"
onClick={() =>
setChipStates((prev) => ({
...prev,
"inverse-s":
prev["inverse-s"] === "Selected"
? "Unselected"
: "Selected",
prev["inverse-s"] === "selected"
? "unselected"
: "selected",
}))
}
/>
<Chip
label="Medium"
state={chipStates["inverse-m"]}
palette="Inverse"
size="M"
palette="inverse"
size="m"
onClick={() =>
setChipStates((prev) => ({
...prev,
"inverse-m":
prev["inverse-m"] === "Selected"
? "Unselected"
: "Selected",
prev["inverse-m"] === "selected"
? "unselected"
: "selected",
}))
}
/>
<Chip
label="Disabled"
state="Disabled"
palette="Inverse"
size="S"
state="disabled"
palette="inverse"
size="s"
/>
</div>
</div>
@@ -959,10 +958,10 @@ export default function ComponentsPreview() {
</h2>
<div className="bg-[var(--color-surface-default-secondary)] rounded-[var(--radius-300,12px)] p-[var(--spacing-scale-032)] space-y-[var(--spacing-scale-024)]">
{/* Small size */}
<MultiSelectExample size="S" />
<MultiSelectExample size="s" />
{/* Medium size */}
<MultiSelectExample size="M" />
<MultiSelectExample size="m" />
</div>
</section>
</div>
+7
View File
@@ -0,0 +1,7 @@
import type { ReactNode } from "react";
// Development-only previews (e.g. `/components-preview`) — no public chrome.
// Routes here are gated by NODE_ENV checks at the page level.
export default function DevLayout({ children }: { children: ReactNode }) {
return <main className="flex-1">{children}</main>;
}
@@ -1,9 +1,9 @@
import dynamic from "next/dynamic";
import { listRuleTemplatesFromDb } from "../../lib/server/ruleTemplates";
import { GOVERNANCE_TEMPLATE_HOME_SLUGS } from "../../lib/templates/governanceTemplateCatalog";
import { gridEntriesForSlugOrderWithCatalogFallback } from "../../lib/templates/templateGridPresentation";
import { listRuleTemplatesFromDb } from "../../../lib/server/ruleTemplates";
import { GOVERNANCE_TEMPLATE_HOME_SLUGS } from "../../../lib/templates/governanceTemplateCatalog";
import { gridEntriesForSlugOrderWithCatalogFallback } from "../../../lib/templates/templateGridPresentation";
const RuleStack = dynamic(() => import("../components/sections/RuleStack"), {
const RuleStack = dynamic(() => import("../../components/sections/RuleStack"), {
loading: () => (
<section className="py-[var(--spacing-scale-032)] min-h-[400px]" />
),
+21
View File
@@ -0,0 +1,21 @@
import dynamic from "next/dynamic";
import type { ReactNode } from "react";
// Site footer is part of the public marketing chrome only — not rendered for
// signed-in product surfaces, admin dashboards, or dev previews. See
// `.cursor/rules/routes.mdc` for the full chrome composition map.
const Footer = dynamic(() => import("../components/navigation/Footer"), {
loading: () => (
<footer className="bg-[var(--color-surface-default-primary)] w-full min-h-[200px]" />
),
ssr: true,
});
export default function MarketingLayout({ children }: { children: ReactNode }) {
return (
<>
<main className="flex-1">{children}</main>
<Footer />
</>
);
}
+1 -1
View File
@@ -4,7 +4,7 @@ import messages from "../../messages/en/index";
import { getTranslation } from "../../lib/i18n/getTranslation";
import HeroBanner from "../components/sections/HeroBanner";
import AskOrganizer from "../components/sections/AskOrganizer";
import { MarketingRuleStackSection } from "./MarketingRuleStackSection";
import { MarketingRuleStackSection } from "./_components/MarketingRuleStackSection";
// Code split below-the-fold components to reduce initial bundle size
const LogoWall = dynamic(() => import("../components/sections/LogoWall"), {
+7 -18
View File
@@ -5,34 +5,26 @@ import type {
ButtonPaletteValue,
ButtonStateValue,
} from "../../../lib/propNormalization";
import {
normalizeSize,
normalizeButtonType,
normalizeButtonPalette,
} from "../../../lib/propNormalization";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
children: React.ReactNode;
/**
* Button type (Figma prop). Accepts both lowercase and PascalCase (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
* Button type (Figma prop).
* @default "filled"
*/
buttonType?: ButtonTypeValue;
/**
* Button palette (Figma prop). Accepts both lowercase and PascalCase (case-insensitive).
* Figma uses "Invert", codebase uses "inverse" - both are supported.
* Button palette (Figma prop).
* @default "default"
*/
palette?: ButtonPaletteValue;
/**
* Button size. Accepts both lowercase and PascalCase (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
* Button size.
* @default "xsmall"
*/
size?: SizeValue;
/**
* Button state (Figma prop). Accepts both lowercase and PascalCase (case-insensitive).
* Button state (Figma prop).
* @default "default"
*/
state?: ButtonStateValue;
@@ -83,12 +75,9 @@ const Button = memo<ButtonProps>(
ariaLabel,
...props
}) => {
// Normalize props
const buttonType = normalizeButtonType(typeProp, "filled");
const buttonPalette = normalizeButtonPalette(paletteProp, "default");
const size = normalizeSize(sizeProp);
// State prop is for Figma alignment - actual state is handled by CSS pseudo-classes
// We accept it for API alignment but don't use it for styling (CSS handles states)
const buttonType = typeProp ?? "filled";
const buttonPalette = paletteProp ?? "default";
const size = sizeProp;
// Map type + palette to variant string for styling (internal use only)
const getVariantFromTypeAndPalette = (
+19 -42
View File
@@ -3,80 +3,57 @@
import { memo } from "react";
import SectionNumber from "../sections/SectionNumber";
import { normalizeNumberCardSize } from "../../../lib/propNormalization";
export type NumberCardSizeValue =
| "Small"
| "Medium"
| "Large"
| "XLarge"
| "small"
| "medium"
| "large"
| "xlarge";
export type NumberCardSizeValue = "small" | "medium" | "large" | "xlarge";
interface NumberCardProps {
number: number;
text: string;
/**
* Number card size. Accepts both PascalCase (Figma default) and lowercase (case-insensitive).
* Figma uses PascalCase, codebase uses PascalCase - both are supported.
*/
size?: NumberCardSizeValue;
iconShape?: string;
iconColor?: string;
}
const NumberCard = memo<NumberCardProps>(({ number, text, size: sizeProp }) => {
// Base classes common to all sizes
const baseClasses =
"bg-[var(--color-surface-inverse-primary)] rounded-[12px] shadow-lg";
// If size prop is provided, use explicit size classes
// Otherwise, use responsive breakpoints for backward compatibility
if (sizeProp) {
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const size = normalizeNumberCardSize(sizeProp);
// Size-specific classes
const size = sizeProp;
const sizeClasses = {
Small: "flex flex-col items-end justify-center gap-4 p-5 relative",
Medium: "flex flex-row items-center gap-8 p-8 relative",
Large:
small: "flex flex-col items-end justify-center gap-4 p-5 relative",
medium: "flex flex-row items-center gap-8 p-8 relative",
large:
"flex flex-col items-start justify-end gap-[22px] h-[238px] p-8 relative",
XLarge:
xlarge:
"flex flex-col items-start justify-end gap-[22px] h-[238px] p-8 relative",
};
// Text size classes
const textClasses = {
Small:
small:
"font-bricolage-grotesque font-medium text-[24px] leading-[32px] text-[#141414]",
Medium:
medium:
"font-bricolage-grotesque font-medium text-[24px] leading-[24px] text-[#141414]",
Large:
large:
"font-bricolage-grotesque font-medium text-[24px] leading-[24px] text-[#141414]",
XLarge:
xlarge:
"font-bricolage-grotesque font-medium text-[32px] leading-[32px] text-[#141414]",
};
// Section number wrapper classes - Small doesn't need a wrapper
const sectionNumberWrapperClasses = {
Small: "relative shrink-0",
Medium: "flex justify-start flex-shrink-0",
Large: "absolute top-8 right-8",
XLarge: "absolute top-8 right-8",
small: "relative shrink-0",
medium: "flex justify-start flex-shrink-0",
large: "absolute top-8 right-8",
xlarge: "absolute top-8 right-8",
};
// Content container classes
const contentClasses = {
Small: "min-w-full relative shrink-0",
Medium: "flex-1",
Large: "absolute bottom-8 left-8 right-16",
XLarge: "absolute bottom-8 left-8 right-16",
small: "min-w-full relative shrink-0",
medium: "flex-1",
large: "absolute bottom-8 left-8 right-16",
xlarge: "absolute bottom-8 left-8 right-16",
};
// Small variant has section number as direct child, others need wrapper
if (size === "Small") {
if (size === "small") {
return (
<div className={`${baseClasses} ${sizeClasses[size]}`}>
{/* Section Number - Direct child for Small */}
@@ -3,7 +3,6 @@
import { memo } from "react";
import { RuleCardView } from "./RuleCard.view";
import type { RuleCardProps } from "./RuleCard.types";
import { normalizeRuleCardSize } from "../../../../lib/propNormalization";
declare global {
interface Window {
@@ -33,8 +32,7 @@ const RuleCardContainer = memo<RuleCardProps>(
logoAlt,
communityInitials,
}) => {
// Normalize size prop
const size = normalizeRuleCardSize(sizeProp, "L");
const size = sizeProp ?? "L";
const handleClick = () => {
// Basic analytics event tracking
@@ -21,7 +21,7 @@ export interface RuleCardProps {
className?: string;
onClick?: () => void;
expanded?: boolean;
size?: "XS" | "S" | "M" | "L" | "xs" | "s" | "m" | "l";
size?: "XS" | "S" | "M" | "L";
categories?: Category[];
logoUrl?: string;
logoAlt?: string;
@@ -261,8 +261,8 @@ export function RuleCardView({
key={categoryIndex}
label={category.name}
showHelpIcon={false}
size="S"
palette="Inverse"
size="s"
palette="inverse"
options={category.chipOptions}
onChipClick={(chipId) => {
category.onChipClick?.(category.name, chipId);
@@ -4,12 +4,10 @@ import { memo } from "react";
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
import ContentContainerView from "./ContentContainer.view";
import type { ContentContainerProps } from "./ContentContainer.types";
import { normalizeContentContainerSize } from "../../../../lib/propNormalization";
const ContentContainerContainer = memo<ContentContainerProps>(
({ post, width = "200px", size: sizeProp = "responsive" }) => {
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const size = normalizeContentContainerSize(sizeProp);
const size = sizeProp;
// Get the corresponding icon based on the same logic as background images
const getIconImage = (slug: string): string => {
const icons = [
@@ -1,17 +1,12 @@
import type { BlogPost } from "../../../../lib/content";
export type ContentContainerSizeValue =
| "xs"
| "responsive"
| "Xs"
| "Responsive";
export type ContentContainerSizeValue = "xs" | "responsive";
export interface ContentContainerProps {
post: BlogPost;
width?: string;
/**
* Content container size. Accepts both lowercase and PascalCase (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
* Content container size.
*/
size?: ContentContainerSizeValue;
}
@@ -4,12 +4,10 @@ import { memo } from "react";
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
import ContentThumbnailTemplateView from "./ContentThumbnailTemplate.view";
import type { ContentThumbnailTemplateProps } from "./ContentThumbnailTemplate.types";
import { normalizeContentThumbnailVariant } from "../../../../lib/propNormalization";
const ContentThumbnailTemplateContainer = memo<ContentThumbnailTemplateProps>(
({ post, className = "", variant: variantProp = "vertical" }) => {
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const variant = normalizeContentThumbnailVariant(variantProp);
const variant = variantProp;
// Get article-specific background image from frontmatter
const getBackgroundImage = (
post: ContentThumbnailTemplateProps["post"],
@@ -1,17 +1,12 @@
import type { BlogPost } from "../../../../lib/content";
export type ContentThumbnailTemplateVariantValue =
| "vertical"
| "horizontal"
| "Vertical"
| "Horizontal";
export type ContentThumbnailTemplateVariantValue = "vertical" | "horizontal";
export interface ContentThumbnailTemplateProps {
post: BlogPost;
className?: string;
/**
* Content thumbnail variant. Accepts both lowercase and PascalCase (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
* Content thumbnail variant.
*/
variant?: ContentThumbnailTemplateVariantValue;
slugOrder?: string[];
@@ -4,11 +4,11 @@ import { memo } from "react";
import { useComponentId } from "../../../hooks";
import { CheckboxView } from "./Checkbox.view";
import type { CheckboxProps } from "./Checkbox.types";
import {
normalizeMode,
normalizeState,
} from "../../../../lib/propNormalization";
/**
* Figma: "Control / Checkbox" (TODO(figma)). Single boolean checkbox with
* optional label, supporting standard and inverse modes.
*/
const CheckboxContainer = memo<CheckboxProps>(
({
checked = false,
@@ -24,9 +24,8 @@ const CheckboxContainer = memo<CheckboxProps>(
ariaLabel,
...props
}) => {
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const mode = normalizeMode(modeProp);
const state = normalizeState(stateProp);
const mode = modeProp;
const state = stateProp;
const isInverse = mode === "inverse";
const isStandard = mode === "standard";
@@ -2,15 +2,9 @@ import type { ModeValue, StateValue } from "../../../../lib/propNormalization";
export interface CheckboxProps {
checked?: boolean;
/**
* Mode variant. Accepts both "standard"/"Standard" and "inverse"/"Inverse" (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
/** Mode variant (Figma: Mode). */
mode?: ModeValue;
/**
* Visual state. Accepts "default"/"Default", "hover"/"Hover", "focus"/"Focus" (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
/** Visual state (Figma: State). */
state?: StateValue;
disabled?: boolean;
label?: string;
@@ -3,8 +3,11 @@
import { memo, useCallback, useId, useState } from "react";
import { CheckboxGroupView } from "./CheckboxGroup.view";
import type { CheckboxGroupProps } from "./CheckboxGroup.types";
import { normalizeMode } from "../../../../lib/propNormalization";
/**
* Figma: "Control / CheckboxGroup" (TODO(figma)). Group of checkboxes sharing
* a name that emits the array of currently selected values.
*/
const CheckboxGroupContainer = ({
name,
value,
@@ -15,8 +18,7 @@ const CheckboxGroupContainer = ({
className = "",
...props
}: CheckboxGroupProps) => {
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const mode = normalizeMode(modeProp);
const mode = modeProp;
// Generate unique ID for accessibility if not provided
const generatedId = useId();
const groupId = name || `checkbox-group-${generatedId}`;
@@ -12,8 +12,7 @@ export interface CheckboxGroupProps {
value?: string[];
onChange?: (_data: { value: string[] }) => void;
/**
* Mode variant. Accepts both "standard"/"Standard" and "inverse"/"Inverse" (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
* Mode variant.
*/
mode?: ModeValue;
disabled?: boolean;
+10 -11
View File
@@ -3,18 +3,17 @@
import { memo, useState, useEffect, useRef } from "react";
import ChipView from "./Chip.view";
import type { ChipProps } from "./Chip.types";
import {
normalizeChipPalette,
normalizeChipSize,
normalizeChipState,
} from "../../../../lib/propNormalization";
/**
* Figma: "Control / Chip" (TODO(figma)). Compact pill-shaped tag with
* selectable, removable, and inline-editable (custom) states.
*/
const ChipContainer = memo<ChipProps>(
({
label,
state: stateProp = "Unselected",
palette: paletteProp = "Default",
size: sizeProp = "S",
state: stateProp = "unselected",
palette: paletteProp = "default",
size: sizeProp = "s",
className = "",
disabled,
onClick,
@@ -23,9 +22,9 @@ const ChipContainer = memo<ChipProps>(
onClose,
ariaLabel,
}) => {
const state = normalizeChipState(stateProp);
const palette = normalizeChipPalette(paletteProp);
const size = normalizeChipSize(sizeProp);
const state = stateProp;
const palette = paletteProp;
const size = sizeProp;
const isDisabled = disabled ?? state === "disabled";
const isCustom = state === "custom";
+13 -19
View File
@@ -7,38 +7,32 @@ import type {
export interface ChipProps {
label: string;
/**
* Visual state of the chip, aligned with Figma:
* - "Unselected"
* - "Selected"
* - "Disabled"
* - "Custom" (editable chips with check/close buttons)
*
* Accepts both PascalCase (Figma) and lowercase values.
* Visual state of the chip:
* - "unselected"
* - "selected"
* - "disabled"
* - "custom" (editable chips with check/close buttons)
*/
state?: ChipStateValue;
/**
* Palette of the chip, aligned with Figma:
* - "Default"
* - "Inverse"
*
* Accepts both PascalCase (Figma) and lowercase values.
* Palette of the chip:
* - "default"
* - "inverse"
*/
palette?: ChipPaletteValue;
/**
* Size of the chip, aligned with Figma:
* - "S"
* - "M"
*
* Accepts both uppercase (Figma) and lowercase values.
* Size of the chip:
* - "s"
* - "m"
*/
size?: ChipSizeValue;
className?: string;
/**
* Whether the chip should be non-interactive. Defaults to `true` when
* `state === "disabled"` to preserve historical behavior. Pass
* `disabled={false}` alongside `state="Disabled"` to render the dimmed
* `disabled={false}` alongside `state="disabled"` to render the dimmed
* "disabled" visual while keeping the chip clickable — useful for toggle
* groups where the unselected state is the disabled Figma visual.
* groups where the unselected state is the disabled visual.
*/
disabled?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
@@ -0,0 +1,18 @@
"use client";
import { memo } from "react";
import { InputWithCounterView } from "./InputWithCounter.view";
import type { InputWithCounterProps } from "./InputWithCounter.types";
/**
* Figma: "Control / InputWithCounter" (TODO(figma)).
* Single-line text input with a label, optional help glyph, and a live
* `value.length / maxLength` counter underneath.
*/
const InputWithCounterContainer = memo<InputWithCounterProps>((props) => {
return <InputWithCounterView {...props} />;
});
InputWithCounterContainer.displayName = "InputWithCounter";
export default InputWithCounterContainer;
@@ -1,2 +1,2 @@
export { InputWithCounterView as default } from "./InputWithCounter.view";
export { default } from "./InputWithCounter.container";
export type { InputWithCounterProps } from "./InputWithCounter.types";
@@ -3,17 +3,17 @@
import { memo } from "react";
import MultiSelectView from "./MultiSelect.view";
import type { MultiSelectProps } from "./MultiSelect.types";
import {
normalizeMultiSelectSize,
normalizeChipPalette,
} from "../../../../lib/propNormalization";
/**
* Figma: "Control / MultiSelect" (TODO(figma)). Labelled set of chips for
* picking multiple values, with an optional add button for custom entries.
*/
const MultiSelectContainer = memo<MultiSelectProps>(
({
label,
showHelpIcon = true,
size: sizeProp = "M",
palette: paletteProp = "Default",
size: sizeProp = "m",
palette: paletteProp = "default",
options,
onChipClick,
onAddClick,
@@ -24,8 +24,8 @@ const MultiSelectContainer = memo<MultiSelectProps>(
onCustomChipClose,
className = "",
}) => {
const size = normalizeMultiSelectSize(sizeProp);
const palette = normalizeChipPalette(paletteProp);
const size = sizeProp;
const palette = paletteProp;
return (
<MultiSelectView
@@ -9,7 +9,7 @@ export interface ChipOption {
state?: ChipStateValue;
}
export type MultiSelectSizeValue = "S" | "M" | "s" | "m";
export type MultiSelectSizeValue = "s" | "m";
export interface MultiSelectProps {
/**
@@ -21,13 +21,11 @@ export interface MultiSelectProps {
*/
showHelpIcon?: boolean;
/**
* Size variant: "S" (small) or "M" (medium)
* Accepts both uppercase (Figma) and lowercase values.
* Size variant: "s" (small) or "m" (medium)
*/
size?: MultiSelectSizeValue;
/**
* Palette for chips: "Default" or "Inverse"
* Accepts both PascalCase (Figma) and lowercase values.
* Palette for chips: "default" or "inverse"
*/
palette?: ChipPaletteValue;
/**
@@ -28,7 +28,7 @@ function MultiSelectView({
? "gap-[var(--measures-spacing-200,8px)]"
: "gap-[var(--measures-spacing-300,12px)]";
const chipSize = isSmall ? "S" : "M";
const chipSize = size;
return (
<div
@@ -41,8 +41,8 @@ function MultiSelectView({
helpIcon={showHelpIcon}
asterisk={false}
helperText={false}
size={size === "s" ? "S" : "M"}
palette={palette === "inverse" ? "Inverse" : "Default"}
size={size}
palette={palette}
/>
)}
@@ -53,13 +53,12 @@ function MultiSelectView({
{options.map((option) => (
<Chip
key={option.id}
label={option.state === "Custom" ? "" : option.label}
state={option.state || "Unselected"}
palette={palette === "inverse" ? "Inverse" : "Default"}
label={option.state === "custom" ? "" : option.label}
state={option.state || "unselected"}
palette={palette}
size={chipSize}
onClick={() => {
// Only toggle if not in Custom state
if (option.state !== "Custom" && onChipClick) {
if (option.state !== "custom" && onChipClick) {
onChipClick(option.id);
}
}}
@@ -3,10 +3,6 @@
import { memo, useCallback, useId } from "react";
import { RadioButtonView } from "./RadioButton.view";
import type { RadioButtonProps } from "./RadioButton.types";
import {
normalizeMode,
normalizeState,
} from "../../../../lib/propNormalization";
const RadioButtonContainer = ({
checked = false,
@@ -22,9 +18,8 @@ const RadioButtonContainer = ({
ariaLabel,
className = "",
}: RadioButtonProps) => {
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const mode = normalizeMode(modeProp);
const state = normalizeState(stateProp);
const mode = modeProp;
const state = stateProp;
// If state is "selected", it means checked in Figma terms
const normalizedState = state === "selected" || checked ? "selected" : state;
@@ -3,14 +3,12 @@ import type { ModeValue, StateValue } from "../../../../lib/propNormalization";
export interface RadioButtonProps {
checked?: boolean;
/**
* Mode variant. Accepts both "standard"/"Standard" and "inverse"/"Inverse" (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
* Mode variant.
*/
mode?: ModeValue;
/**
* Visual state. Accepts "default"/"Default", "hover"/"Hover", "focus"/"Focus", "selected"/"Selected" (case-insensitive).
* Visual state.
* Note: "selected" state is represented by the `checked` prop in practice.
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
state?: StateValue;
/**
@@ -3,11 +3,11 @@
import { memo, useCallback, useId } from "react";
import { RadioGroupView } from "./RadioGroup.view";
import type { RadioGroupProps } from "./RadioGroup.types";
import {
normalizeMode,
normalizeState,
} from "../../../../lib/propNormalization";
/**
* Figma: "Control / RadioGroup" (TODO(figma)). Group of radio buttons sharing
* a name that emits the single currently selected value.
*/
const RadioGroupContainer = ({
name,
value,
@@ -19,14 +19,11 @@ const RadioGroupContainer = ({
className = "",
...props
}: RadioGroupProps) => {
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const mode = normalizeMode(modeProp);
// Normalize state, but handle "With Subtext" separately (it's represented by options with subtext)
const state =
typeof stateProp === "string" &&
(stateProp.toLowerCase() === "with subtext" || stateProp === "With Subtext")
? "default" // "With Subtext" is handled via RadioOption.subtext, use default state
: normalizeState(stateProp);
const mode = modeProp;
const state: "default" | "hover" | "focus" | "selected" =
stateProp === "With Subtext" || stateProp === "with subtext"
? "default"
: stateProp;
// Generate unique ID for accessibility if not provided
const generatedId = useId();
const groupId = name || `radio-group-${generatedId}`;
@@ -12,14 +12,12 @@ export interface RadioGroupProps {
value?: string;
onChange?: (_data: { value: string }) => void;
/**
* Mode variant. Accepts both "standard"/"Standard" and "inverse"/"Inverse" (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
* Mode variant.
*/
mode?: ModeValue;
/**
* Visual state. Accepts "default"/"Default", "hover"/"Hover", "focus"/"Focus" (case-insensitive).
* Visual state.
* Figma also supports "With Subtext" state, which is handled via RadioOption.subtext.
* Figma uses PascalCase, codebase uses lowercase - both are supported.
*/
state?: StateValue | "With Subtext" | "with subtext";
disabled?: boolean;
@@ -16,12 +16,11 @@ import React, {
import { useClickOutside } from "../../../hooks";
import { SelectInputView } from "./SelectInput.view";
import type { SelectInputProps } from "./SelectInput.types";
import {
normalizeState,
normalizeSmallMediumLargeSize,
normalizeLabelVariant,
} from "../../../../lib/propNormalization";
/**
* Figma: "Control / SelectInput" (TODO(figma)). Custom-styled select dropdown
* with a labelled trigger button and floating option menu.
*/
const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
(
{
@@ -53,22 +52,14 @@ const SelectInputContainer = forwardRef<HTMLButtonElement, SelectInputProps>(
const shouldShowLabel =
showLabel !== undefined ? showLabel : labelText !== undefined;
// Normalize state - handle "state5" as disabled
let normalizedState = externalStateProp;
if (normalizedState === "state5" || normalizedState === "State5") {
normalizedState = "default"; // Map to default, disabled prop handles the disabled state
normalizedState = "default";
}
const externalState = normalizeState(normalizedState);
const externalState = normalizedState;
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
// Note: labelVariant and size are normalized for future use but not yet implemented in the view
const _labelVariant = labelVariantProp
? normalizeLabelVariant(labelVariantProp)
: undefined;
const _size = sizeProp
? normalizeSmallMediumLargeSize(sizeProp)
: undefined;
// Mark as intentionally unused for future implementation
const _labelVariant = labelVariantProp;
const _size = sizeProp;
void _labelVariant;
void _size;
@@ -7,18 +7,8 @@ export interface SelectOptionData {
import type { StateValue } from "../../../../lib/propNormalization";
export type SelectInputLabelVariantValue =
| "default"
| "horizontal"
| "Default"
| "Horizontal";
export type SelectInputSizeValue =
| "small"
| "medium"
| "large"
| "Small"
| "Medium"
| "Large";
export type SelectInputLabelVariantValue = "default" | "horizontal";
export type SelectInputSizeValue = "small" | "medium" | "large";
export interface SelectInputProps {
id?: string;
@@ -33,18 +23,15 @@ export interface SelectInputProps {
*/
showLabel?: boolean;
/**
* Label variant. Accepts both lowercase and PascalCase (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
* Label variant.
*/
labelVariant?: SelectInputLabelVariantValue;
/**
* Select input size. Accepts both lowercase and PascalCase (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
* Select input size.
*/
size?: SelectInputSizeValue;
/**
* Visual state. Accepts "default"/"Default", "active"/"Active", "focus"/"Focus", "error"/"Error", "state5"/"State5" (State5 = Disabled).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
* Visual state. "state5" maps to disabled.
*/
state?: StateValue | "state5" | "State5";
/**
@@ -1,7 +1,7 @@
import React, { Children, type ReactNode } from "react";
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
import SelectDropdown from "./SelectDropdown";
import SelectOption from "./SelectOption";
import SelectOption from "../SelectOption";
import type { SelectOptionData } from "./SelectInput.types";
export interface SelectInputViewProps {
@@ -3,8 +3,11 @@
import { forwardRef, memo, useCallback } from "react";
import { SelectOptionView } from "./SelectOption.view";
import type { SelectOptionProps } from "./SelectOption.types";
import { normalizeContextMenuItemSize } from "../../../../../lib/propNormalization";
/**
* Figma: "Control / SelectOption" (TODO(figma)). Single option row rendered
* inside `SelectInput`'s dropdown menu.
*/
const SelectOptionContainer = forwardRef<HTMLDivElement, SelectOptionProps>(
(
{
@@ -18,8 +21,7 @@ const SelectOptionContainer = forwardRef<HTMLDivElement, SelectOptionProps>(
},
ref,
) => {
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const size = normalizeContextMenuItemSize(sizeProp);
const size = sizeProp;
const getTextSize = (): string => {
switch (size) {
case "small":
@@ -1,10 +1,4 @@
export type SelectOptionSizeValue =
| "small"
| "medium"
| "large"
| "Small"
| "Medium"
| "Large";
export type SelectOptionSizeValue = "small" | "medium" | "large";
export interface SelectOptionProps {
children?: React.ReactNode;
@@ -15,8 +9,7 @@ export interface SelectOptionProps {
_e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>,
) => void;
/**
* Select option size. Accepts both lowercase and PascalCase (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
* Select option size.
*/
size?: SelectOptionSizeValue;
}
@@ -3,8 +3,11 @@
import { memo, useCallback, useId, forwardRef } from "react";
import { SwitchView } from "./Switch.view";
import type { SwitchProps } from "./Switch.types";
import { normalizeState } from "../../../../lib/propNormalization";
/**
* Figma: "Control / Switch" (TODO(figma)). Animated on/off toggle switch,
* optionally paired with a trailing text label.
*/
const SwitchContainer = memo(
forwardRef<HTMLButtonElement, SwitchProps>((props, ref) => {
const {
@@ -18,8 +21,7 @@ const SwitchContainer = memo(
...rest
} = props;
// Normalize props to handle both PascalCase (Figma) and lowercase (codebase)
const state = normalizeState(stateProp);
const state = stateProp;
const switchId = useId();
@@ -17,8 +17,7 @@ export interface SwitchProps extends Omit<
onFocus?: (_e: React.FocusEvent<HTMLButtonElement>) => void;
onBlur?: (_e: React.FocusEvent<HTMLButtonElement>) => void;
/**
* Visual state. Accepts "default"/"Default", "hover"/"Hover", "focus"/"Focus" (case-insensitive).
* Figma uses PascalCase, codebase uses lowercase - both are supported.
* Visual state.
*/
state?: StateValue;
/**

Some files were not shown because too many files have changed in this diff Show More