# Craft UI

> **Version** 0.2.0 · **By** Webcraftsman · **Base** shadcn/ui (radix-nova) · **Motion** `motion/react` · **Icons** Solar (`@solar-icons/react`) · **Stack** Next.js App Router + Tailwind CSS v4
> **Gallery** `/` · **Live demo** `/demo` · **Registry** `/r/{name}.json`

This file is the contract for every Webcraftsman internal web app UI (dashboards, admin tools, back-office). It is written for **AI coding agents first** and for people second.
If you are an agent: treat every **MUST / MUST NOT** as a hard rule, follow the decision tables literally, and copy the code patterns instead of inventing new ones. When a rule and a user request conflict, follow the user and mention the rule you broke.

---

## 0. Quick rules (read this if nothing else)

1. **MUST** install UI from the Craft registry (`npx shadcn@latest add @craft/<name>`), never raw `shadcn add <name>` and never hand-rolled copies.
2. **MUST** use Solar icons. **MUST NOT** import `lucide-react`, `react-icons`, `@heroicons/*`, `@tabler/icons-react` or `@radix-ui/react-icons`.
3. **MUST** use semantic color tokens (`bg-primary`, `text-muted-foreground`, `bg-success/10`). **MUST NOT** use raw palette classes (`bg-blue-500`, `text-gray-600`) or hex values in components.
4. **MUST** follow the shape language (§3.4): pills (`rounded-full`) for buttons, badges, chips and tab triggers; `rounded-3xl` for cards, dialogs and sheets. **MUST NOT** add `shadow-*` anywhere — depth comes from flat fills, borders and radius. `glass` is for floating layers only (§3.4).
5. **MUST** take motion values from `@/lib/motion` (`spring.snappy`, `fadeUp`, …) or the CSS tokens (`duration-(--motion-base) ease-(--ease-spring)`). **MUST NOT** invent durations or easings.
6. **MUST** import Motion from `motion/react`, not `framer-motion`.
7. **MUST** write UI copy in English (see §6.7), with numbers, dates and currency formatted through `lib/format.ts` (en-GB, THB).
8. **MUST** give every icon-only button an `aria-label` and (usually) a `Tooltip`.
9. **MUST** keep one primary (`variant="default"`) button per view region.
10. **MUST NOT** use the browser's built-in UI: no native `<select>` (use `Select` / `Combobox`), no `title="…"` tooltips (use `Tooltip`), no `<input type="date">` (use `DateInput` / `DatePicker`), no `alert()` / `confirm()` / `prompt()` (use `Dialog` / toast), no `<details>` (use `Accordion`). Wrap third-party widgets that render native controls (e.g. react-day-picker's dropdowns) with the system component.

---

## 1. Principles

| Principle | What it means in practice |
|---|---|
| **Calm by default** | White and soft-gray (#F5F5F7) surfaces, one brand color (Webcraftsman blue), colour reserved for meaning (status, primary action, focus). |
| **Flat and soft** | No shadows. Depth comes from fill contrast (gray on white, white on gray), hairline borders and big radii; interactive chips are pills. |
| **Dense but readable** | Built for dashboards and back-office work: 36px controls, 14px UI text, tabular numbers, compact tables. |
| **Motion explains** | Every animation answers "what changed / where did it go / did it work?". No decorative loops. |
| **English-first, Thai-safe** | Copy and formatting are English (en-GB); fonts and line-heights still render Thai data (names, addresses) correctly. |
| **Own the code** | Components are copied into each app (shadcn model). Change them there if you must, but upstream fixes here first. |

---

## 2. Setup

### 2.0 With an AI agent (fastest)

This file is served at **`https://ui.webcraftsman.co/design.md`**. Works with any agent that can read files and run commands (Claude Code, Cursor, GitHub Copilot, Gemini CLI, Windsurf, …). The overview page has the full **setup prompt** to copy; in short:

> Set up Craft UI in this project. Read https://ui.webcraftsman.co/design.md and follow it for all UI work, add the `@craft` registry to components.json, install `@craft/foundation`, wrap the root layout in `<Providers>` with `lang="en"`, and add the Craft rule to the project's agent rules file.

Rule line for `CLAUDE.md` / `AGENTS.md` / `.cursor/rules` (the setup prompt adds it):

> For all UI work, follow https://ui.webcraftsman.co/design.md. Install components only from the @craft registry (`npx shadcn@latest add @craft/<name>`) — never hand-roll components, other icon sets, or native browser controls.

The prompt and rule live in `lib/site.ts` (`agentSetupPrompt`, `agentRulesLine`) — change them there, not in copies.

### 2.1 New app

```bash
pnpm dlx create-next-app@latest my-app --ts --tailwind --eslint --app --use-pnpm
cd my-app
pnpm dlx shadcn@latest init -b radix -p nova
```

Add the registry to `components.json`:

```json
{
  "registries": {
    "@craft": "https://ui.webcraftsman.co/r/{name}.json"
  }
}
```

Install the foundation **first**, then what you need:

```bash
npx shadcn@latest add @craft/foundation      # tokens, fonts (Outfit, Prompt, Plex Mono), motion, providers
npx shadcn@latest add @craft/app-shell        # pulls sidebar, button, tooltip, … automatically
npx shadcn@latest add @craft/orders-table @craft/customer-sheet
```

The foundation pulls the font items `@craft/font-outfit`, `@craft/font-prompt` and `@craft/font-plex-mono`; install them individually only if you are assembling a custom foundation.

Wrap the root layout (the CLI adds the fonts; you add `Providers`, `lang="en"` and `suppressHydrationWarning`):

```tsx
// app/layout.tsx
import { Providers } from "@/components/providers"

<html lang="en" suppressHydrationWarning className={/* font variables added by the CLI */}>
  <body>
    <Providers>{children}</Providers>
  </body>
</html>
```

`Providers` = `ThemeProvider` (next-themes, class strategy) + `NuqsAdapter` + `MotionConfig reducedMotion="user"` + `HapticsProvider` (§7.1) + `TooltipProvider` + `Toaster`.

### 2.2 Lint guard

Add to `eslint.config.mjs`:

```js
{
  rules: {
    "no-restricted-imports": ["error", {
      paths: [
        { name: "lucide-react", message: "Use Solar icons (@solar-icons/react/linear)." },
        { name: "framer-motion", message: "Import from \"motion/react\"." },
      ],
      patterns: [{ group: ["react-icons", "react-icons/*", "@heroicons/*", "@tabler/icons-react", "@radix-ui/react-icons"], message: "Use Solar icons." }],
    }],
  },
}
```

> `components.json` keeps `"iconLibrary": "lucide"` because the shadcn CLI has no Solar option. That's fine — Craft registry items already import Solar. If you ever add an **upstream** shadcn component, replace its lucide imports using the map in §4.4 (and strip any `shadow-*` classes, §3.4).

---

## 3. Tokens

All tokens live in `app/globals.css` (installed by `@craft/foundation`) and are exposed to Tailwind via `@theme inline`.

### 3.1 Brand

```css
:root {
  --brand-h: 269.3;  /* Webcraftsman blue #2F3EE3 = oklch(0.479 0.243 269.3) */
  --brand-c: 0.243;  /* chroma */
  --brand: oklch(0.479 var(--brand-c) var(--brand-h));      /* exact logo colour, same in dark mode */
  --brand-secondary: oklch(0.495 0.264 284.8);               /* Webcraftsman violet #6025ED */
}
.dark {
  --brand-secondary: oklch(0.66 0.2 284.8);
}
```

- `primary` and `chart-1` in light mode **are** `--brand` (#2F3EE3). White text on it passes AA (≥ 4.5:1).
- Dark mode lifts `primary` for contrast on dark surfaces; `--brand` / `text-brand` stays #2F3EE3 — use it only for the logo.
- `--brand-secondary` (`bg-brand-secondary`, `text-brand-secondary`) is the violet accent. Use it sparingly for brand moments (e.g. a highlight tile or illustration accent), **never** for a second primary button, status or chart series.
- Foreground is near-black `oklch(0.13 0.004 h)`, tinted with the brand hue.

**Logo** — `<LogoMark />` is the Webcraftsman mark (2:1, `viewBox="0 0 20 10"`, `currentColor`, defaults to `text-brand`); `<Logo />` is mark + "Craft" wordmark. Size the mark by height and keep the 2:1 ratio. On a coloured or dark fill set the colour explicitly: `<LogoMark className="text-primary-foreground" />`. Don't put the mark inside a tinted box, stretch it, or recolour it with anything except brand, `foreground`, `background` or `primary-foreground`. Favicon is `app/icon.svg`.

Every brand-tinted token (`primary`, `accent`, `ring`, `chart-1`, sidebar, neutrals' tint) derives from `--brand-h` / `--brand-c`.
**To rebrand: convert the brand hex to OKLCH (oklch.com) and change these two numbers only** (plus `--brand-secondary` if the accent changes). Then re-check contrast of `primary` on `primary-foreground` (≥ 4.5:1).

### 3.2 Color (semantic)

| Token | Tailwind | Use for | Don't use for |
|---|---|---|---|
| `background` / `foreground` | `bg-background` `text-foreground` | Page (white), body text (near-black) | — |
| `card` / `popover` | `bg-card` `bg-popover` | Raised surfaces, overlays | Page background |
| `primary` | `bg-primary` `text-primary` | The one main action, active nav, links, focus ring | Large backgrounds, decoration |
| `brand-secondary` | `bg-brand-secondary` | Rare brand accent (violet #6025ED) | Buttons, status, charts, text on gray |
| `secondary` | `bg-secondary` | Secondary buttons, soft fills (#F5F5F7) | Status |
| `muted` / `muted-foreground` | `bg-muted` `text-muted-foreground` | Surfaces/tracks (#F5F5F7), descriptions, placeholders, meta text | Body text that must be read |
| `accent` / `accent-foreground` | `bg-accent` | Hover/selected rows, highlighted notes, icon tiles | Buttons |
| `destructive` | `text-destructive` `bg-destructive/10` | Delete, errors, negative trend | Warnings |
| `success` | `text-success` `bg-success/10` | Paid, completed, positive trend | Primary actions |
| `warning` | `bg-warning/15 text-foreground` | Pending, needs attention | Errors |
| `border` / `input` / `ring` | `border` `border-input` `ring-ring` | Dividers, field borders, focus | — |
| `sidebar` / `sidebar-accent` | `bg-sidebar` `bg-sidebar-accent` | Sidebar (gray #F5F5F7); hover/active item = **white pill** (`--sidebar-accent: oklch(1 0 0)`) | Content areas |
| `chart-1…5` | `var(--chart-n)` | Data series, in order | UI chrome |

- `--secondary` and `--muted` are both `oklch(0.971 0.003 h)` = **#F5F5F7**, the Webcraftsman soft-gray fill.
- `--muted-foreground` is `oklch(0.52 0.011 h)`, ≥ 5:1 on #F5F5F7. Webcraftsman's marketing gray **#87878F** is only 3.3:1 on #F5F5F7 — agents **MUST NOT** use #87878F (or anything lighter than `muted-foreground`) for text.
- Status badges always use the **tinted** pattern: `bg-<status>/10 text-<status> border-transparent` (warning uses `/15` + `text-foreground` for contrast).

**Charts** — `--chart-1…5` = blue (brand #2F3EE3) · rust · teal · plum · slate. Use them **in order**, max 5 series; fold the rest into "Other" on `chart-5`. Each colour is ≥ 3:1 against the card in both themes, and every pair stays distinguishable for red–green colour blindness (min ΔEok ≈ 11 light / 14 dark, simulated) — the palette leans on lightness steps, so don't swap in lookalike hues. Still label series directly or with a legend; never rely on colour alone.

### 3.3 Typography

| Role | Class | Notes |
|---|---|---|
| Page title | `text-2xl font-semibold tracking-tight` | One per page (`h1`) |
| Section title | `text-lg font-semibold` | `h2` |
| Card title | `CardTitle` (default) | KPI value: `text-2xl font-semibold` |
| UI / body | `text-sm` (14px) | Default for labels, cells, descriptions |
| Long-form body | `text-base` (16px) | Docs, onboarding |
| Meta | `text-xs text-muted-foreground` | Timestamps, hints |
| Code / IDs | `font-mono text-xs` | Order IDs, keys |

- Fonts: **Outfit** (Latin, `--font-outfit`) → **Prompt** (Thai fallback, `--font-prompt`, `thai` subset) → **IBM Plex Mono** (`font-mono`). Stack: `--font-sans: var(--font-outfit), var(--font-prompt), …`. Registry items: `@craft/font-outfit`, `@craft/font-prompt`, `@craft/font-plex-mono` (all pulled by `@craft/foundation`).
- Webcraftsman's **marketing-site** type rules (regular weight only, 20px minimum) deliberately **do not apply** to app UI. Apps keep the scale above: 14px UI text, `font-medium` / `font-semibold` for hierarchy.
- Line-height is built into the type scale (`--text-*--line-height` in `@theme`), English-first with room for Thai stacked vowels: xs 1.5 · sm 1.55 · base 1.6 · lg 1.55 · xl 1.45 · 2xl 1.35 · 3xl 1.25. Tailwind's `text-*` classes set line-height per element, so the scale itself carries it.
- **MUST NOT** use `leading-none` / `leading-tight` on anything that can hold user data (names, addresses, free text) — Thai stacked vowels/tone marks overlap the next line. `leading-snug` is the tightest allowed (single-line labels, titles).
- **MUST NOT** use negative tracking on body text; `tracking-tight` is allowed on headings only.
- Numbers that are compared (tables, KPIs, money) **MUST** be `tabular-nums` (automatic inside `Table` and `[data-numeric]`).

### 3.4 Shape, spacing, size

**Shape language (Webcraftsman)**

| Element | Shape | Notes |
|---|---|---|
| Button, Badge, StatusBadge, Toggle / Toggle Group items, chips (MultiSelect), Tabs triggers, CountBadge | **Pill** `rounded-full` | Including icon buttons (circles) |
| Tabs list | Pill segmented control | Gray track (`bg-muted`), white active pill; `variant="line"` keeps the underline |
| Card, Dialog, Sheet (floating side panel), Alert, EmptyState tile | **`rounded-3xl`** (24px) | Large radius is the signature; nested surfaces step down (`rounded-2xl`, `rounded-xl`) |
| Input, Textarea, Select trigger, Input Group, DateInput, FileDropzone | `rounded-xl` | Control radius — same for single- and multi-line fields |
| Menu / list items (Dropdown, Command, Select items) | `rounded-lg` / `rounded-xl` | Inside a `rounded-2xl` popover |
| Sidebar nav items | Pill, white on gray (`bg-sidebar-accent`) | Hover and active |

- **No shadows anywhere.** Agents **MUST NOT** add `shadow-*`, `drop-shadow-*` or custom `box-shadow`. Separate surfaces with fill contrast (#F5F5F7 on white, white on #F5F5F7), `border`, and radius. Focus uses the `ring`, not a shadow.
- **MUST NOT** mix shapes in one group (e.g. a pill button next to a `rounded-md` button). Upstream shadcn code you port must be converted.

**Glass (floating layers only)**

The `glass` utility (`app/globals.css`, `@utility glass`) is a frosted, translucent fill: `background-color: var(--glass)`, `border-color: var(--glass-border)`, `backdrop-filter: blur(var(--glass-blur)) saturate(170%)`. Pair it with `border` (the utility sets the colour, not the width).

| Use `glass` on | **MUST NOT** use `glass` on |
|---|---|
| Popover, Dropdown Menu, Select content, Toast, the sticky App Shell top bar, `DataTableBulkBar` / `DynamicIsland` | Cards, sidebars, page sections, Dialog / Sheet panels, anything whose text must be read over arbitrary content |

| Token | Light | Dark |
|---|---|---|
| `--glass` | `popover` at 72% | `popover` at 70% |
| `--glass-border` | foreground at 8% | white at 10% |
| `--glass-blur` | `24px` | `24px` |

- It is built into the components above — don't re-add it. Only floating layers you build yourself take `glass`.
- **Dialogs stay solid.** Their overlay blurs the page (`backdrop-blur-sm`); the panel itself is `bg-popover`.
- Falls back to solid `--popover` when the viewer sets `prefers-reduced-transparency: reduce` or the browser lacks `backdrop-filter`. **MUST NOT** hand-roll `backdrop-blur-*` + translucent fills instead — use the utility so the fallback applies.
- **MUST NOT** stack glass on glass. Anything inside a glass layer is transparent (e.g. `Command` inside a `Popover` has no fill of its own); nested surfaces inside glass use `bg-muted/…` tints, not another `glass`.
- Glass doesn't replace the border or add a shadow — the no-shadow rule still holds.

| Token | Value | Use |
|---|---|---|
| `--radius` | `0.75rem` (12px) | Base. `rounded-lg` = 12px, `rounded-xl` = 16px (controls), `rounded-2xl` / `rounded-3xl` for larger surfaces |
| Control height | `h-(--control-h)` = 36px default (32px compact) · `h-8` sm · `h-10` lg | Buttons, inputs, selects, input groups, tabs. Custom controls must use the token, not `h-9` |
| Icon button | `size-(--control-h)` · `icon-sm` = 32px | Toolbars, row actions use `icon-sm` |
| Table rows | `--table-head-h` 40px (36) · `--table-cell-py` 8px (4) | Built into `TableHead` / `TableCell` |
| Page padding | `p-4 md:p-6` | Inside `AppShell` |
| Section gap | `gap-4 md:gap-6` | Between page sections/cards |
| Form field gap | `gap-2` (label→field) · `gap-5` (field→field) | |

**Density** is a per-viewer preference: `useDensity()` (hooks/use-density.ts) stores it and sets `data-density="compact"` on `<html>`, which swaps the three tokens above. The account menu in `AppShell` has the switch (Comfortable / Compact). Add `densityScript` to `<head>` in the root layout so it applies before first paint.

### 3.5 Motion

| Token | CSS | JS (`@/lib/motion`) | Use |
|---|---|---|---|
| fast | `--motion-fast: 120ms` | `duration.fast` | Exits, hovers |
| base | `--motion-base: 180ms` | `duration.base` | Color/press transitions, overlays' backdrop |
| slow | `--motion-slow: 260ms` | `duration.slow` | Edge panels (sheet) |
| spring (CSS) | `--motion-spring: 320ms` + `--ease-spring` | — | Overlay enter (dialog, popover, menu, select, tooltip) |
| ease-out | `--ease-out` | `ease.out` | Everything non-spring entering |
| ease-in | `--ease-in` | `ease.in` | Everything exiting |
| snappy | — | `spring.snappy` (500/32) | **Default** for interactive motion: press, toggle, layout, nav indicator |
| gentle | — | `spring.gentle` (300/30) | Large surfaces, height changes, card entrance |
| bouncy | — | `spring.bouncy` (600/22) | Icon swaps, checkmarks, tiny badges only |
| morph | — | `spring.morph` (bounce 0.22, 0.5s) | Shape morphs only: trigger → panel, island resizes, ActionButton width |

Presets in `@/lib/motion`: `pressable`, `popIn`, `fadeUp`, `listStagger` (30ms), `iconSwap`, `morphContent` (content inside a morphing shape: blur 6px + scale 0.96 → sharp, `duration.slow` ease-out in, `duration.fast` ease-in out).

**Built-in motion per component** (don't re-add):

| Component | Motion |
|---|---|
| Tabs, Toggle Group (single), App Shell nav | Selection pill glides (`layoutId` + `spring.snappy`) |
| Dialog, Popover, Dropdown, Select, Tooltip | Grow from the trigger side (origin-aware scale) on the CSS spring **with a 6px blur-in** (`--tw-enter-blur`, set globally on `[data-slot$="-content"]`), fast ease-in out with a 4px blur |
| Sheet | Slide, ease-out 260ms, no overshoot. Without `side`: up from the **bottom on phones** (< 768px, grab handle, max 90dvh, safe-area padding), from the **right** on larger screens |
| Accordion | Height ease-out 260ms, arrow rotates |
| Checkbox / Switch | Check zooms in / thumb slides on spring; Switch thumb stretches toward its travel while pressed |
| Button | Press scale 0.97; `loading` overlays spinner |
| FieldError | Height + fade in; Field shakes once when invalid |
| Icon | Linear → Bold swap on bouncy spring |
| CountBadge | Pop in/out; digits roll by direction |
| AnimatedNumber, Progress | Ease-out, never overshoot |
| EmptyState, KPI cards, lists | Stagger `fadeUp` |
| Dialog, Popover (opened from their Trigger) | **Morph by default**: the panel grows out of the trigger's box and folds back into it on close (`lib/morph.ts`, FLIP on the Web Animations API; Radix keeps positioning and focus). Opened without a trigger (⌘K, controlled `open`), it scales in with a blur instead |
| Popover (in place) | By default the panel opens **over** its trigger (negative `sideOffset` of the trigger's height; Radix flips it upward when there's no room below and shifts it on screen) and the trigger fades out — the pill becomes the panel. Opens beside the trigger instead with a `<PopoverAnchor>` (fields you keep typing in, e.g. DateInput) or `<PopoverContent inPlace={false}>` |
| ActionButton | Width springs to each label, icon pops; error shakes once |
| StatusBadge | Tint cross-fades on tone change |
| DynamicIsland, DataTableBulkBar | Shape springs to the new size; old content blur-fades out under the new |

**Rules**

- Radix overlays animate via CSS automatically (foundation CSS targets `[data-slot$="-content"]`). Don't wrap them in Motion.
- Use Motion for: layout changes (`layout`, `layoutId`), list enter/exit (`AnimatePresence`), stateful swaps (`<Icon>`), counters (`AnimatedNumber`).
- Enter = spring or ease-out. Exit = `duration.fast` + `ease.in` (things leave faster than they arrive).
- **MUST NOT** overshoot when something is anchored to a screen edge (sheets, sidebars) or represents a real value (counters, progress).
- **MUST NOT** animate on every render, loop animations, or animate > 1 thing per interaction unless it's a staggered list.
- **MUST** keep `MotionConfig reducedMotion="user"` at the root; the global `prefers-reduced-motion` rule shortens CSS animations to 1ms.
- Stagger at most ~8 items; beyond that, animate the container.

**Morph** — a shape that turns into another shape (pill → panel, island resizes). Text never morphs letter by letter — labels that change in place just swap. Use it only where the object really transforms; pick the component, don't hand-roll `layoutId` morphs.

| Need | Use |
|---|---|
| A panel that opens from a button (form, share, filters, confirmation) | **Dialog** / **Popover** with their Trigger — they morph by default |
| Async action that finishes in place (save, copy, send) | `ActionButton` — `onAction` returns a promise: loading → success (`successLabel`, default "Done") or throw → error (`errorLabel`, default "Try again"); resets after `resetAfter` ms (1800) |
| Floating status or selection (bulk bar, upload progress) | `DynamicIsland` — change `view` when the content changes meaning; position it yourself |

- Dialog and Popover morph on their own whenever they open from `DialogTrigger` / `PopoverTrigger` (including `asChild`) — **MUST NOT** wrap them in extra morph code. To open one without the morph, control `open` without a Trigger.
- **MUST NOT** morph navigation (links, route changes, tabs between pages).
- Content inside a morphing shape **MUST** blur-fade in *after* the shape grows (`morphContent`, or the built-in delay) — never resize and cross-fade text at the same time.
- Reduced motion is handled by `MotionConfig reducedMotion="user"`; don't add your own checks or timers.
- `ActionButton` is for actions that finish in place; a submit that closes a sheet uses `Button loading`.

```tsx
import { motion, AnimatePresence } from "motion/react"
import { fadeUp, listStagger, spring } from "@/lib/motion"

<motion.ul variants={listStagger} initial="hidden" animate="visible">
  {items.map((i) => <motion.li key={i.id} variants={fadeUp}>…</motion.li>)}
</motion.ul>

{active && <motion.span layoutId="tab-indicator" transition={spring.snappy} className="absolute inset-0 -z-10 rounded-full bg-background" />}
```

### 3.6 Not yet

These Webcraftsman marketing-site effects are **not** part of Craft UI 0.2.0. Agents **MUST NOT** recreate them by hand in apps:

- Brand gradient (#2F3EE3 → #6025ED) on surfaces, text or buttons — planned as a token, not yet.
- Blur-reveal or scroll-triggered entrance effects.

(Glass and morph shipped in 0.2.0 — see §3.4 and §3.5.)

---

## 4. Icons (Solar)

### 4.1 Styles

| Style | Import | Use |
|---|---|---|
| **Linear** | `@solar-icons/react/linear` | **Default everywhere** |
| **Bold** | `@solar-icons/react/bold` | Active/selected state only (via `<Icon>`) |
| **Bold Duotone** | `@solar-icons/react/bold-duotone` | Empty states and feature tiles only |
| Line Duotone, Outline, Broken | — | **MUST NOT** use |

> Non-goal for v1: Webcraftsman's marketing site uses lucide, but Craft UI stays on Solar and lucide stays banned. Switching icon sets is a possible future decision, not something to anticipate in app code.

### 4.2 Static icons

```tsx
import { MagnifierIcon, AddCircleIcon } from "@solar-icons/react/linear"

<Button><AddCircleIcon />Add customer</Button>   {/* size comes from the component (size-4) */}
```

- Inside shadcn components, **don't** set a size — they size `svg` to `size-4` (16px). Elsewhere: `size-4` (inline), `size-5` (nav/toolbars), `size-6` (empty state tile), `size-8` (feature tiles).
- Color inherits `currentColor`. Use `text-*` tokens only.
- Solar icons are plain SVG components with no React context, so they work in Server Components.

### 4.3 Stateful icons — `<Icon>`

Anything whose icon reflects state (active nav item, favourite, follow, show/hide password, theme toggle) **MUST** use `<Icon>` so the swap is animated consistently:

```tsx
import { HomeIcon } from "@solar-icons/react/linear"
import { HomeIcon as HomeBold } from "@solar-icons/react/bold"
import { Icon } from "@/components/ui/icon"

<Icon as={HomeIcon} activeAs={HomeBold} active={isCurrent} />
<Icon as={EyeIcon} activeAs={EyeClosedIcon} active={visible} />   // two different glyphs is fine too
```

Pass `label` only when the icon is the sole content; otherwise it's `aria-hidden`.

### 4.4 Lucide → Solar map (for porting upstream shadcn code)

| lucide | Solar (linear) |
|---|---|
| `XIcon` | `CloseIcon` |
| `SearchIcon` | `MagnifierIcon` |
| `CheckIcon` | `CheckIcon` |
| `ChevronDown/Up/Left/RightIcon` | `AltArrowDown/Up/Left/RightIcon` |
| `MoreHorizontalIcon` | `MenuDotsIcon` |
| `PanelLeftIcon` | `SidebarMinimalisticIcon` |
| `CircleCheckIcon` | `CheckCircleIcon` |
| `InfoIcon` | `InfoCircleIcon` |
| `TriangleAlertIcon` | `DangerTriangleIcon` |
| `OctagonXIcon` | `CloseCircleIcon` |
| `Loader2Icon` | `<Spinner />` (Solar has no spinner) |
| `PlusIcon` | `AddIcon` / `AddCircleIcon` |
| `TrashIcon` | `TrashBinTrashIcon` |
| `PencilIcon` | `PenIcon` |
| `SettingsIcon` | `SettingsIcon` |
| `UserIcon` | `UserRoundedIcon` |
| `LogOutIcon` | `Logout2Icon` |
| `DownloadIcon` | `DownloadMinimalisticIcon` |
| `ArrowUpDownIcon` | `SortVerticalIcon` |

Browse all 1,451 glyphs: https://solar-icons.vercel.app — component name = PascalCase(kebab name) + `Icon` (e.g. `cart-large-2` → `CartLarge2Icon`).

### 4.5 Attribution

Solar icons by **480 Design**, licensed **CC BY 4.0**. Every app **MUST** credit them once (footer, About, or Licenses page): *"Icons by 480 Design (Solar), CC BY 4.0"*.

---

## 5. Components

Install: `npx shadcn@latest add @craft/<name>`. Live examples and source: `/docs/components/<name>`.

### 5.1 Actions

**Button** — pill-shaped. `default` (primary, one per region) · `secondary` (gray #F5F5F7 fill) · `outline` (most secondary actions) · `ghost` (toolbars, row actions) · `destructive` · `link`. Sizes `sm | default | lg | icon | icon-sm` (icon sizes are circles).
- Icon before label for actions, after label only for "next/open" (`ArrowRightIcon`).
- Pending: **`<Button loading={pending}>Save changes</Button>`** — keeps colour, width and focus; spinner overlays the label; clicks/submits are ignored. **MUST NOT** fake it with `disabled` + manual spinner.
- Disabled: `disabled` shows `cursor-not-allowed` and no hover. Never disable without explaining why (tooltip or helper text).
- Motion: CSS press scale 0.97. Don't wrap Button in `motion.*`. No shadow, no gradient.
- Haptics: presses fire `light` (`rigid` for `destructive`) on phones. `haptic="success"` (any preset) overrides, `haptic={false}` silences (§7.1).
- **`button.tsx` is a client component** (haptics). `buttonVariants` also lives in `components/ui/button-variants.ts`; Server Components that style links as buttons **MUST** import it from there: `import { buttonVariants } from "@/components/ui/button-variants"` → `<Link className={buttonVariants({ variant: "outline" })} />`.
- Async save/copy/send that ends in place → `ActionButton` (§3.5 Morph).

**Toggle Group** — pill segmented control for view/range switching (≤ 5 options). `variant="outline" size="sm"` in card headers. With `type="single"` the selection pill glides between items automatically.

### 5.2 Forms

**Field** wraps every form control: `Field` → `FieldLabel` → control → `FieldDescription` → `FieldError`. Label **above** the field, single column, `gap-5` between fields.

```tsx
<Field data-invalid={!!errors.email}>
  <FieldLabel htmlFor="email">Email</FieldLabel>
  <Input id="email" name="email" aria-invalid={!!errors.email} />
  <FieldDescription>We send receipts here</FieldDescription>
  <FieldError>{errors.email}</FieldError>   {/* animates in/out; renders nothing when empty */}
</Field>
```

- **Input Group** for leading icons (search), prefixes (`https://`), trailing buttons (copy, reveal password).
- **Select** for ≤ 12 static options; **Combobox** (Popover + Command) for more or searchable options.
- **Checkbox** for independent choices; **Radio Group** for 2–5 mutually exclusive visible options (use bordered "card" radios for important choices); **Switch** only for settings that take effect immediately (no Save button).
- **Real forms use `FormField`** (react-hook-form + zod). It renders the Field parts, sets `data-invalid`, wires `id` / `aria-invalid` / `aria-describedby`, and focuses the first invalid field on submit:

  ```tsx
  const form = useForm<z.input<typeof schema>, unknown, z.output<typeof schema>>({ resolver: zodResolver(schema), defaultValues })
  <FormField control={form.control} name="email" label="Email" description="We send receipts here"
    render={({ field, control }) => <Input type="email" {...field} {...control} />} />
  ```

  Non-input controls take `value` / `onValueChange` (Select, RadioGroup), `checked` / `onCheckedChange` (Switch) or `value` / `onChange` (DatePicker) from `field`, plus `{...control}`.
- **Schemas** come from `lib/validation.ts`: `required("Customer name")`, `email`, `thaiMobile` (accepts dashes, outputs digits), `thaiTaxId` (13 digits + check digit), `optional(schema)` (empty → `undefined`). The Thai validators stay because internal apps handle Thai customer data.
- Validation timing: on submit, then live while the user fixes it (RHF default). Server-side checks (e.g. email already used) → `form.setError(name, { message }, { shouldFocus: true })` with a `Spinner` inside the field while checking. Messages go in `FieldError` — never toasts.
- **Dates** — Gregorian (CE) by default, shown en-GB ("1 Oct 2026"). Pass `era="be"` only for Thai-facing data that must show Buddhist-era years (Calendar then uses `@daypicker/buddhist` with the Thai locale).
  - `DateInput` — **default for form fields.** Typeable + calendar button. Parses via `parseDateInput`: `1/10/2026`, `01-10-26`, `2026-10-01`, `1 Oct 2026`, Thai month names, and BE years (≥ 2400) — day-first (DD/MM/YYYY), never US order. Rewrites to `1 Oct 2026` on blur/Enter. Unparseable or disallowed text stays, turns red and reports `undefined`. Pass `onBlur={field.onBlur}` in forms.
  - `DatePicker` — button-only, for filters and short pickers. `×` clears (`clearable`, default on).
  - `DateRangePicker` — filters. Presets: Today, Last 7/30 days, This month, Last month, This quarter, This year, **This fiscal year** (1 Oct–30 Sep). The range previews under the pointer before the second click. `maxDays` caps the span (and hides longer presets); `clearable={false}` when "no range" isn't valid. When data has a fixed end (reports, demos) use `createDateRangePresets(() => lastDay)`.
  - `Calendar` (inside all of them): Monday-first for CE (Sunday-first with `era="be"`), Arabic digits; months slide in the direction of travel; click the month caption for a 12-month grid, then the year for a 12-year grid (Esc steps back, never closes the popover); "Today" jumps to the current month (`showToday={false}` to hide).
  - All three take `era` (`"ce"` default, `"be"` opt-in), `disabledDays` (any DayPicker matcher, e.g. `{ after: new Date() }`), `captionLayout="dropdown"` + `startMonth` / `endMonth` for far-away dates (birthdays).
  - **Sending dates to an API: `toISODate(d)`** from `lib/format.ts` → `"2026-10-01"`. Never `d.toISOString()` — it converts to UTC and gives the previous day for local midnight in Bangkok (UTC+7). Read them back with `parseISODate(s)` (not `new Date(s)`, which is UTC too).
- Hover: controls darken their border on hover (built in); focus ring wins over hover; invalid wins over both.
- Submit area: right-aligned, `[Cancel (outline)] [Primary]`.

### 5.3 Overlays

| Need | Use |
|---|---|
| Confirm a destructive/irreversible action | **Dialog** (`rounded-3xl`; title as question, description states consequence, destructive button) |
| Create / edit an entity, view details | **Sheet** (leave `side` unset: bottom sheet on phones, right panel on desktop; `rounded-3xl`, `sm:max-w-md`, scrolling body with `min-h-0 flex-1 overflow-y-auto`, footer actions) |
| Small contextual form or info (share, filters, quick add) | **Popover** — opens in place over its trigger and folds back into it |
| List of actions on a thing | **Dropdown Menu** (destructive item last, separated, `variant="destructive"`) |
| Name an icon-only control | **Tooltip** (300ms delay) |
| Global search / commands | **Command** dialog on ⌘K (in `AppShell`) |

Overlays separate from the page by the backdrop and their border — never a shadow. Floating layers (popover, menu, select, toast) are `glass`; dialogs and sheets stay solid (§3.4).

### 5.4 Feedback

| Situation | Use |
|---|---|
| Result of a user action (saved, deleted, exported) | **Toast** — `toast.success/error/promise`; add `action: { label: "Undo" }` for undoable deletes |
| Persistent page-level message (maintenance, billing) | **Alert** (dismissible with height animation) |
| Loading > 300ms with known layout | **Skeleton** matching the final layout |
| Loading inside a control | **Spinner** |
| Progress toward a known total | **Progress** |
| Nothing to show | **Empty State** — Bold Duotone icon + title + why + one action |
| Unread / pending count on an icon | **Count Badge** (positioned `absolute -top-0.5 -right-0.5` on the icon button) |

Never show a toast for something the user can already see changed in place.

**Toast anatomy** (built into `Toaster`): tinted icon disc by type (`success` · `error` · `warning` · `info` · loading spinner) → title (`font-medium`, short, past tense: "Changes saved") → optional description (what/where: "They apply immediately") → optional action button (one verb: "Undo"). ✕ shows on hover. Plain `toast("…")` has no icon — use it for neutral notices with an action. Don't put long text or links in toasts; if it needs reading, it's an Alert.

### 5.5 Data display

- **Card**: `rounded-3xl`, flat (border or #F5F5F7 fill, no shadow). `CardHeader` (title, description, `CardAction` top-right) → `CardContent` → `CardFooter`.
- **Badge**: pill. Status = tinted pattern (§3.2); counts = `default`.
- **Table**: header `bg-muted/40`; numbers right-aligned + `tabular-nums`; IDs `font-mono text-xs`; row actions = `ghost icon-sm` dropdown in last column; selection checkbox first column.
- **Data Table** block (`@craft/orders-table`) is the reference: TanStack Table v8, search + filter toolbar, sortable headers (`SortVerticalIcon`), selection count, pagination footer, `EmptyState` when filtered to zero, rows animate with `layout="position"`.
- **Chart**: Recharts via `ChartContainer`; colors from `--chart-1…5` in order; area/line for time series, bar for categories; tooltips via `ChartTooltipContent`; dates and money via `lib/format.ts` (en-GB, THB).
- **Animated Number** for KPI values (counts up once in view; ease-out, never overshoots).
- **Avatar**: initials fallback (Latin or Thai initials both render via the font stack).

### 5.6 Navigation

- Every authenticated app uses the **App Shell** block: collapsible sidebar (`collapsible="icon"`, `variant="inset"`, gray #F5F5F7), top bar with `SidebarTrigger`, breadcrumb, ⌘K search, theme toggle, notifications, account menu.
- Nav items: Linear icon → Bold when active via `<Icon>`, plus the animated `layoutId` **white pill** (`bg-sidebar-accent`) on the gray sidebar. Max ~7 top-level items; group with `SidebarGroupLabel`.
- **Tabs** for peer views inside one page/card — a pill segmented control: gray track, white active pill that glides between triggers (or underline with `variant="line"`); works controlled or uncontrolled. **Breadcrumb** for hierarchy (≥ 2 levels).
- **Accordion** for FAQs and "advanced" sections; never for primary content the user must see.

### 5.7 Craft-only components

| Component | Why it exists |
|---|---|
| `Icon` | Consistent animated Linear→Bold swaps |
| `Logo` / `LogoMark` | Webcraftsman mark (2:1, `text-brand`) and mark + "Craft" wordmark, `currentColor` |
| `StatusBadge` | Record status: pill with dot + label, tones `success · warning · info · danger · neutral`; `live` pulses for in-progress states. Map domain states → tones once, next to the data. Use instead of hand-tinted `Badge`s |
| `MultiSelect` | Several values from a list (tags, branches, assignees) — pill chips, search, groups, select all/clear. ≤ 5 always-visible options → checkboxes instead |
| `FileDropzone` | Uploads: drag/drop/click/paste, per-file progress, error + retry, rejection reasons. Pass `onUpload(file, onProgress)` |
| `Stepper` | Multi-step flows (import, onboarding). Only completed steps are clickable |
| `Timeline` | Activity feeds / record history with relative time ("3 minutes ago") |
| `Kbd` / `KbdGroup` | Shortcut hints; `then` for sequences (G then O) |
| `Spinner` | Solar has no loader glyph |
| `AnimatedNumber` | KPI counters formatted through `lib/format.ts` |
| `EmptyState` | Standard empty/zero-result pattern with staggered entrance |
| `CountBadge` | Unread/cart counts: pops in/out, digits roll by direction, `99+` cap, hidden at 0 |
| `ActionButton` | Async action (save, copy, send) that morphs loading → success / error and resets; success/error haptics |
| `DynamicIsland` | Floating glass pill that resizes to fit its content (status, selection, progress); powers the bulk bar |
| `HapticsToggle` | Viewer on/off switch for phone haptics ("Haptic feedback"); for settings pages |

---

## 6. Patterns

### 6.1 Page anatomy

```
AppShell
└─ header row: h1 + description (left) · actions (right: outline secondary, primary last)
└─ optional Alert
└─ KPI row (KpiCards, 4 across on xl, 2 on sm)
└─ main grid: lg:grid-cols-3 → chart (col-span-2) + side card
└─ section: h2 + description → table card
```

### 6.2 Loading

- < 300ms: show nothing. 300ms–10s: Skeleton in final layout (`aria-busy`). Long jobs: Progress or `toast.promise`.
- Never replace a whole page with a spinner.

### 6.3 Empty & error

- Empty first-use: Bold Duotone icon + what this area is for + primary action ("Create project").
- Empty from filters: say so + "Clear filters" action.
- Error: say what failed and what to do; offer retry. Never show raw error codes alone.

### 6.4 Forms

- Create/edit in a Sheet; long multi-section settings get their own page with Cards per section.
- Optimistic UI only for reversible actions. Destructive → Dialog confirm (or toast with undo for soft deletes).
- After submit: pending state (`loading` on the submit button, from `formState.isSubmitting`) → close sheet → toast success. Field errors → inline. Save failed (network/server) → keep the sheet open, keep the values, show a destructive `Alert` at the top of the form. Reset the form when the sheet **opens**, not when it closes (no flash of empty fields).

### 6.5 Tables

- ≥ 20 rows → paginate (8–25 per page) or virtualize. Default sort is newest first.
- **Toolbar** (left → right): search (name/email/ID at minimum) · one `DataTableFacetedFilter` per categorical column (multi-select with live counts; column `filterFn: facetFilterFn`, table `getFacetedRowModel()` + `getFacetedUniqueValues()`) · "Clear filters" when anything is filtered · right side: `DataTableViewOptions` (show/hide columns via `meta.label`; persist `columnVisibility` with `useLocalStorage`) and export.
- **Bulk actions** live in `DataTableBulkBar` — floats up from the bottom only while rows are selected ("3 selected", ≤ 3 actions, ✕ / Esc clears). Never put bulk actions in the toolbar. The bar is a glass `DynamicIsland` pill with a border, not a shadow.
- **Footer** is `DataTablePagination`: "Showing 9–16 of 60" (or the selection count), rows per page (8/20/50), first/prev/next/last.
- Pieces live in `components/blocks/data-table/`; `OrdersTable` is the reference composition.
- **Thousands of rows to scan** → `VirtualTable` (only visible rows render, sticky header, fixed 40px rows, no row animation). Filtering + bulk actions on a page of results → the Data Table.
- **View state lives in the URL** via nuqs: `useTableUrlState({ searchColumn, filterColumns, facetColumns, defaultSort, pageSize })` returns TanStack-ready `sorting` / `columnFilters` / `pagination` + handlers (`?q=&status=paid,shipped&sort=date.desc&page=2`; `facetColumns` hold string[]). Defaults stay out of the URL; search updates it after 300 ms with `replace`; paging forward `push`es (Back = previous page). Row selection stays local. Page-level filters (date range) use `useQueryStates` with `parseAsLocalDate` from `lib/search-params.ts` — never nuqs' `parseAsIsoDate`, which shifts dates by a day in UTC+7.
- Anything that reads the URL must sit inside `<Suspense>` on prerendered pages. Memoise filtered data on primitive keys (e.g. `date.getTime()`), not on the Date objects nuqs returns, or the table will keep resetting to page 1.

### 6.6 Responsive

- Mobile first. Sidebar becomes a Sheet below `md` (built in). Tables scroll horizontally inside their card; hide low-priority columns below `md`.
- Touch targets ≥ 36px; icon buttons ≥ 32px with an expanded hit area where tight.

### 6.7 English UI copy & formatting

- English by default, written for internal teams: plain, specific, no marketing tone.
- **Sentence case** everywhere — titles, labels, buttons, menu items, table headers ("Add customer", not "Add Customer").
- **Buttons are concise verbs** naming the outcome: "Save changes", "Add customer", "Export CSV", "Delete order" — not "Submit", "OK" or "Yes". Pending: "Saving…".
- **No trailing periods** on labels, buttons, titles, menu items or toast titles; full sentences (with periods) in descriptions and help text.
- **Empty states say what to do next** ("No invoices yet. Create one to start billing."), not just "No data".
- **Error messages say what happened and how to fix it** ("Couldn't save — check your connection and try again", "Enter a date after 1 Oct 2026"). Never blame the user, never show a raw error code alone.
- Keep product names, technical terms and code as-is ("API key", "Webhook URL").
- **Thai data still renders correctly**: customer names, addresses and free text in Thai fall back to Prompt automatically — don't transliterate or strip them, and keep `leading-snug` or looser on anything that can hold them (§3.3).
- Format through `lib/format.ts` — never inline `Intl` / `toLocale*` in components. Default locale **en-GB**, currency **THB**, timezone context **Asia/Bangkok**:
  - `formatDate(d)` → `1 Oct 2026` (`style: "short" | "medium" | "long"`; short = `1 Oct 26`, long = `1 October 2026`). `era` defaults to `"ce"`; `era: "be"` gives Buddhist-era output for Thai-facing data (e.g. `1 ต.ค. 2569`).
  - `formatDateRange(a, b)` → `1–7 Oct 2026`, `formatTime(d)` → `14:05` (24-hour).
  - `formatTHB(n)` → `฿1,284,500` (`satang: true` for `.00`).
  - `formatRelative(d)` → `just now` / `3 minutes ago` / `yesterday`.
  - `parseDateInput(s)` → `Date | undefined` (see §5.2 for accepted input).
  - Plain counts: `toLocaleString("en-GB")`.
- Typed dates are **DD/MM/YYYY**; never show US MM/DD order.
- Use the ellipsis character `…`, not `...`.

---

## 7. Accessibility (WCAG 2.2 AA)

- Text contrast ≥ 4.5:1 (≥ 3:1 for large text and UI boundaries). Re-verify when `--brand-h` changes. Text on #F5F5F7 uses `foreground` or `muted-foreground` only (§3.2) — never #87878F.
- Without shadows, surface boundaries rely on fill contrast and borders: a control's boundary (input border, outline button, unselected chip) must stay ≥ 3:1 against its background.
- Every interactive element is reachable by keyboard and shows the focus ring (`focus-visible:ring-3 ring-ring/50`, built in). Never remove outlines.
- Icon-only controls: `aria-label` (English) + Tooltip. Toggle buttons: `aria-pressed`.
- Form controls: `<Label htmlFor>` or wrapping `<Label>`; errors via `aria-invalid` + visible text.
- Loading regions: `aria-busy="true"`; Spinner has `role="status"`.
- Motion: respect `prefers-reduced-motion` (handled globally) — don't bypass it with JS timers.
- `<html lang="en">`. Wrap known Thai-language content blocks in `lang="th"` so screen readers switch voice.

### 7.1 Haptics

Phone vibration feedback via **web-haptics**, provided by `HapticsProvider` (`lib/haptics.tsx`) inside the foundation's `components/providers.tsx`. **On by default**; each viewer can turn it off, stored in `localStorage` under `craft:haptics` (`"on"` / `"off"`). Settings: the `HapticsToggle` component (settings pages) and the **"Haptic feedback"** checkbox item in the App Shell user menu.

**Built in** (don't re-add):

| Component | Preset |
|---|---|
| Button press | `light`; `destructive` variant → `rigid`. Override `haptic="success"` (any preset), off with `haptic={false}` |
| Switch, Checkbox, Radio, Toggle, Toggle Group, Tabs, Accordion, Select (on pick), Command items (Combobox, MultiSelect, ⌘K) | `selection` |
| Dropdown menu items | `light` (`destructive` → `rigid`); checkbox / radio items → `selection` |
| Toast `success` / `warning` / `error` | matching preset |
| Field turning invalid | `error` |
| ActionButton | `light` on press, then `success` / `error` |

**Constants** — use `haptic` from `@/lib/haptics`, not raw preset names, so feedback stays consistent:

| `haptic.*` | Preset | For |
|---|---|---|
| `press` | `light` | Buttons, menu items |
| `toggle` | `selection` | Switch, checkbox, radio, toggle, tab, segmented control |
| `destructive` | `rigid` | Destructive confirmations |
| `success` / `warning` / `error` | same name | Results |

Custom components: `const { trigger } = useHaptics(); trigger(haptic.success)`, or wrap a handler with `useHapticHandler(onClick, haptic.press)` (pass `false` to skip).

- **MUST NOT** add haptics to scrolling, hovering, or passive updates (polling, live data, incoming notifications). Haptics confirm what the user just did.
- **One haptic per user action.** Don't trigger again on top of a built-in one (e.g. a Button that also calls `trigger`).
- **MUST NOT** make a haptic the only feedback — only phones vibrate (iOS via the switch trick inside web-haptics), so every haptic pairs with a visible change.
- Without the provider, triggers are silent no-ops, so components still work on their own.

---

## 8. For agents: building a screen

1. Start from `@craft/app-shell`. Put page content in its children.
2. Compose from registry items and blocks; check `/docs/blocks` before building anything table-, form- or KPI-shaped.
3. Use only tokens from §3; if you need a new token, stop and ask.
4. Add motion only from §3.5 presets; if unsure, add none — the components already animate.
5. Write English copy per §6.7.
6. Self-check before finishing:
   - [ ] No `lucide-react` / other icon libs; stateful icons use `<Icon>`
   - [ ] No raw colors, hex values or arbitrary durations
   - [ ] No `shadow-*`; pills for buttons/badges/chips/tabs, `rounded-3xl` for cards/dialogs/sheets
   - [ ] One primary button per region; destructive actions confirmed
   - [ ] Loading (`Button loading`, Skeleton), empty and error (`FieldError`) states exist
   - [ ] Icon-only buttons labelled; keyboard reachable
   - [ ] Sentence-case copy, verb buttons, no trailing periods on labels
   - [ ] Numbers tabular; money/dates via `lib/format.ts` (en-GB, THB)
   - [ ] Works at 375px width and in dark mode
   - [ ] `glass` only on floating layers (never cards, sidebars, sections, dialogs; never glass on glass)
   - [ ] Haptics only on confirmations of user actions (built-ins first, one per action, never the only feedback)
   - [ ] Morph only where the object really transforms (not navigation, not interrupting confirmations)

---

## 9. Maintaining this system

- Source of truth: this repo. `lib/catalog.ts` lists what ships; `pnpm registry:build` regenerates `registry.json` + `public/r/*.json` (dependencies are derived from imports).
- To add a component: build it in `components/ui`, add an example region in `components/examples/index.tsx`, add it to `lib/catalog.ts`, document it in §5, run `pnpm registry:build`.
- **Checks before merging** (all must pass):
  - `pnpm lint` and `pnpm lint:a11y` — the full jsx-a11y recommended set as errors; deliberate exceptions live in `scripts/lint-a11y.mjs` with their reasons.
  - `pnpm test:ui` — builds, then opens every gallery page (from `lib/catalog.ts`) in light/dark × desktop 1280/mobile 375 with locale `en-GB` and the clock frozen at 1 Oct 2026: axe WCAG 2.2 AA must report **zero serious/critical**, and each page must match its snapshot in `tests/ui/__snapshots__`. A new catalog entry is covered automatically.
  - Intended visual change → `pnpm test:ui:update`, then review the new PNGs in the diff before committing.
  - Snapshots are recorded on macOS with installed Chrome; a Linux CI needs its own baseline (run `test:ui:update` in the CI image once).
- Pulling fixes from upstream Ecsight UI is manual (hard fork): port the change, then re-apply Craft tokens, shape language and English copy.
- Breaking changes (token rename, API change) → bump the version at the top and add a changelog line.

## 10. Changelog

- **0.2.0** (2026-10-03) — Dialog and Popover morph out of their trigger by default (lib/morph.ts); ActionButton and DynamicIsland, glass on floating layers, built-in haptics via web-haptics; Button is now a client component (buttonVariants also in button-variants.ts).
- **0.1.0** (2026-10-03) — Forked from Ecsight UI 0.5.x: Webcraftsman tokens, Outfit + Prompt, pill/large-radius/no-shadow shape language, English-first copy and en-GB formatting.

History before the fork lives in the upstream repo: github.com/ecsight-tech/ecsight-ui.
