diff --git a/.cursor/rules/component-props.mdc b/.cursor/rules/component-props.mdc index d906ffc..200beec 100644 --- a/.cursor/rules/component-props.mdc +++ b/.cursor/rules/component-props.mdc @@ -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 `__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 ; -}; +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` (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: "" -()`. +- Container docstring (required on every DS container): `Figma: + "" ()`. +- View root element: `data-figma-node=""` 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=""` 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. diff --git a/.cursor/rules/component-structure.mdc b/.cursor/rules/component-structure.mdc index 6bd9f32..449befe 100644 --- a/.cursor/rules/component-structure.mdc +++ b/.cursor/rules/component-structure.mdc @@ -22,24 +22,27 @@ app/components/controls// **Container** (`.container.tsx`): - Marked `"use client"`. -- Receives `Props`; normalizes PascalCase enums via - `lib/propNormalization.ts`; computes derived state (clamps, ids, bounds). -- Renders `<View />` with already-normalized `ViewProps`. +- Receives `Props`; computes derived state (clamps, ids, bounds, prop + defaults) and bound event handlers. +- Renders `<View />`. Containers do **not** translate prop casing — + enum props are lowercase end-to-end (see `component-props.mdc`). - Default export: `memo(Container)` with `.displayName = ""`. +- Carries the Figma docstring (`Figma: "" ()`). **View** (`.view.tsx`): - Marked `"use client"`. -- Pure render of `ViewProps`. No prop normalization, no data fetching, - no derived business logic. -- Default export: `memo(View)` with - `.displayName = "View"`. +- Pure render of `ViewProps`. No data fetching, no derived business + logic, no enum casing translation. +- Default export: `memo(View)` with `.displayName = "View"`. **Types** (`.types.ts`): -- Export `Props` (consumer-facing, accepts PascalCase + lowercase). -- Export `ViewProps` (already-normalized shape the view consumes). -- Export any locally-defined value types (`SizeValue`, etc.). +- Export `Props` (consumer-facing). +- Export `ViewProps` (the shape the view consumes — typically a + resolved superset of `Props`). +- Export any locally-defined value types (`SizeValue`, etc.) sourced + from the matching `*_OPTIONS` array in `lib/propNormalization.ts`. **Index** (`index.tsx`): @@ -51,10 +54,10 @@ export type { Props } from "./.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 diff --git a/.cursor/rules/create-flow.mdc b/.cursor/rules/create-flow.mdc index e64c450..9c9a417 100644 --- a/.cursor/rules/create-flow.mdc +++ b/.cursor/rules/create-flow.mdc @@ -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//Screen.tsx` +- Screens live in `app/(app)/create/screens//Screen.tsx` where `` 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) | diff --git a/.cursor/rules/hooks.mdc b/.cursor/rules/hooks.mdc new file mode 100644 index 0000000..245199e --- /dev/null +++ b/.cursor/rules/hooks.mdc @@ -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.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: + +- 1–2 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>, + 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. diff --git a/.cursor/rules/routes.mdc b/.cursor/rules/routes.mdc new file mode 100644 index 0000000..1f9d9dd --- /dev/null +++ b/.cursor/rules/routes.mdc @@ -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 `