App reorganization
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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:
|
||||
|
||||
- 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<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.
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user