Establish cursor rules
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
---
|
||||
description: App Router API handler conventions (Next.js + Prisma + Zod)
|
||||
globs: app/api/**/*.ts,lib/server/**/*.ts
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# API route anatomy
|
||||
|
||||
Every DB-touching handler in `app/api/**/route.ts` follows the same skeleton.
|
||||
Keep new routes within this shape so auth, config, and validation stay uniform.
|
||||
|
||||
1. **Config guard (first line of the handler).**
|
||||
|
||||
```typescript
|
||||
if (!isDatabaseConfigured()) return dbUnavailable();
|
||||
```
|
||||
|
||||
From `lib/server/env` + `lib/server/responses`. Returns a consistent 503
|
||||
when `DATABASE_URL` is missing (local dev, preview builds).
|
||||
|
||||
2. **Auth (when the route requires a user).**
|
||||
|
||||
```typescript
|
||||
const user = await getSessionUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
```
|
||||
|
||||
From `lib/server/session`. Never read session cookies or tokens directly.
|
||||
|
||||
3. **Body parsing + validation (POST/PUT/PATCH).**
|
||||
|
||||
```typescript
|
||||
const parsed = await readLimitedJson(request);
|
||||
const result = mySchema.safeParse(parsed);
|
||||
if (!result.success) return jsonFromZodError(result.error);
|
||||
```
|
||||
|
||||
Helpers live in `lib/server/validation/{requestBody,zodHttp}.ts`. All
|
||||
payload schemas belong in `lib/server/validation/*.ts` (today:
|
||||
`createFlowSchemas.ts`) — colocate new schemas there rather than inline in
|
||||
the route.
|
||||
|
||||
4. **Prisma access** via `import { prisma } from "lib/server/db"`. Do not
|
||||
instantiate `PrismaClient` directly.
|
||||
|
||||
5. **Responses** via `NextResponse.json(...)`. Shared shapes (`dbUnavailable`)
|
||||
live in `lib/server/responses.ts`; add new shared responses there when a
|
||||
pattern repeats in two routes.
|
||||
|
||||
# Server-only isolation
|
||||
|
||||
`lib/server/*` is the server boundary. Anything that:
|
||||
|
||||
- imports `@prisma/client`,
|
||||
- reads secrets from `env`,
|
||||
- sends email, hashes tokens, or touches sessions
|
||||
|
||||
…lives under `lib/server/`. Never import `lib/server/*` from client
|
||||
components, `app/components/**`, or any file marked `"use client"`. Shared
|
||||
logic safe for both sides goes in `lib/*`.
|
||||
|
||||
# Deferred — follow existing code, don't invent
|
||||
|
||||
These areas are still settling. Match whatever the nearest route already does
|
||||
instead of introducing new patterns:
|
||||
|
||||
- **Rate limiting.** `lib/server/rateLimit.ts` is an in-memory stopgap marked
|
||||
for replacement. Reuse `rateLimitKey()` where limiting is needed; don't
|
||||
design a new limiter.
|
||||
- **Error response shape.** Currently `{ error: string }` + HTTP status. No
|
||||
error codes yet — don't add a taxonomy until one is designed.
|
||||
- **Pagination / filtering.** Only `rules/route.ts` paginates (`take` capped
|
||||
at 100). Mirror it if you add list endpoints; don't invent cursors or
|
||||
offset contracts unilaterally.
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
description: Figma ↔ codebase prop alignment & normalization for design-system components
|
||||
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.
|
||||
|
||||
## Scope
|
||||
|
||||
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
|
||||
|
||||
```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} />;
|
||||
};
|
||||
```
|
||||
|
||||
## 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>)`.
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 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.
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
description: File-structure conventions for design-system components
|
||||
globs: app/components/**/*.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Component file structure
|
||||
|
||||
## Split-file pattern (default)
|
||||
|
||||
Anything in `app/components/controls/**` and `app/components/utility/**` uses
|
||||
a **4-file split**, one folder per component:
|
||||
|
||||
```
|
||||
app/components/controls/<Name>/
|
||||
<Name>.types.ts // Public Props + internal ViewProps
|
||||
<Name>.view.tsx // "use client"; pure render; exports memo(view)
|
||||
<Name>.container.tsx // "use client"; memo; prop normalization & logic
|
||||
index.tsx // re-exports default + public types
|
||||
```
|
||||
|
||||
**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`.
|
||||
- Default export: `memo(<Name>Container)` with `.displayName = "<Name>"`.
|
||||
|
||||
**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"`.
|
||||
|
||||
**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.).
|
||||
|
||||
**Index** (`index.tsx`):
|
||||
|
||||
```typescript
|
||||
export { default } from "./<Name>.container";
|
||||
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.
|
||||
|
||||
## Wrapper / group components
|
||||
|
||||
Related composites live in a **sibling folder**, not inside the base
|
||||
component's folder — mirror `CheckboxGroup/` ↔ `Checkbox/`,
|
||||
`IncrementerBlock/` ↔ `Incrementer/`, etc. Each gets its own 4-file split.
|
||||
Consumers import from the folder's `index.tsx`.
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
description: Create-flow structure & design-system reuse guardrails
|
||||
globs: app/create/**/*.{ts,tsx},messages/en/create/**/*.json
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Create-flow guardrails
|
||||
|
||||
## Folder & file layout
|
||||
|
||||
- Screens live in `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
|
||||
inside a screen — pick the matching shell (`CreateFlowStepShell` /
|
||||
`CreateFlowTwoColumnSelectShell`).
|
||||
- Shared create-flow pieces go in `app/create/components/` (layout shells,
|
||||
field composites). Generic primitives go in `app/components/`.
|
||||
|
||||
## Use the design system — don't hand-roll
|
||||
|
||||
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` |
|
||||
| `[– 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) |
|
||||
| Toggle chip (dim-but-clickable) | `Chip` with `state="Disabled" disabled={false}` |
|
||||
| Card-click → structured creation modal | `Create` with `backdropVariant="loginYellow"` |
|
||||
|
||||
If a screen grows a 2nd inline copy of any pattern above, **extract a shared
|
||||
component** rather than duplicate. Local section components inside a screen
|
||||
file are a smell once they're used more than once.
|
||||
|
||||
## Copy & data
|
||||
|
||||
- Step copy lives in `messages/en/create/<step>.json`, wired into
|
||||
`messages/en/index.ts` under the `create:` namespace (see
|
||||
`localization.mdc` for the standard pattern).
|
||||
- Modal `sections` defaults are DB-shaped seed placeholders, not UI
|
||||
constants — expect replacement with live data.
|
||||
|
||||
## Interaction tracking
|
||||
|
||||
Every user interaction inside a create-flow screen must call
|
||||
`markCreateFlowInteraction()` from `useCreateFlow()` before mutating state —
|
||||
progress / footer logic depends on it.
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
description: Text localization via messages/ bundles and useMessages()
|
||||
globs: messages/**/*.{ts,json}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Text localization
|
||||
|
||||
All user-visible copy lives in the typed messages bundle under `messages/en/`
|
||||
and is read via `useMessages()` (fully typed) or `useTranslation()` (dot
|
||||
notation). Never hard-code user-facing strings in components.
|
||||
|
||||
## File layout
|
||||
|
||||
- `messages/en/<area>.json` for single-file areas (`common.json`,
|
||||
`navigation.json`, `metadata.json`).
|
||||
- `messages/en/<folder>/<entry>.json` for areas with multiple buckets:
|
||||
`components/*.json`, `pages/*.json`, `create/*.json`. One JSON per
|
||||
component / page / create-flow step — don't shoehorn unrelated copy into
|
||||
a shared file.
|
||||
- Optional `"_comment"` at the top of a JSON documents the bundle's purpose.
|
||||
|
||||
## Registration — required
|
||||
|
||||
Every new JSON must be wired into `messages/en/index.ts`:
|
||||
|
||||
```typescript
|
||||
import createConflictManagement from "./create/conflictManagement.json";
|
||||
|
||||
export default {
|
||||
// …
|
||||
create: {
|
||||
conflictManagement: createConflictManagement,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
The default export **is** the type source for `useMessages()`; skipping this
|
||||
step means consumers can't read your strings and TypeScript won't flag the gap.
|
||||
|
||||
## Access pattern
|
||||
|
||||
```typescript
|
||||
import { useMessages } from "../contexts/MessagesContext";
|
||||
|
||||
const m = useMessages();
|
||||
const title = m.create.conflictManagement.page.compactTitle; // fully typed
|
||||
```
|
||||
|
||||
Use `useTranslation(namespace)` only when you need dot-path lookup by dynamic
|
||||
key; prefer direct property access for the type safety.
|
||||
|
||||
## Key conventions
|
||||
|
||||
- **Structural keys**: camelCase (`compactTitle`,
|
||||
`sectionHeadings.corePrinciple`).
|
||||
- **Content ids**: match the id consumers already use (card id, step id, URL
|
||||
segment) — typically kebab-case (`"in-person-meetings"`,
|
||||
`"peer-mediation"`).
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
description: Storybook story conventions — location, naming, titles, decorators
|
||||
globs: stories/**/*.{js,jsx,ts,tsx,mdx},.storybook/**/*.{js,ts}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Where stories live
|
||||
|
||||
All stories live in the top-level `stories/` folder, mirroring the
|
||||
`app/components/` directory tree:
|
||||
|
||||
| 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` |
|
||||
|
||||
Do **not** colocate `*.stories.*` next to components. The Storybook config
|
||||
(`.storybook/main.js`) only globs `stories/**`.
|
||||
|
||||
# File naming
|
||||
|
||||
- `<ComponentName>.stories.js` — matches 69/70 existing files.
|
||||
- Use `.tsx` only when the story genuinely needs types (rare; prefer JS to
|
||||
match the codebase convention).
|
||||
- Variants get a suffix: `Button.visual.stories.js`,
|
||||
`Footer.responsive.stories.js`.
|
||||
|
||||
# Default export shape (CSF2)
|
||||
|
||||
```javascript
|
||||
import MyComponent from "../../app/components/<area>/MyComponent";
|
||||
|
||||
export default {
|
||||
title: "Components/<SubFolder>/MyComponent",
|
||||
component: MyComponent,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
docs: {
|
||||
description: {
|
||||
component: "Short description of what the component is for.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: { type: "select" },
|
||||
options: ["filled", "outline"],
|
||||
description: "The variant (Figma prop)",
|
||||
},
|
||||
onClick: { action: "clicked" },
|
||||
},
|
||||
};
|
||||
|
||||
export const Default = { args: { variant: "filled" } };
|
||||
```
|
||||
|
||||
## Title hierarchy
|
||||
|
||||
- Design-system components → `Components/<SubFolder>/<Name>` (e.g.
|
||||
`Components/Controls/Checkbox`).
|
||||
- Pages → `Pages/<PageName>` (folder: `stories/pages/`).
|
||||
- Create flow shared pieces → `Create Flow/<Name>`.
|
||||
|
||||
## `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`.
|
||||
|
||||
# Rely on the global preview — don't re-wrap
|
||||
|
||||
`.storybook/preview.js` already provides:
|
||||
|
||||
- `MessagesProvider` with `messages/en` → access copy via `useMessages()`
|
||||
inside stories exactly like app code. Never hard-code user-facing strings.
|
||||
- `app/globals.css` + `.font-inter` wrapper → design tokens and fonts are
|
||||
already present.
|
||||
|
||||
Do **not** add your own `MessagesProvider`, font wrapper, or token setup in a
|
||||
story. If you need a new global, update `preview.js`.
|
||||
|
||||
# Interaction tests (`play`)
|
||||
|
||||
Use `@storybook/test` for interaction assertions — not `@testing-library/*`
|
||||
directly. This matches `Checkbox.stories.js` and stays compatible with the
|
||||
Vitest portable-stories runner in `.storybook/vitest.setup.js`.
|
||||
|
||||
```javascript
|
||||
import { within, userEvent, expect } from "@storybook/test";
|
||||
|
||||
export const Interactive = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("checkbox"));
|
||||
expect(canvas.getByRole("checkbox")).toHaveAttribute("aria-checked", "true");
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
# 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`
|
||||
entry. A new component without a story is considered incomplete.
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
description: Tailwind-first styling for all React components
|
||||
globs: app/**/*.{ts,tsx},stories/**/*.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Tailwind-first styling
|
||||
|
||||
Tailwind v4 is the default styling layer. Reach for utility classes + design
|
||||
tokens **before** anything else.
|
||||
|
||||
## Priority
|
||||
|
||||
1. **Tailwind utilities** — `className="flex items-center gap-4 p-6 rounded-lg"`.
|
||||
Use arbitrary values (`w-[200px]`) and responsive variants (`sm:`, `lg:`)
|
||||
as needed. Design-token CSS variables go in arbitrary values:
|
||||
`bg-[var(--color-surface-default-primary)]`.
|
||||
2. **`style` prop** — only for values that truly change at runtime
|
||||
(`style={{ width: `${dynamicPx}px` }}`).
|
||||
3. **Custom / global CSS** — last resort. Justified for keyframes, third-party
|
||||
overrides, dynamic-count CSS Grid, and similar cases Tailwind can't express.
|
||||
4. **CSS-in-JS / CSS Modules** — don't introduce.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
```tsx
|
||||
// ❌ Opaque class names bypass the design system
|
||||
<div className="custom-container">
|
||||
<span className="custom-text">Hello</span>
|
||||
</div>
|
||||
|
||||
// ❌ Inline style for a static value
|
||||
<div style={{ padding: 16, borderRadius: 8 }}>…</div>
|
||||
|
||||
// ✅ Tailwind + token
|
||||
<div className="p-4 rounded-lg bg-[var(--color-surface-default-primary)]">…</div>
|
||||
```
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
description: Test file layout & shared harnesses (vitest + Playwright)
|
||||
globs: tests/**/*.{ts,tsx,js,jsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Testing conventions
|
||||
|
||||
## Runner split
|
||||
|
||||
- **Vitest** for unit, component, and page-level tests (`tests/components`,
|
||||
`tests/pages`, `tests/unit`, `tests/contexts`, `tests/accessibility`).
|
||||
Run via `npm test` or `npx vitest run`.
|
||||
- **Playwright** for browser e2e and visual regression (`tests/e2e`).
|
||||
Run via `npm run e2e`. Never put Playwright specs outside `tests/e2e/`.
|
||||
|
||||
## File layout
|
||||
|
||||
| Path | Use |
|
||||
| --- | --- |
|
||||
| `tests/components/<Name>.test.tsx` | Design-system component tests. |
|
||||
| `tests/pages/<step>.test.jsx` | Page / screen integration tests. |
|
||||
| `tests/unit/<fn>.test.{ts,js}` | Pure logic — utilities, reducers, hooks without DOM. |
|
||||
| `tests/contexts/<Ctx>.test.tsx` | Context provider tests. |
|
||||
| `tests/accessibility/` | `jest-axe` suites (unit) and `wcag-compliance.spec.ts` (e2e). |
|
||||
| `tests/e2e/` | Playwright specs (user journeys, visual, performance). |
|
||||
|
||||
## Providers — always use `renderWithProviders`
|
||||
|
||||
`render` from `@testing-library/react` **skips** Messages/AuthModal/CreateFlow
|
||||
providers. Import the wrapped version instead:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
renderWithProviders as render,
|
||||
screen,
|
||||
} from "../utils/test-utils";
|
||||
```
|
||||
|
||||
Raw `render` is only acceptable for pure-presentational components that read
|
||||
none of those contexts.
|
||||
|
||||
## DS component suites
|
||||
|
||||
Reuse `componentTestSuite` for standard DS coverage (renders,
|
||||
`jest-axe` a11y, keyboard navigation, disabled/error states) instead of
|
||||
rewriting each check per component:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
componentTestSuite,
|
||||
type ComponentTestSuiteConfig,
|
||||
} from "../utils/componentTestSuite";
|
||||
|
||||
const config: ComponentTestSuiteConfig<Props> = {
|
||||
component: MyComponent,
|
||||
name: "MyComponent",
|
||||
props: baseProps,
|
||||
primaryRole: "button",
|
||||
testCases: { renders: true, accessibility: true, keyboardNavigation: true },
|
||||
};
|
||||
componentTestSuite(config);
|
||||
```
|
||||
|
||||
Custom interaction tests live alongside the suite in the same file.
|
||||
|
||||
## Required imports
|
||||
|
||||
- `import "@testing-library/jest-dom/vitest";` — required for matcher types
|
||||
(`toBeInTheDocument`, `toHaveAttribute`, etc.).
|
||||
- `afterEach(() => cleanup())` in page-level test files where multiple
|
||||
`render` calls run sequentially.
|
||||
Reference in New Issue
Block a user