126 lines
13 KiB
Markdown
126 lines
13 KiB
Markdown
# Northstar Design System — agent handbook
|
||
|
||
React 19 + Vite 8 + TypeScript (strict) design-system showcase. Dark-first, token-driven UI with a light theme.
|
||
|
||
## Commands
|
||
|
||
| Task | Command |
|
||
|---|---|
|
||
| Dev server | `npm run dev` (vite.config.ts pins `0.0.0.0:5173`) |
|
||
| Full gate: lint + token contract + tests + typecheck + build | `npm run check` — run before declaring any change done |
|
||
| Lint only | `npm run lint` (oxlint) |
|
||
| Tests | `npm run test` (vitest, jsdom; suites in `tests/`) |
|
||
|
||
TypeScript is **strict** (`strict` + `noUncheckedIndexedAccess` in `tsconfig.app.json`) — implicit `any` fails the build, not just the reviewer.
|
||
|
||
## Debugging without a console
|
||
|
||
Browser tooling here has no console access. Runtime errors therefore render into a `#dev-errors` element (see `src/dev/errorSink.ts` + `src/dev/ErrorBoundary.tsx`): after loading the page, `wait_for` / `inspect` `[data-dev-error]` to read error messages, and `wait_for vite-error-overlay` for build/HMR errors. If `#root` is empty but no `[data-dev-error]` exists, check the dev-server stdout.
|
||
|
||
## Sandboxed worker dev flow
|
||
|
||
- `vite.config.ts` allows `*.preview.k6n.net`; expose port **5173** via `repo_instance_expose` for a preview URL.
|
||
- Removing `allowedHosts` breaks preview ingress with a 403 ("host not allowed"). Don't.
|
||
- `/api` is proxied to the Go backend (target `API_URL`, default `http://localhost:8080`) and forwards `X-Forwarded-Host`/`X-Forwarded-Proto`, so OIDC login works unchanged on localhost and preview hosts. The auth module is `src/auth/` (`useSession` hook, Topbar `UserMenu`, overview `SessionCard`) — server side lives in the backend's `internal/auth` + `/api/auth/*` (see backend/AGENTS.md).
|
||
|
||
## Design tokens — the contract
|
||
|
||
1. **Single source of truth: `src/tokens.css`.** Every font size, color, spacing, radius, and shadow color comes from a token. The palette is defined in modern CSS Color 4 `lab(...)` color space:
|
||
- Dark theme (`:root:not([data-theme='light'])`, `[data-theme='dark']`, `.dark`): Pitch black base (`lab(0% 0 0)`), deep cards (`lab(.903296% 0 0)`), radiant electric mint/cyan primary (`lab(89.9321% -47.9163 -15.7303)`), vivid destructive (`lab(54.5811% 71.684 50.9279)`), chart colors 1–5, and subtle dot grid (`--dot-color`).
|
||
- Light theme (`:root`, `[data-theme='light']`, `.light`): Pure white daylight base (`lab(100% 0 0)`), crisp charcoal primary (`lab(7.78201% -.0000149012 0)`), and high-contrast surfaces.
|
||
- There are **no** legacy aliases (`--panel*`, `--line*`, `--text-soft`, …) anymore — canonical names only.
|
||
2. The guard `scripts/check-tokens.mjs` (in `npm run check`) bans hardcoded colors — hex, named colors, `rgb()/hsl()/oklch()/lab()`, even across multiple lines — plus px font sizes and `var(--x)` references that no CSS file defines. It scans **CSS files and inline `style={{ … }}` in `.ts/.tsx`**. Allowed: `var(--token)`, `color-mix(in srgb, var(--token) N%, transparent)`, `light-dark(var(--a), var(--b))`, and neutral black/white rgba shines.
|
||
3. Typography scale (`--text-3xs` … `--text-2xl`, `--text-hero`) is rem-based on a 9→28px grid. Longform body `--text-md`, buttons/nav `--text-xs`, meta/eyebrows `--text-2xs`/`--text-3xs`.
|
||
4. **Global density knob: `--root-font-size`** (default `112%` readability preset, baseline 16px = `100%`). One line scales every token-driven size app-wide; spacing tokens follow.
|
||
5. **Theming:** components read only `var(--*)` and never know the theme. A pre-paint script in `index.html` applies `localStorage['ns-theme']` or `prefers-color-scheme` before first paint (no flash); `useTheme` in `Topbar.tsx` reads what it set and keeps `data-theme` **and** the `.dark`/`.light` classes in sync on toggle.
|
||
|
||
## Smart Utils & AI Workflow
|
||
|
||
- **Global app state**: `src/app/` — `AppStateProvider` (mounted in `main.tsx`) owns the few settings the whole app shares: `theme`, `viewMode`, `sidebarCollapsed`, `searchOpen`, plus the ⌘K/Ctrl+K shortcut. Read them anywhere with `useAppState()` (import from `./app` or `../app`); it throws outside the provider on purpose. Add a setting to `appState.ts` (types) + `AppStateProvider.tsx` (state, DOM mirror, `STORAGE_KEYS` persistence) — never thread a new prop chain. Section-local state (active tab, selected pattern) stays in its section. Persisted settings are written **only on user action**, so an untouched app keeps following `prefers-color-scheme`. `theme` always boots from the `<html data-theme>` attribute the pre-paint script set (storage key `ns-theme` is shared with `index.html`); `viewMode` mirrors to `<html data-view-mode>` and currently gates nothing — it is the audience preset hook for per-persona views.
|
||
- **Class helper**: `src/utils/cn.ts` — zero-dependency classnames merging utility for conditional component styles.
|
||
- **Timer helper**: `src/utils/useTimeout.ts` — `schedule(fn, ms)` / `clear()`; auto-cancels pending timers on unmount. Never fire-and-forget a raw `setTimeout` in a component.
|
||
- **Smart CSS**: `src/utils.css` — ready-to-use helpers (`.dot-canvas`, `.surface-card`, `.surface-sidebar`, `.badge-*`, `.hover-lift`, `.kbd-tag`, `.skeleton-pulse`, `.progress-*`, `.ui-table-*`, `.dialog-*`, `.sheet-*`, `.alert-*`, `.ui-tabs-*`, `.switch-*`).
|
||
- **Overlays — one primitive**: `src/components/ui/Overlay.tsx` owns focus trap, Escape-to-close, scroll lock, focus restore, and conditional `aria-labelledby`/`aria-describedby` (wired only when a title/description is actually rendered). `Dialog` and `Sheet` are thin skins over it; `SearchPalette` reuses `trapTabKey` + `useOverlayLifecycle` from `src/components/ui/overlayBehavior.ts`. Build new modals on Overlay, not from scratch.
|
||
- **Composable Primitives**: `src/components/ui/` (`Overlay`, `Dialog`, `Sheet`, `Tabs`, `Alert`, `EmptyState`, `Table`, `Progress`, `Switch`, `Badge`, `Card`, `StatCard`, `Skeleton`, `Tooltip`, `Kbd`, `Avatar`, `Spinner`; `Icon` lives at `src/components/Icon.tsx` and is re-exported from the barrel).
|
||
- **StatCard**: `trend` is sentiment (badge color), `direction` is the arrow and defaults to the sign of `change` — a "good" latency drop is `trend="up"` with `change="-4.2ms"` rendering a green `↓ -4.2ms`.
|
||
- **Reusable Application Patterns**: `src/components/examples/` (`MetricsDashboard`, `SettingsPanel`, `ServiceTable`, `DeploymentModal`).
|
||
- **Icon Library**: the registry in `src/components/icon-registry.ts` maps every `IconName` to a lucide component (strictly typed — typos fail `tsc`, unknown names at runtime **throw in dev** so the error sink surfaces them). 42 curated icons power the showcase; `lucide-react` ships 1,400+ for direct import.
|
||
- **Routing — wouter (3 kB)**: the route table is `src/app/routes.tsx` and it drives the `<Switch>` in `App.tsx`, the sidebar, the breadcrumbs, the right rail and the ⌘K palette, so navigation cannot drift from the pages that exist. Adding a page = a `src/pages/*.tsx` component plus one registry entry (`path`, `group`, `label`, `index`, `kicker`, `title`, `description`, `meta`, `page`). Navigation is real links (`<Link href>`), never `onClick` + `scrollIntoView`; the shell scrolls to top on every location change. State that belongs in the URL lives in the query (`/patterns?pattern=table`), read with `useSearchParams` and validated by a registry helper (`patternFromSearch`). Deep links work because Vite serves the SPA fallback.
|
||
|
||
## Backend data — api / hooks / page separation
|
||
|
||
Live backend pages follow three layers and never skip one:
|
||
|
||
1. **`src/api/client.ts`** — the only `fetch` in the app. It knows the Go API's error envelope `{"error":{"code","message"}}` and turns every failure into an `ApiError` (`status`, `code`, `message`). Components switch on `code` (`validation_failed`, `not_found`, `network`, …), never on message strings. Override the origin with `VITE_API_URL`.
|
||
2. **`src/api/<resource>.ts`** — typed request functions per resource; `backend/internal/<resource>` is the source of truth for the wire format (camelCase JSON, bare arrays for lists).
|
||
3. **`src/hooks/use<Resource>.ts`** — SWR data hooks (`useItems`, `useItem`) plus one actions hook: mutations call the api module, then revalidate every matching cache entry through a key-filter `mutate`. All keys come from one factory (`itemsKeys`) so lists and details invalidate consistently. Lists use `keepPreviousData` so filter switches don't flash skeletons.
|
||
|
||
Pages import hooks only — never `src/api` directly, never `fetch`. The reference wiring is `src/pages/ItemsPage.tsx` at `/items`: skeleton rows on first load, a retry alert when the backend is down (with the exact `cd backend && make migrate-up && make run` command — SQLite is the backend default, no database server), empty state with a create CTA, per-row busy spinner, and inline action errors. Dev wiring: `vite.config.ts` proxies `/api` to the Go server on :8080 (override with `API_URL=… npm run dev`).
|
||
|
||
Adding a live resource: `src/api/<res>.ts` → `src/hooks/use<Res>.ts` → a page → route registry entry (+ `SearchPalette` SEARCH_INDEX entry) → tests mock `src/api/<res>` and assert the hook wiring (see `tests/useItems.test.tsx`).
|
||
|
||
## Structure
|
||
|
||
```
|
||
src/
|
||
tokens.css ← lab() design tokens + dark & light themes (edit this first)
|
||
utils.css ← token-compliant smart CSS utility classes
|
||
index.css ← fonts, tokens & utils import, base resets
|
||
App.css ← application component styles (token-driven, plain CSS)
|
||
App.tsx ← app shell: topbar + sidebar + breadcrumbs + <Switch> routes + right rail
|
||
main.tsx ← mounts App inside the ErrorBoundary, installs the dev error sink
|
||
app/
|
||
AppStateProvider.tsx ← global settings (theme, viewMode, sidebar, ⌘K search) + persistence
|
||
appState.ts ← context, types, useAppState()
|
||
routes.tsx ← route table: paths, headings, nav groups, URL helpers
|
||
persist.ts ← guarded localStorage read/write + storage keys
|
||
auth/ ← useSession() + UserMenu + SessionCard — OIDC identity via the /api proxy
|
||
api/ backend clients: client.ts (fetch wrapper + ApiError) and
|
||
one typed module per resource (items.ts)
|
||
hooks/ SWR data hooks + mutation actions per resource
|
||
(useItems.ts) — the only consumer of src/api
|
||
pages/ ← one component per route (OverviewPage, ButtonsPage, PatternsPage, …)
|
||
dev/
|
||
ErrorBoundary.tsx, errorSink.ts ← render runtime errors into #dev-errors
|
||
utils/
|
||
cn.ts ← conditional classnames utility
|
||
useTimeout.ts ← auto-cleaning setTimeout hook
|
||
components/
|
||
ui/ ← composable UI primitives (Overlay, Dialog, Sheet, Tabs, Alert,
|
||
EmptyState, Table, Progress, Switch, Badge, Card, StatCard,
|
||
Skeleton, Tooltip, Kbd, Avatar, Spinner) + overlayBehavior.ts
|
||
icon-registry.ts← the one icon name → lucide component map (single source)
|
||
examples/ ← reusable pattern components (MetricsDashboard, SettingsPanel,
|
||
ServiceTable, DeploymentModal)
|
||
AIWorkflowSection.tsx ← interactive pattern switcher & showcase
|
||
SearchPalette.tsx ← ⌘K command palette (Overlay behavior + combobox a11y)
|
||
TypographySection.tsx ← type-scale showcase
|
||
IconMatrix.tsx ← interactive Lucide icon explorer (renders iconRegistry)
|
||
Icon.tsx ← <Icon name="…" /> component (types from icon-registry.ts)
|
||
Topbar.tsx ← owns the theme toggle (data-theme + .dark/.light)
|
||
Hero.tsx, CodeSample.tsx, RightRail.tsx, ButtonMatrix.tsx,
|
||
Sidebar.tsx, Section.tsx, FormField.tsx, TokenGrid.tsx,
|
||
MarkdownRenderer.tsx, CodeEditor.tsx, Button.tsx
|
||
data/
|
||
library.ts ← demo content (button examples, color tokens)
|
||
patterns.ts ← pattern catalogue + PatternId (feeds /patterns?pattern=…)
|
||
tests/ ← vitest suites (jsdom): overlay, icon-registry, useTimeout,
|
||
app-state, routing, check-tokens guard
|
||
scripts/check-tokens.mjs ← token contract guard (part of npm run check)
|
||
```
|
||
|
||
## Parallel subagent workflow
|
||
|
||
Multiple agents can work the same checkout concurrently if tasks are **file-disjoint**:
|
||
|
||
- Each agent gets an explicit allow-list of files; shared files (barrels like `components/ui/index.ts`, `utils.css`, `App.tsx`) are edited by the orchestrator only, after agents finish.
|
||
- Agents verify with `npx oxlint <files>`, `npx tsc --noEmit -p tsconfig.app.json`, and `node scripts/check-tokens.mjs` — never `npm run check`/`vite build` in parallel (incremental build info and `dist/` would race).
|
||
- Read-only investigation tasks use the read-only spawn mode; they can run freely alongside writers.
|
||
- New primitives: agents create self-contained files (component + own `.css` import if keyframes/classes are needed); the orchestrator registers exports and showcase usage.
|
||
|
||
## Iteration workflow for visual changes
|
||
|
||
1. Change the token (or component markup) — never a literal.
|
||
2. `npm run check` until green.
|
||
3. Verify live: dev server + preview URL; screenshot at desktop width; toggle light/dark via the topbar button.
|