---
paths:
  - "**/*.tsx"
  - "**/*.jsx"
  - "**/*.ts"
  - "**/*.css"
  - "**/*.html"
  - "**/tailwind.config.*"
  - "**/vite.config.*"
---
# Frontend

Tailwind v4 + shadcn-based UI conventions for **web** apps — styling, the `@nurix/components` library, the `design.md` token artifact, and design-skill routing — plus the TypeScript conventions every package compiles under.

- **Build every UI through the `design` skill** — never hand-roll layouts; the page archetypes, the `design.md` identity system with its token artifact (`design.tokens.css`), and chrome wiring live in the skill.
- **The brief's stated theme binds** — dark boots dark when the brief says so (recorded in the project's `design.md`); light is the default only when the brief is silent.
- **`@nurix/components`** is the UI library — import by subpath (`@nurix/components/button`). **Never** `npx shadcn add`, no `components.json` scaffold — consume the prebuilt library (`.tsx`, not `.jsx`). *A recorded deviation from canonical shadcn, where primitives are vendored into `components/ui/` and edited in place: this workspace consumes a published library instead, and vendoring would fork it. Deliberate — the design skill does not "converge" it.*
- Vite: `@tailwindcss/vite` + `@vitejs/plugin-react-swc`. Pin `react` / `react-dom` / `react/jsx-runtime` / `react/jsx-dev-runtime` to the app's single copy via `resolve.alias` — `@nurix/*` ship nested react, and two copies means two hook dispatchers.
- Tailwind v4 is CSS-only — no `tailwind.config.js`, no PostCSS.
- **The CSS entry is whatever the manifest names**, not a fixed filename — `globals.css`, `index.css` and `styles.css` are each conventional in different framework guides, so resolve it rather than grep for one. Imports in order: `@import "./fonts.css"` (`@fontsource` registrations) → the project's `design.tokens.css` (derived by the design skill's validator from the `design.md` in the design folder — `packages/common/` at the base of a multi-package workspace, `src/common/` in a single-package repo — **never** hand-edited) → `@import "tailwindcss"` → the library's `styles.css` → the project's `personality.css` (the same validator's `--emit-personality`, compiling the `design.md` traits through `@nurix/components`' own compiler — **never** hand-edited) — then `@theme inline {…}` mapping vars to tokens.
- **Dark mode is the `.dark` class on the root element**, bound as `@custom-variant dark (&:is(.dark *))` in the CSS entry — **never** a `data-theme` attribute. `@nurix/components` keys its `dark:` variants, its theme toggle, and its toaster on that class and a consumer cannot re-key them, so an app on the attribute renders mixed: its own tokens invert, the library's dark-only chrome stays light. The switcher is three parts — a pre-paint inline script in the HTML shell (a mounted provider runs too late and the page flashes), `color-scheme: light dark`, and the persisted-preference module.
- Tokens: raw values in `:root:not(.dark)` and `:root.dark` — **both specificity (0,2,0), and that armor is load-bearing.** Tailwind compiles `@theme` into `@layer theme` and any unlayered declaration beats a layered one, so the library's unlayered `:root`/`.dark` defeats an app's `@theme` outright and *ties* a bare `:root`/`.dark`, leaving the winner to import order. Values are then consumed as semantic Tailwind tokens (`bg-card`, `text-foreground`) through an `@theme inline` mapping layer. The library bundles `class-variance-authority`, `clsx`, `tailwind-merge` — don't re-add them.
- **Never retune a Tailwind scale key `@nurix/components` consumes** — `--text-xs`, `--text-sm`, `--text-base`, `--text-lg`, `--text-xl`, `--text-2xl`. Its components carry raw scale utilities and its stylesheet declares no `font-size`, so those compile against *this app's* `@theme`: redefining `--text-sm` moves every button, menu item and table cell in the library at once, with nothing to report it. Change type by changing ramp tokens.
- Spacing in 2/4/6/8; `object-cover`; `clip` / `clip-<n>` / `clip-fade` (the library's — they carry `min-width: 0` and `overflow-wrap: anywhere`; **never** `truncate` / `line-clamp-*`, which leave the flex-child overflow bug open); color through tokens — **never** hardcoded hex in components, and **never a literal font size**. The type ramp and the family rules are the design skill's [`references/type-system.md`](../skills/design/references/type-system.md); `text-sm` is a scale step and is fine, `text-[13px]` is a component overruling the system.
- **Feel comes from the traits, never from literals on a control or collection surface.** `personality.css` compiles `density` / `pace` / `edge` / `depth` into `--density-*`, `--motion-duration-*`, `--radius-scale`, `--elevation-*`, and every library component reads those. App code stays inside the same reach: scanned surfaces pad and gap through the tokens (`p-(--density-pad-md)`, `gap-(--density-gap-md)`), a `transition-*` utility carries **no** `duration-*` class (the library's default transition duration retimes it from `pace`), a repeated-row container carries `data-mode="inline"`, and app animation reads `var(--motion-duration-base)`. A `p-3`, a `duration-200`, or a `rounded-lg` on such a surface silently opts out of the product's feel.
- **Every rendered time is `<Timestamp>`** (`@nurix/components/element/timestamp`, `format="auto"`), and a live label ticks through `useNow` (`@nurix/components/lib/time`) — one shared interval, never a `setInterval` per row or a hand-rolled relative formatter.
- **Status colour is the role.** `success`, `warning`, `info` are declared in `design.md` in both themes; the app's `@theme inline` maps its utilities onto the same roles (`--color-success: var(--success)`), and a status dot is `<Chip prefix="dot" color="var(--tone-success)">` — one hue per meaning on every surface.
- **`cn()` keeps only one of a type role and a colour merged together** — its tailwind-merge tables class `text-h1` / `text-body` / `text-dense` as colours, so `cn("text-h1", "text-foreground")` and `cn("text-h1 text-foreground")` are both `text-foreground`. Keep a role and a colour in a `className` string that never goes through `cn`, or put the colour on the parent.

## Component Search Order

**A component already exists until the search proves otherwise** — building one is the last step, never the first instinct, and a primitive the library ships is never re-implemented. Search in order: **`@nurix/components`** (its `exports` keys are the available subpaths) → **the app's own `src/components/`**, which in a monorepo is the consuming package's pool → **the route folders**, where a near-match is a misfiled generic to promote rather than duplicate.

**A new component lands in `src/components/` on first use, not on second.** Waiting for reuse to promote it fails in practice: nobody notices the second use, so the promotion never happens and the component sits where the next session will not look. Findability is the point of the pool, so one consumer earns a place in it.

**The exception is mechanical — a component importing from a specific route folder belongs to that route folder.** That import is what domain-coupled means, and it is decidable the moment the file is written, where consumer count cannot classify anything until a second use exists. A pool component that starts importing a route has drifted and moves down.

**Pay the pollution as you go, never as scheduled cleanup:** re-read the pool before adding — something differing from an existing component by one prop is a prop, not a component — and group the pool by kind once it passes roughly fifteen flat files.

## TypeScript

Every package compiles under `strict: true` and `noUncheckedIndexedAccess: true`.

### Type Safety

- **Never** `any` to silence the checker. Unknown value → type `unknown`, then narrow (type guard / `zod` parse).
- **Never** weaken strictness: no `// @ts-nocheck`; `// @ts-expect-error` only with a same-line reason.
- Annotate at boundaries (exports, props, contracts).

### Generics

Shared libraries (`@nurix/components`, etc.) are generic on purpose — that's what types `cell(row)`, checks `slug` against `keyof TData`, and ties `data`/`columns` together; **never** widen to `any` to dodge it. State the row type **once per file** and let inference carry it — repeating `fn<Type>(…)` / `<Comp<Type>>` per call site is cosmetic (same safety, same emitted JS):

- **Bind a repeated factory once** (preferred): `const col = defineColumn<Row>;` → `col({...})`.
- **Infer JSX type args from props:** `<Table data={rows} columns={columns} />`, not `<Table<Row> …>`.
- **Anchor with a collection annotation** when nothing else can: `const columns: ColumnDefinition<Row>[] = [...]`.
