diff --git a/README.md b/README.md
index 7868fb461..87e15899c 100644
--- a/README.md
+++ b/README.md
@@ -185,8 +185,8 @@ Feature flow, issue-to-PR automation, backlog
đš aidd-ui
-1 skill
-UI and UX design, smoke test only
+1 skill · 3 hooks
+UI design system, checked on every write
diff --git a/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/phase-1.md b/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/phase-1.md
new file mode 100644
index 000000000..e0d25f365
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/phase-1.md
@@ -0,0 +1,122 @@
+---
+status: done
+---
+
+# Instruction: Records and history
+
+## Architecture projection
+
+> Tree of the final files. â
create · âïž modify · â delete
+
+```txt
+.
+âââ plugins/aidd-ui/hooks/lib/
+â âââ markdown.cjs â
+â âââ project.cjs â
+â âââ records.cjs â
+â âââ history.cjs â
+âââ scripts/__tests__/
+ âââ aidd-ui-records.test.js â
+ âââ fixtures/
+ âââ README.md âïž
+ âââ aidd-ui/
+ âââ project.js â
+```
+
+## Test Scope
+
+| Scenario | Action | Observable outcome | Actor |
+| ------------ | ----------------------- | -------------------- | ------------------------------- |
+| Setup | `writeProject(tmp)` from `fixtures/aidd-ui/project.js` | a complete valid project: every record kind, tokens, renders, code under `src/ui/` | system |
+| Happy path | `records.check(load(tmp))` and `history.check(load(tmp))` | both return `[]` | system |
+| Edge case | R1: a record without front matter, or a line that is neither `key: value` nor `key: [a, b]` | one problem naming the file and the line | system |
+| Edge case | R2: `kind: widget`, or an atom under `screens/` | one problem: unknown kind, or kind outside its folder | system |
+| Edge case | R3: a required key removed, per kind; `status: done`; `interactive: maybe`; asset `type: photo` | one problem naming the missing key or the bad value | system |
+| Edge case | R4: `id` differs from the file name; two records with the same `id` | one problem each | system |
+| Edge case | R5: `version: 0` or `v2` on an element; `version: 1.0` on `system.md` | one problem each: element integer from 1, system semver | system |
+| Edge case | R6: `uses` names an absent id; an atom uses an atom; a molecule uses a composition; a screen uses a screen | one problem each | system |
+| Edge case | R7: an interactive component without `hover`, `focus` or `disabled`; a component without `default`; a screen without `default`; an interactive component without `## Keyboard` | one problem each | system |
+| Edge case | R8: `status: coded` without `code` | one problem | system |
+| Edge case | R9: `deprecated` without `replaced-by`; `replaced-by` absent or deprecated; `replaced-by: none` without `## Deprecation` | one problem each | system |
+| Edge case | R10: a non-deprecated record uses a deprecated one | one problem naming both | system |
+| Edge case | R11: a screen breakpoint missing from `system.md` | one problem | system |
+| Edge case | R12: a journey step naming an absent screen | one problem | system |
+| Edge case | R13: a review missing a row for one screen state; `Verdict` not `ok`, `ko` or `pending` | one problem each | system |
+| Edge case | R14: `brand.md` without one of its seven headings | one problem naming the heading | system |
+| Edge case | R16: `system.md`, `brand.md` or a review with `status: coded` or `deprecated` | one problem: only `proposed` or `validated` | system |
+| Edge case | R15: an asset whose `file` is absent | one problem | system |
+| Edge case | H1: a versioned record without `## History` | one problem | system |
+| Edge case | H2: two rows for version 2, as after a merge of two branches | one problem: two changes claim version 2, renumber | system |
+| Edge case | H3: front matter `version` not the highest row; a gap from 1 to 3 | one problem each | system |
+| Edge case | H4: `Breaking` neither `yes` nor `no` | one problem | system |
+| Edge case | H5: a task with a breaking element row and no major bump in `system.md`; a task with only non-breaking rows whose `system.md` row raises nothing; a task in an element history with no `system.md` row | one problem each, naming the task | system |
+| Edge case | H6: a `deprecated` record whose last history row says `Breaking: no` | one problem | system |
+| Teardown | remove `tmp` | no file left | system |
+
+## Tasks to do
+
+### `1)` Fixture project
+
+> One complete valid project every phase mutates.
+
+1. Export `writeProject(dir)` writing:
+ - Every record holds `id`, `kind`, `status`, `version` and a `## History` table (Version, Change, Breaking, Task); every element's first row is task `ds-init`.
+ - `aidd_docs/ui/system.md`: `id: system`, `kind: system`, `status: validated`, `version: 2.0.0`, `platforms: [web]`, `breakpoints: [mobile, desktop]`, `themes: [light, dark]`, `locales: [fr, ar]`, `directions: [ltr, rtl]`, `code-roots: [src/ui]`, `ignore: [src/ui/tokens.css]`, `libraries: [@radix-ui/react-dialog]`; a `## Breakpoints` table; `## History` rows `1.0.0` for task `ds-init`, `2.0.0` for task `retire-old-button`.
+ - `aidd_docs/ui/brand.md`: `id: brand`, `kind: brand`, `status: validated`, `version: 1`; headings `## Project`, `## Users`, `## Domain`, `## Platforms`, `## Personality and tone`, `## References`, `## Anti-references`, optional `## Imposed charter`.
+ - `aidd_docs/ui/tokens.json`: `color.brand.primary` (srgb, dark theme extension), `color.text.default` aliasing it, `space.md` (rem), `border.thin` (`0.0625rem`), `motion.fast` (ms), `font.body` (family list), `font.weight.bold` (700).
+ - `aidd_docs/ui/assets/icon-search.md` with `icon-search.svg`.
+ - `aidd_docs/ui/components/`, each with `tokens`, `variants`, `states`, `role` and its `.html` render carrying a `data-variant` per variant, a `data-state` per state, tokens through `var(--...)` only, logical properties only:
+ - atom `button`: interactive, `role: button`, coded `src/ui/Button.tsx#Button`, variants `primary`, `secondary`, states `default`, `hover`, `focus`, `disabled`, `## Keyboard`.
+ - molecule `search-field`: interactive, `role: search`, validated, uses `button`, `icon-search`, no code, variants `default`, states `default`, `hover`, `focus`, `disabled`, `## Keyboard`.
+ - composition `dialog`: interactive, `role: dialog`, coded `package:@radix-ui/react-dialog#Root`, uses `button`, variants `default`, states `default`, `hover`, `focus`, `disabled`, `## Keyboard`.
+ - atom `old-button`: not interactive, `role: button`, variants `default`, states `default`, deprecated, `replaced-by: button`, history row 2 `Breaking: yes` for task `retire-old-button`.
+ - `aidd_docs/ui/screens/home.md` (coded, `src/ui/HomePage.tsx#HomePage`, states `default`, `empty`, `loading`, `error`, breakpoints `mobile`, `desktop`, route `/`) with `home.html`.
+ - `aidd_docs/ui/journeys/onboarding.md` (`## Steps` table: Screen, Action).
+ - `aidd_docs/ui/reviews/first-pass.md` (`status: proposed`, `scope: system`, `screens: [home]`, `## Verdicts` table: Screen, State, Verdict, Comment, one row per state, one `pending`: the remaining screens are the `pending` rows).
+ - `src/ui/Button.tsx`, `src/ui/HomePage.tsx`, `src/ui/tokens.css` (ignored, holds hex values).
+2. Add the fixture to `scripts/__tests__/fixtures/README.md`.
+
+### `2)` Markdown reading
+
+> Records read the same way everywhere.
+
+1. `markdown.cjs`: front matter between the first two `---` lines into `{key: string | string[]}`, a problem per malformed line; `## ` headings into sections; a section's first table into rows keyed by header.
+
+### `3)` Project loading
+
+> One read of the tree every check shares.
+
+1. `project.cjs`: `load(root)` returns `null` without `aidd_docs/ui/`, else every record with its file, folder, front matter and sections, the raw `tokens.json` text, and the render paths.
+
+### `4)` Record rules
+
+> R1 to R16 hold.
+
+1. `records.cjs`: `check(project)` returns `{file, message}` per broken rule, in rule order.
+2. Required keys: every record `id`, `kind`, `status`, `version`; `system.md` `platforms`, `breakpoints`, `themes`, `locales`, `directions`, `code-roots`, `ignore`, `libraries`; component `uses`, `tokens`, `variants`, `states`, `interactive`, `role`; screen `uses`, `states`, `breakpoints`, `route`; journey `persona`; asset `type`, `file`, `alt`; review `scope`, `screens`; `code` only when `coded`; `replaced-by` only when `deprecated`.
+3. Folders: `components/` holds atom, molecule, composition; `screens/` screen; `journeys/` journey; `assets/` asset; `reviews/` review; the root holds `system.md`, `brand.md`.
+
+### `5)` History rules
+
+> H1 to H6 hold.
+
+1. `history.cjs`: `check(project)` reads each `## History` table (Version, Change, Breaking, Task) and returns `{file, message}` per broken rule.
+2. Bumps: the system row of a task raises the major version over the previous row when any element row of that task says `yes`, else at least the minor.
+3. A `deprecated` record's last row says `Breaking: yes`.
+
+### `6)` Tests
+
+> Each rule has the mutation that turns its test red.
+
+1. `aidd-ui-records.test.js`: the Happy path, then one test per Edge case row, each asserting its own message.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| ---- | -------------------------------- |
+| 1 | `writeProject` output passes both checks; the fixture README lists it |
+| 2 | a malformed front matter line comes back as a problem with its line |
+| 3 | `load` returns `null` without `aidd_docs/ui/` and every record otherwise |
+| 4 | each of R1 to R16 has its own message |
+| 5 | each of H1 to H6 has its own message, H2 naming the claimed version |
+| 6 | reverting any one rule turns exactly its test red |
diff --git a/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/phase-2.md b/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/phase-2.md
new file mode 100644
index 000000000..7007d5ce8
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/phase-2.md
@@ -0,0 +1,77 @@
+---
+status: done
+---
+
+# Instruction: Tokens and renders
+
+## Architecture projection
+
+> Tree of the final files. â
create · âïž modify · â delete
+
+```txt
+.
+âââ plugins/aidd-ui/hooks/lib/
+â âââ tokens.cjs â
+â âââ renders.cjs â
+âââ scripts/__tests__/
+ âââ aidd-ui-tokens-renders.test.js â
+```
+
+## Test Scope
+
+| Scenario | Action | Observable outcome | Actor |
+| ------------ | ----------------------- | -------------------- | ------------------------------- |
+| Setup | `writeProject(tmp)` | the valid project | system |
+| Happy path | `tokens.check(load(tmp))`, `renders.check(load(tmp))` | both `[]` | system |
+| Edge case | T1: `tokens.json` not JSON | one problem | system |
+| Edge case | T2: a name starting with `$` that is not a reserved key; a name holding `.` | one problem each | system |
+| Edge case | T3: a token without `$value`; without any `$type`; with `$type: colour` | one problem each | system |
+| Edge case | T4: color `colorSpace: cmyk`; color with two components; `hex: #fff`; dimension `unit: em`; duration `unit: min`; fontWeight `1200`; cubicBezier x `1.5` | one problem each | system |
+| Edge case | T5: `{color.missing}`; `{color.brand}` naming a group | one problem each | system |
+| Edge case | T6: theme `sepia` absent from `system.md`; a theme value of the wrong shape | one problem each | system |
+| Edge case | T7: a component `tokens` entry naming no token | one problem | system |
+| Edge case | T8: a dimension token in `px` | one problem: sizes are in `rem` | system |
+| Edge case | V1: a component or screen without its `.html` | one problem | system |
+| Edge case | V2: a declared variant or state with no `data-variant` or `data-state` in the render | one problem each | system |
+| Edge case | V3: `var(--color-missing)` in a render | one problem | system |
+| Edge case | V4: `#336699`, `rgb(`, `hsl(` in a render | one problem each, with its line | system |
+| Edge case | V6: `16px`, `1.5rem` or `200ms` written in a render | one problem each: only tokens carry values | system |
+| Edge case | V5: `margin-left` in a render while `system.md` declares `rtl`; the same with only `ltr` | one problem; none | system |
+| Teardown | remove `tmp` | no file left | system |
+
+## Tasks to do
+
+### `1)` Token rules
+
+> T1 to T8 hold.
+
+1. `tokens.cjs`: walk the tree; a key starting with `$` is a property, any other is a group or a token, a token being an object with `$value`.
+2. Resolve `$type`: own, else the aliased token's, else the closest group's.
+3. Check primitive shapes per the 2025.10 module; for composite types, check only that `$value` exists.
+4. Read themes from `$extensions["dev.ai-driven.themes"]`; check names against `system.md` `themes`, values against the token's type.
+5. Require `rem` on every dimension token.
+6. Export the set of token paths and their CSS names for renders.
+
+### `2)` Render rules
+
+> V1 to V6 hold.
+
+1. `renders.cjs`: require `.html` beside each component and screen record.
+2. Find `data-variant="x"` and `data-state="x"` for each declared variant and state.
+3. Flag each `var(--x)` with no token, each colour literal (`#` with 3, 4, 6 or 8 hex digits, `rgb(`, `rgba(`, `hsl(`, `hsla(`) with its line, and, when `rtl` is declared, each `margin-left`, `margin-right`, `padding-left`, `padding-right`, `border-left`, `border-right`, `left:`, `right:`, `text-align: left`, `text-align: right`, `float: left`, `float: right`.
+4. Flag each number followed by `px`, `rem`, `em`, `ms` or `s` outside `var(...)`; `0` alone passes.
+5. Export the colour, unit and physical-property patterns for the code checks.
+
+### `3)` Tests
+
+> Each rule has its mutation.
+
+1. `aidd-ui-tokens-renders.test.js`: the Happy path, then one test per Edge case row.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| ---- | -------------------------------- |
+| 1 | each of T1 to T8 has its own message |
+| 2 | each of V1 to V6 has its own message; `ltr` alone allows physical properties |
+| 3 | reverting any one rule turns exactly its test red |
diff --git a/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/phase-3.md b/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/phase-3.md
new file mode 100644
index 000000000..0e0aa2099
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/phase-3.md
@@ -0,0 +1,64 @@
+---
+status: done
+---
+
+# Instruction: Code link
+
+## Architecture projection
+
+> Tree of the final files. â
create · âïž modify · â delete
+
+```txt
+.
+âââ plugins/aidd-ui/hooks/lib/
+â âââ code.cjs â
+âââ scripts/__tests__/
+ âââ aidd-ui-code.test.js â
+```
+
+## Test Scope
+
+| Scenario | Action | Observable outcome | Actor |
+| ------------ | ----------------------- | -------------------- | ------------------------------- |
+| Setup | `writeProject(tmp)` | the valid project | system |
+| Happy path | `code.check(load(tmp))` | `[]` | system |
+| Edge case | C1: `code` path absent; export name absent from the file; `code` without `#` | one problem each | system |
+| Edge case | C2: `package:left-pad#Pad` not in `libraries` | one problem | system |
+| Edge case | C3: `src/ui/Orphan.tsx` linked by no record and under no `ignore` | one problem naming the file | system |
+| Edge case | C3: a `proposed` record whose `code` points at an existing file | no problem: the link is optional before `coded`, valid when present | system |
+| Edge case | C4: `color: #ff0000` in `src/ui/Button.tsx`; the same in `src/ui/tokens.css` | one problem with its line; none, the file is ignored | system |
+| Edge case | C4: `padding: 16px` in a `.css` file under `src/ui/`; the same in a `.tsx` file | one problem; none, units in script files are a stated limit | system |
+| Edge case | C5: two records with the same `code` | one problem naming both | system |
+| Edge case | C6: `margin-left` in a `.css` file under `src/ui/` while `rtl` is declared | one problem | system |
+| Teardown | remove `tmp` | no file left | system |
+
+## Tasks to do
+
+### `1)` Link rules
+
+> C1 and C2 hold.
+
+1. `code.cjs`: split `code` on its last `#`; a `package:` link needs its name in `system.md` `libraries`; any other needs the file under the project root and the export as a whole word in it.
+
+### `2)` Coverage and literals
+
+> C3 to C6 hold.
+
+1. Walk each `code-roots` prefix; flag each file no `code` names and no `ignore` prefix covers.
+2. In files with extension `.css`, `.scss`, `.js`, `.jsx`, `.ts`, `.tsx`, `.vue`, `.svelte`, `.html`, outside `ignore`, flag colour literals with their line, with the render pattern.
+3. In `.css` and `.scss` files only, flag unit literals and, when `rtl` is declared, physical properties, with the render patterns.
+4. Flag two records sharing one `code`.
+
+### `3)` Tests
+
+> Each rule has its mutation.
+
+1. `aidd-ui-code.test.js`: the Happy path, then one test per Edge case row.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| ---- | -------------------------------- |
+| 1 | an absent file, an absent export, a malformed link and an undeclared package each have their own message |
+| 2 | an orphan file, a colour literal, a unit literal, a shared link and a physical property each have their own message; an ignored file and a unit in a script file have none |
+| 3 | reverting any one rule turns exactly its test red |
diff --git a/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/phase-4.md b/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/phase-4.md
new file mode 100644
index 000000000..9eb94de74
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/phase-4.md
@@ -0,0 +1,88 @@
+---
+status: done
+---
+
+# Instruction: Entry, hook and docs
+
+## Architecture projection
+
+> Tree of the final files. â
create · âïž modify · â delete
+
+```txt
+.
+âââ docs/
+â âââ CATALOG.md âïž
+âââ plugins/aidd-ui/
+â âââ CATALOG.md âïž
+â âââ README.md âïž
+â âââ hooks/
+â âââ hooks.json â
+â âââ ui-hook.cjs â
+â âââ validate-ui.cjs â
+âââ scripts/__tests__/
+ âââ aidd-ui-validate.test.js â
+```
+
+## Test Scope
+
+| Scenario | Action | Observable outcome | Actor |
+| ------------ | ----------------------- | -------------------- | ------------------------------- |
+| Setup | `writeProject(tmp)` | the valid project | system |
+| Happy path | `node plugins/aidd-ui/hooks/validate-ui.cjs tmp` | `VALID`, exit 0 | cli |
+| Edge case | E1: the same on a project without `aidd_docs/ui/` | nothing printed, exit 0 | cli |
+| Edge case | E2: one mutation from each of phases 1, 2 and 3 | one `â : ` line each, exit 1 | cli |
+| Edge case | E3: `ui-hook.cjs` with a PostToolUse payload, `cwd` = `tmp`, `tool_input.file_path` under `aidd_docs/ui/`, after a mutation | the `â` lines on stderr, exit 2 | system |
+| Edge case | E3: the same with `file_path` under `src/ui/` | the `â` lines on stderr, exit 2 | system |
+| Edge case | E3: the same with `file_path` outside `aidd_docs/ui/` and every code root | silent, exit 0 | system |
+| Edge case | E3: the same on a valid project | silent, exit 0 | system |
+| Edge case | E4: SessionStart payload on a project with failures | stdout: the count then the `â` lines, exit 0 | system |
+| Edge case | E4: SessionStart on a project without `aidd_docs/ui/` | silent, exit 0 | system |
+| Edge case | E5: PostToolUse payload without `tool_input.file_path`, as a host without matcher sends; empty or non-JSON stdin | silent, exit 0 | system |
+| Teardown | remove `tmp` | no file left | system |
+
+## Tasks to do
+
+### `1)` Manual entry
+
+> One command runs every check.
+
+1. `validate-ui.cjs`: root from the argument, else the working directory; `load`; nothing and exit 0 when `null`; else run `records`, `history`, `tokens`, `renders`, `code` in that order; print `VALID` or one `â : ` per problem; exit 1 on any; export `validate(root)` returning the problems.
+
+### `2)` Hook entry
+
+> The same checks on every write and at session start.
+
+1. `ui-hook.cjs`: read stdin JSON; anything else exits 0 silently; root from `cwd`.
+2. PostToolUse: run `validate(root)` only when `tool_input.file_path` is under `aidd_docs/ui/` or a `code-roots` prefix; on problems write the `â` lines to stderr and exit 2.
+3. SessionStart: on problems write `aidd-ui: problems` then the `â` lines to stdout; always exit 0.
+4. Any thrown error exits 0 silently: a broken check never blocks a session.
+5. `hooks.json`: `SessionStart` and `PostToolUse` matching `Write|Edit|MultiEdit`, each `node ${CLAUDE_PLUGIN_ROOT}/hooks/ui-hook.cjs`.
+
+### `3)` Tests
+
+> Each entry has its mutation.
+
+1. `aidd-ui-validate.test.js`: one test per Test Scope row, spawning `node` with the payload on stdin.
+
+### `4)` Docs
+
+> The README and catalogs match the tree.
+
+1. `plugins/aidd-ui/README.md`: status from `plugin.json`'s version; the `aidd_docs/ui/` tree and each record's keys; the hand command `node /hooks/validate-ui.cjs`; the limits: no `node`, no feedback in the turn outside Claude Code, Codex runs the hook once trusted, no composite token shape, no non-web code literal, no unit literal in script files, a word of hex letters after `#` counts as a colour; fixed widths on text and long translations are for the audit.
+2. Run `node scripts/sync-generated-docs.mjs` and `sh scripts/summarize-plugin-catalogs.sh`.
+
+### `5)` Contributor checks
+
+> The tree passes what a pull request requires.
+
+1. Run `make check` (`CONTRIBUTING.md`) and `node scripts/check-tests-leave-git-alone.js -- node --test 'scripts/__tests__/**/*.test.js'` (`aidd_docs/memory/testing.md`); fix until both exit 0.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| ---- | -------------------------------- |
+| 1 | the valid project prints `VALID`; each phase's mutation prints its line and exits 1 |
+| 2 | each Test Scope hook row gives its exit code and output |
+| 3 | reverting the path filter, the exit 2 or the silent catch turns its own test red |
+| 4 | the README holds the tree, the command and every limit; `docs/CATALOG.md` lists `hooks/ui-hook.cjs` under `SessionStart` and `PostToolUse`; `plugins/aidd-ui/CATALOG.md` lists the hook files |
+| 5 | both commands exit 0 |
diff --git a/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/phase-5.md b/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/phase-5.md
new file mode 100644
index 000000000..00ee1c282
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/phase-5.md
@@ -0,0 +1,77 @@
+---
+status: done
+---
+
+# Instruction: Fit a real project
+
+## Architecture projection
+
+> Tree of the final files. â
create · âïž modify · â delete
+
+```txt
+.
+âââ aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-rework/
+â âââ spec.md âïž
+âââ plugins/aidd-ui/
+â âââ README.md âïž
+â âââ hooks/lib/
+â âââ code.cjs âïž
+â âââ records.cjs âïž
+â âââ tokens.cjs âïž
+âââ scripts/__tests__/
+ âââ aidd-ui-code.test.js âïž
+ âââ aidd-ui-records.test.js âïž
+ âââ aidd-ui-tokens-renders.test.js âïž
+ âââ fixtures/aidd-ui/project.js âïž
+```
+
+## Test Scope
+
+| Scenario | Action | Observable outcome | Actor |
+| ------------ | ----------------------- | -------------------- | ------------------------------- |
+| Setup | `writeProject(tmp)`, now with `primitives: [color.brand]`, a semantic `color.action.default` and a `## Usage` on every component | the valid project | system |
+| Happy path | every check on it | `[]` | system |
+| Edge case | C4: `padding: 16px` in a linked stylesheet | no problem: only colours are checked in code | system |
+| Edge case | R8: search-field `coded` without `code`, button coded; then button `validated` | no problem; then one problem | system |
+| Edge case | R17: dialog without `## Usage` | one problem | system |
+| Edge case | T8: button citing `color.brand.primary` | one problem | system |
+| Edge case | R6, R7: an atom using an atom; a screen using itself; an interactive component without hover or disabled, then without focus | no problem; one; none; one | system |
+| Edge case | T5, T3: a dimension aliasing a colour; a broken alias without `$type` | one problem; one problem, not two | system |
+| Edge case | T6: a themed token without `dark`; a value for the base theme; a theme value aliasing a dimension | one problem each | system |
+| Edge case | raw CSS: `color-mix()` under `dev.ai-driven.css`; an empty one; a `$root` | none; one; one | system |
+| Edge case | V3: a custom property the render sets itself | no problem | system |
+| Edge case | C3: `search-field/search-field.component.tsx` beside a filter and `__tests__/`; `Button.module.css` and `Orphan.tsx` beside `Button.tsx` | none; only `Orphan.tsx` | system |
+| Teardown | remove `tmp` | no file left | system |
+
+## Tasks to do
+
+### `1)` Contract
+
+> The spec follows what Gouvernail showed, and stays lean.
+
+1. `spec.md`: a link covers its own folder; an assembled element is coded through its components; primitive and semantic tokens; `## Usage`; optional glossary and rules; only colours checked in code, other literals and forbidden words left to the audit.
+
+### `2)` Rules
+
+> Each change has the mutation that proves it.
+
+1. `code.cjs`: C3 covers the folder of each linked file outside a code root; C4 drops unit literals in code.
+2. `records.cjs`: `primitives` required in `system.md`; R8 accepts a molecule or composition whose used components, assets aside, are all coded; R17 needs `## Usage` on every component.
+3. `tokens.cjs`: T8 names a component token under a `primitives` group.
+4. `README.md`: the format table and limits follow.
+
+### `3)` Second real project
+
+> What Gouvernail and Kairos extractions showed, run through the validator.
+
+1. C3 covers a folder only when it bears the linked file's name, else the files of its stem.
+2. R6 accepts the same level and names self-use; R7 requires `focus` only.
+3. Tokens: the rem rule goes; the first theme is the base and every other theme is given; an alias must match its type; T3 stays silent on a broken alias; raw CSS rides `dev.ai-driven.css`; `$root` is named unsupported.
+4. V3 accepts custom properties the render sets itself.
+
+## Test acceptance criteria
+
+| Task | Acceptance criteria |
+| ---- | -------------------------------- |
+| 1 | the spec names every rule below and moves unit literals and forbidden words to the audit |
+| 2, 3 | a folder-covered file, an orphan at a root, an uncoded component under a coded molecule, a missing usage and a primitive token each behave as the test scope says; reverting any one rule turns exactly its test red |
diff --git a/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/plan.md b/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/plan.md
new file mode 100644
index 000000000..929fd751a
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/plan.md
@@ -0,0 +1,59 @@
+---
+objective: "A deterministic validator, run by hand and by an aidd-ui hook on every write, rejects any UI record, token, render or code link that breaks the spec."
+status: implemented
+---
+
+# Plan: aidd-ui foundation, every record and its validator
+
+Source: aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-rework/spec.md, pull request 1 of aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-rework/roadmap.md.
+
+> The format decisions taken after this plan ran (the version the code implements, publication rows, the Resolver for themes, severities, one engine command) live in the spec and the plugin README, which supersede the names and rules below.
+
+## Phases
+
+| # | Phase | File | Depends on |
+| --- | ------------ | ---------------------------- | ---------- |
+| 1 | Records and history | [`phase-1.md`](./phase-1.md) | none |
+| 2 | Tokens and renders | [`phase-2.md`](./phase-2.md) | 1 |
+| 3 | Code link | [`phase-3.md`](./phase-3.md) | 1 |
+| 4 | Entry, hook and docs | [`phase-4.md`](./phase-4.md) | 2, 3 |
+| 5 | Fit a real project | [`phase-5.md`](./phase-5.md) | 4 |
+
+## External sources
+
+| URL | What it settled |
+| ----- | ----------------- |
+| https://www.designtokens.org/tr/2025.10/format/ | types: color, dimension, fontFamily, fontWeight, duration, cubicBezier, number, and composites strokeStyle, border, transition, shadow, gradient, typography; dimension `{value, unit: px or rem}`, duration `{value, unit: ms or s}`, fontWeight 1 to 1000 or a keyword, cubicBezier four numbers with x in 0..1; `$type` from the token, else the referenced token, else the closest parent group, else invalid; names never start with `$` nor hold `{`, `}`, `.`; a `{a.b}` alias targets a token, never a group; `$extensions` keys in reverse-domain notation; `$deprecated` true, false or a string; themes are not in the format module |
+| https://www.designtokens.org/tr/2025.10/color/ | color `$value` is `{colorSpace, components, alpha?, hex?}`, `colorSpace` one of srgb, srgb-linear, hsl, hwb, lab, lch, oklab, oklch, display-p3, a98-rgb, prophoto-rgb, rec2020, xyz-d65, xyz-d50, `components` three numbers or `none`, `hex` six digits |
+| https://code.claude.com/docs/en/hooks#posttooluse | PostToolUse reads `tool_input.file_path` on stdin; exit 2 hands stderr back to the agent |
+
+## Impact
+
+| Category | Verdict and evidence | Handled in |
+| -------------------------------------------------- | -------------------- | ------------------ |
+| runtime | the hook runs on the user's `node` with built-ins only: plugins ship without `node_modules` (`plugins/aidd-context/hooks/update_memory.js`). Kilo and opencode replay only `node ${CLAUDE_PLUGIN_ROOT}/hooks/` (`cli/src/contexts/tools/domain/profiles/kilo/kilo-hooks-bridge.ts:27`) and drop the hook's output (`:77`); Cursor and Copilot drop the matcher (`cli/src/contexts/tools/domain/formats/flat-hooks-merge.ts:109`); Codex runs a hook once trusted (`cli/src/contexts/tools/domain/profiles/codex/profile.ts:54`); a sibling `require` survives every translation (`plugins/aidd-telemetry/hooks/catch-up.cjs:2`). Tool names on hosts other than Claude Code: unverified, stated as a limit | 4 |
+| regression | low: no existing file changes behaviour; `01-hello` stays; golden snapshots read `cli/tests/fixtures/framework-real` (`cli/tests/golden/framework-build-golden.e2e.test.ts:17`). Safety-net phase: no | none |
+| architecture | aidd-ui gains its first hooks, `hooks/hooks.json` discovered without a manifest key (`plugins/aidd-context/.claude-plugin/plugin.json`). No skill reaches the validator by path: the hook runs it on every write | 4 |
+| documentation | `docs/CATALOG.md` hooks block and `plugins/aidd-ui/CATALOG.md` are regenerated at pre-commit (`scripts/sync-generated-docs.mjs:49`, `scripts/summarize-plugin-catalogs.sh`), checked by `scripts/__tests__/architecture-doc-matches-the-tree.test.js`; `plugins/aidd-ui/README.md` says `0.1.0-alpha.0`, `plugin.json` holds `0.2.1-alpha.0`; `scripts/__tests__/fixtures/README.md` lists every fixture | 1, 4 |
+
+## Rules
+
+| Rule | Why it applies |
+| ------ | -------------- |
+| A guard ships with the mutation that proves it (`aidd_docs/memory/coding-assertions.md`) | every rule below has one test that its one mutation turns red |
+| No code for an input never observed in real data (`aidd_docs/memory/coding-assertions.md`) | composite token values, alias cycles, globs and non-web code are stated limits, not code |
+| A plugin never holds its own tests (`aidd_docs/memory/testing.md`) | tests and their fixture go in `scripts/__tests__/` |
+| Run the scripts suite wrapped (`aidd_docs/memory/testing.md`) | `node scripts/check-tests-leave-git-alone.js -- node --test ...` |
+| A skill never links outside itself (`aidd_docs/memory/architecture.md`) | the validator lives in `hooks/`, reached by the hook, never by a skill path |
+| Bundled hooks run Node (`aidd_docs/memory/architecture.md`) | without `node` the hook is silent, stated in the README |
+
+## Decisions
+
+| Decision | Why |
+| ---------- | --- |
+| Front matter is flat: `key: value` or `key: [a, b]`, nothing nested; tables in the body carry what has rows | parses with built-ins, no YAML dependency, same reading for an agent and a script |
+| Keys and values are English; the spec's states map to `proposed`, `validated`, `coded`, `deprecated` | the plugin and its users' records share the repository's language |
+| Themes are a token extension `"$extensions": {"dev.ai-driven.themes": {"": }}` | the 2025.10 format module defines no themes; extensions are its sanctioned, preserved escape |
+| A token's CSS name is `--` plus its path joined by `-` | one rule both renders and the later `tokens.css` generator follow |
+| A code link is `path#Export` or `package:#Export` | one file and one export per element, a library component by its package |
+| The validator is `hooks/validate-ui.cjs` over modules in `hooks/lib/`, built one module per phase; `hooks/ui-hook.cjs` runs it for PostToolUse and SessionStart | one source of rules for the hand, the hook and every skill that writes |
diff --git a/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/run.md b/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/run.md
new file mode 100644
index 000000000..a086e34a5
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/run.md
@@ -0,0 +1,27 @@
+# Run
+
+## Run 1
+
+Head: 9db5c887bc58093237dd16c9aa5fb6707b2a36d6
+
+| Task | Validation | Result |
+| ---- | ---------- | ------ |
+| 1.1 | Happy path: `writeProject` passes records and history, `node scripts/check-tests-leave-git-alone.js -- node --test scripts/__tests__/aidd-ui-records.test.js` | 54 of 54 pass |
+| 1.2 | R1: a malformed front matter line, same command | one problem naming line 7 |
+| 1.3 | `load` without `aidd_docs/ui/`, and on the fixture, same command | `null`; 10 records |
+| 1.4 | R1 to R16, one mutation each, same command | one message per rule |
+| 1.5 | H1 to H6, one mutation each, same command | one message per rule; H2 names version 2 |
+| 1.6 | each rule dropped from `RULES` in turn, then the file rerun | only that rule's tests red, 22 of 22 rules |
+| 2.1 | T1 to T8, `node scripts/check-tests-leave-git-alone.js -- node --test scripts/__tests__/aidd-ui-tokens-renders.test.js` | one message per rule, 33 of 33 pass |
+| 2.2 | V1 to V6, same command | one message per rule; `ltr` alone allows `margin-left` |
+| 2.3 | each rule dropped in turn, then the file rerun | only that rule's tests red, 14 of 14 rules |
+| 3.1 | C1 and C2, `node scripts/check-tests-leave-git-alone.js -- node --test scripts/__tests__/aidd-ui-code.test.js` | absent file, absent export, malformed link and undeclared package each named, 14 of 14 pass |
+| 3.2 | C3 to C6, same command | orphan, colour, unit, shared link and physical property named; ignored file and script units silent |
+| 3.3 | each rule dropped in turn, then the file rerun | only that rule's tests red, 7 of 7 rules |
+| 4.1 | `node plugins/aidd-ui/hooks/validate-ui.cjs `, `node scripts/check-tests-leave-git-alone.js -- node --test scripts/__tests__/aidd-ui-validate.test.js` | `VALID` exit 0; one line per family, exit 1; nothing without `aidd_docs/ui/` |
+| 4.2 | E3 to E5 hook payloads on stdin, same command | exit 2 with the lines on stderr under `aidd_docs/ui/` and `src/ui/`; silent elsewhere; SessionStart prints the count; silent on bad stdin and on a throw |
+| 4.3 | path filter, exit 2, silent catch and each check reverted in turn | each turns its own test red, 13 of 13 pass restored |
+| 4.4 | `node scripts/sync-generated-docs.mjs --check`, `sh scripts/summarize-plugin-catalogs.sh` | in sync; `docs/CATALOG.md` lists `hooks/ui-hook.cjs` under both events |
+| 4.5 | `make check`; `pnpm exec lefthook run pre-commit --all-files --force`; `node scripts/check-tests-leave-git-alone.js -- node --test 'scripts/__tests__/**/*.test.js'` | `make check` exit 0, every job skipped with nothing staged; whole tree: 870 of 871 pass, the one failure names untracked `hooks/ui-hook.cjs`, and it passes with the new files tracked in a scratch index |
+
+Left: nothing committed (commit strategy `never`); `comments-name-files-that-exist` stays red until `plugins/aidd-ui/hooks/` is tracked; `plugins/aidd-ui/CATALOG.md` lists `hooks.json` only, because its generator skips `.cjs` files, as for `aidd-telemetry`.
diff --git a/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-rework/brainstorm.md b/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-rework/brainstorm.md
new file mode 100644
index 000000000..128dd1f7e
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-rework/brainstorm.md
@@ -0,0 +1,71 @@
+---
+status: approved
+---
+
+# aidd-ui : un design system qui vit avec le code
+
+Pour un dev qui conçoit l'UI avec une IA : le plugin crée, fait évoluer et versionne un design system, et le relie aux écrans et au code, pour que chaque changement soit tracé et propagé sans travail manuel.
+
+## Clair
+
+### PérimÚtre
+
+- Tout est dans le périmÚtre de la premiÚre version, versionnage par élément compris. Les étapes forment une seule chaßne, pas des outils séparés.
+- La chaßne : marque et identité (projet, utilisateurs, domaine, plateformes), tokens, atomes, molécules, compositions, parcours et wireframes, écrans finis, code.
+- La marque part du contexte réel (utilisateurs, domaine, plateformes), pas d'un style par défaut.
+- Deux points de dĂ©part : un projet vierge, oĂč le DS est construit, et un projet existant, oĂč le DS est extrait du code.
+- Les skills suivent le format lean de brainstorm, plan et implement : chaque mot a sa place.
+
+### Emplacement
+
+- Tout vit dans `aidd_docs/ui/`, un dossier dédié avec un fichier par élément. Les parcours vont dans `ui/journeys/`. Rien n'est posé à cÎté du code.
+- L'IA écrit les fiches, chacune suivant son template : token, atome, molécule, composition, écran, parcours.
+- Les artefacts ne dépendent d'aucune techno. Les tokens suivent le format W3C DTCG 2025.10. Figma et Claude Design se branchent, le contrat du projet fait foi.
+
+### Lien
+
+- Le lien entre le design system, les Ă©crans et le code est le cĆur du plugin. On relie composants et Ă©crans, pas chaque token.
+- Chaque élément correspond à un seul fichier de code, identifié par son chemin et le nom du composant.
+- Les usages se lisent dans les compositions du DS, et le rapport d'impact remonte cet arbre.
+
+### Feature
+
+- Chaque feature a son `ui.md`, le contrat UI de la feature. Une fois la feature implémentée, le code est vérifié contre ce contrat et l'index est mis à jour.
+- Quand un élément change : rapport d'impact, validation, régénération de ce qui est touché, nouvelle validation si besoin, puis nouvelle version du DS.
+
+### Vitrine
+
+- Un script génÚre la vitrine à partir des fiches et de templates HTML fixes, sans IA : le rendu est identique à chaque fois et se régénÚre à volonté.
+- Elle montre les Ă©lĂ©ments, leurs liens et l'endroit oĂč chacun est utilisĂ©.
+- Les écrans y apparaissent dans leur version validée, la maquette HTML créée par l'IA avec les vrais tokens, et chacun porte un lien vers le réel (route ou fichier).
+
+### Vérifications
+
+- Des scripts Node déterministes, qui tournent partout sans IA, vérifient le format de chaque fichier généré (écrans HTML compris), un lien vers un fichier absent, un fichier UI absent de l'index, une composition qui utilise un élément inexistant, et une valeur en dur dans le code.
+- Une fiche mal formée bloque la construction de la vitrine.
+- Des hooks (`SessionStart`, `PostToolUse` sur `aidd_docs/ui/`) lancent ces scripts. Ce sont les mĂȘmes scripts qu'on lance Ă la main, donc ils marchent aussi dans les outils sans hooks.
+- L'IA juge le sens : le code fait-il ce que dit le `ui.md` ?
+
+### Audit et review
+
+- L'audit UI appartient au plugin. Il constate et ne corrige rien. Il compare le validé et le réel : capture d'écran sur le web, fiche contre code ailleurs. Le volet UI de l'audit dev est retiré une fois cet audit en place.
+- La review design se fait dans une page HTML interactive : OK ou pas OK par écran, avec un commentaire, et les verdicts sont enregistrés dans un fichier du projet. Quand l'utilisateur dit qu'il a fini, l'IA lit le fichier et continue.
+- La review code reste dans dev. L'accessibilité fait partie du plugin.
+
+### Signal de réussite
+
+Sur un projet vierge comme sur un projet existant :
+
+- le flow complet passe de bout en bout jusqu'à tous les écrans voulus ;
+- les features générées ensuite sont liées, sans dérive, et testées ;
+- les mises à jour et le cycle de vie s'enchaßnent de façon cohérente.
+
+## Supposé
+
+- On repart de zéro et l'ancienne PR aidd-ui est fermée. Seules ses idées sont gardées : boucle delta, ordre de priorité entre sources, classes de dérive.
+- La vitrine s'inspire d'un outil propriétaire vu en démo : DS, liens et usages visibles, tout normé et vérifié par script.
+- Le réel n'est rendu que sur le web. Ailleurs, l'audit compare la fiche au code.
+
+## Ouvert
+
+- Projet pilote : Gouvernail ou Keros pour le cas vierge. Le cas existant demande un projet qui a déjà du code UI.
diff --git a/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-rework/roadmap.md b/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-rework/roadmap.md
new file mode 100644
index 000000000..813c7a5f3
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-rework/roadmap.md
@@ -0,0 +1,33 @@
+# aidd-ui : feuille de route
+
+Cinq pull requests empilées, chacune planifiée, codée et revue à son tour. Le contrat est dans [spec.md](./spec.md).
+
+## Skills
+
+| Skill | Fait | Produit |
+| --- | --- | --- |
+| `01-system` | pose la marque, les tokens, les assets et les composants, depuis rien ou en les extrayant du code ; conduit un changement de l'impact jusqu'aux nouvelles versions | `system.md`, `brand.md`, `tokens.json`, `assets/`, `components/` |
+| `02-screens` | dessine les parcours, les wireframes puis les écrans avec leurs états | `journeys/`, `screens/` |
+| `03-review` | ouvre la page de review et reprend Ă partir des verdicts | `reviews/` |
+| `04-feature` | écrit le `ui.md` d'une feature, puis vérifie son code et met à jour les liens | `ui.md` dans le dossier de la tùche |
+| `05-audit` | constate les écarts sans rien corriger | un rapport |
+
+Un skill ne nomme jamais un autre skill.
+
+La vérification est un hook, déclenché à chaque écriture dans `aidd_docs/ui/` : chaque skill en hérite et voit l'erreur pendant qu'il travaille. Elle se lance aussi à la main. Un seul moteur, livré avec le hook, porte la lecture des fiches et chaque génération ; les skills l'appellent par sous-commande : `tokens.css` pour `01-system`, la vitrine pour `02-screens`, la page de review pour `03-review`.
+
+`01-hello` disparaĂźt avec la PR 2.
+
+## Pull requests
+
+| # | Contenu | Fini quand |
+| --- | --- | --- |
+| 1 | Socle : rÚgles de chaque fiche, vérification complÚte en hook et à la main | chaque rÚgle de la spec a un cas qui la fait échouer ; un projet exemple complet passe |
+| 2 | `01-system` : marque, tokens, assets, composants, extraction depuis le code, génération de `tokens.css`, flux de changement | sur le pilote vierge, puis sur Gouvernail et Kairos, le design system est posé, puis un changement de token produit son impact et ses versions |
+| 3 | `02-screens`, `03-review`, vitrine | sur le pilote vierge, tous les écrans voulus sont validés en review, vitrine générée deux fois identique |
+| 4 | `04-feature`, et la lecture du `ui.md` par le plan et l'implémentation | une feature du pilote passe du `ui.md` au code vérifié, liens à jour |
+| 5 | `05-audit`, retrait du volet UI de l'audit dev | l'audit du pilote signale chaque écart semé exprÚs, et rien d'autre |
+
+## Limites connues
+
+- Le retour du hook à l'agent est garanti sur Claude Code. Codex ne lance le hook qu'une fois approuvé ; Copilot ne le lance que dans un dossier approuvé ; Kilo et opencode le reçoivent et relancent l'agent en fin de tour, une fois. Cursor n'est pas vérifié. Ailleurs, la commande à la main est la garantie.
diff --git a/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-rework/spec.md b/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-rework/spec.md
new file mode 100644
index 000000000..8ab3e15fe
--- /dev/null
+++ b/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-rework/spec.md
@@ -0,0 +1,172 @@
+# aidd-ui : un design system qui vit avec le code
+
+## Target
+
+Dans un projet, un dev qui conçoit l'UI avec une IA obtient un design system versionné, relié à ses écrans et à son code, dont chaque changement est tracé, propagé et vérifiable sans IA.
+
+## Hard constraints
+
+### Arborescence
+
+Tout le design system vit dans `aidd_docs/ui/`, un fichier par élément, rien à cÎté du code.
+
+```txt
+aidd_docs/ui/
+âââ system.md racine du design system
+âââ brand.md identitĂ© de marque
+âââ tokens.json tokens du thĂšme de base, format W3C DTCG 2025.10
+âââ tokens.resolver.json thĂšmes, module Resolver de DTCG 2025.10
+âââ themes/| .json valeurs qui changent pour un thĂšme
+âââ tokens.css gĂ©nĂ©rĂ© depuis les tokens, jamais Ă©crit Ă la main
+âââ assets/.md icĂŽne, image, illustration, avec son fichier Ă cĂŽtĂ©
+âââ components/.md atome, molĂ©cule ou composition
+âââ components/.html rendu du composant, toutes variantes et tous Ă©tats
+âââ screens/.md Ă©cran
+âââ screens/.html maquette de l'Ă©cran, tous ses Ă©tats dans un seul fichier
+âââ journeys/.md parcours utilisateur
+âââ reviews/.md verdicts d'une review du design system
+âââ showcase/ vitrine gĂ©nĂ©rĂ©e, jamais Ă©crite Ă la main, ignorĂ©e par git
+```
+
+Le contrat UI d'une feature, `ui.md`, vit dans le dossier de sa tùche, à cÎté du plan. Il n'est jamais versé dans `aidd_docs/ui/`.
+
+### Fiches
+
+Chaque fiche porte en tĂȘte : `id` (Ă©gal au nom du fichier, unique), `kind`, `status`, `version`.
+
+| Fiche | Contient en plus |
+| --- | --- |
+| `system.md` | version du format des fiches, version du design system, plateformes, breakpoints, thĂšmes, langues et sens d'Ă©criture, groupes de tokens primitifs, dossiers UI du code, fichier oĂč le code consomme les tokens, chemins ignorĂ©s, librairies de composants utilisĂ©es ; rĂšgles transverses s'il y en a |
+| `brand.md` | projet, utilisateurs, domaine, plateformes, personnalité et ton, charte imposée s'il y en a une, références et anti-références ; glossaire (mot à employer, mots interdits) s'il y en a un |
+| asset | type (icĂŽne, image, illustration), fichier, texte alternatif attendu |
+| atome, molécule, composition | quand l'utiliser et quand ne pas l'utiliser, éléments utilisés, tokens sémantiques utilisés, variantes, états (au moins défaut ; focus quand il est interactif ; survol, désactivé, chargement ou erreur quand ils existent), comportement clavier et rÎle accessible, lien vers le code |
+| écran | éléments utilisés, tokens sémantiques utilisés, états (au moins défaut, vide, chargement, erreur, sans droits quand ils existent), breakpoints couverts, route ou fichier réel, lien vers le code |
+| parcours | persona, étapes ordonnées, chaque étape un écran et l'action qui mÚne à la suivante |
+| review | écrans revus, verdict OK ou pas OK par écran, par état et par version revue, commentaire, écrans restants |
+
+### Cycle de vie
+
+Deux axes, jamais mĂȘlĂ©s : oĂč en est le design, et quelle version le code implĂ©mente.
+
+- `status` dit le design : `proposĂ©` (créé, pas encore validĂ©), `validĂ©` (rendu approuvĂ© en review), `dĂ©prĂ©ciĂ©` (ne doit plus ĂȘtre utilisĂ©, nomme son remplaçant ou dit pourquoi il n'en a pas).
+- `implemented-version` dit le code : la version de l'élément que le code implémente. Le code est à jour quand elle égale `version`, en retard quand elle est plus petite, absente tant que rien n'est codé.
+- Le lien vers le code est obligatoire dÚs qu'`implemented-version` existe, sauf pour une molécule ou une composition implémentée par ce qu'elle utilise : chaque élément utilisé, hors assets, est implémenté.
+- Modifier un élément implémenté monte sa `version` et le repasse à `proposé` ; son `implemented-version` ne bouge pas, et le retard reste visible jusqu'à ce que la feature fasse suivre le code.
+- Un élément non déprécié n'utilise jamais un élément déprécié.
+
+### Lien avec le code
+
+- Un Ă©lĂ©ment correspond Ă un seul fichier d'entrĂ©e, identifiĂ© par son chemin et le nom du composant exportĂ©. Quand ce fichier est dans un dossier qui porte son nom, le lien couvre tout le dossier (styles, hooks, tests) ; sinon, seulement les fichiers de mĂȘme nom.
+- Un Ă©lĂ©ment utilise des Ă©lĂ©ments de son niveau ou d'un niveau infĂ©rieur, jamais lui-mĂȘme.
+- Un composant de librairie est lié à son paquet et à son nom exporté.
+- Chaque fichier sous un dossier UI déclaré est lié à une fiche ou couvert par un chemin ignoré.
+- Les usages se lisent dans les fiches : un token mÚne aux composants qui le déclarent, un composant aux compositions et écrans qui l'utilisent, un écran aux parcours, chaque élément à son fichier de code. Le rapport d'impact suit ce chemin.
+
+### Versions et historique
+
+- Chaque élément a une version entiÚre et un historique : une ligne par version, avec le changement, s'il est cassant, et la tùche qui l'a fait.
+- Une tĂąche se nomme par le chemin de son dossier, `aidd_docs/tasks/âŠ` : historiques, `ui.md` et plan se rejoignent sur cette valeur.
+- Est cassant : retirer une variante, un état ou une propriété, renommer ou supprimer un token, déprécier un élément.
+- Le design system a sa propre version dans `system.md`, qui ne bouge qu'Ă une publication : une ligne liste les tĂąches publiĂ©es ; elle est cassante, et monte la majeure, quand une de ces tĂąches a cassĂ© un Ă©lĂ©ment ou quand elle le dit elle-mĂȘme, pour un changement de tokens seuls ; sinon elle monte la mineure. Une tĂąche pas encore publiĂ©e n'est pas une erreur, et deux features parallĂšles sur des Ă©lĂ©ments distincts n'entrent jamais en conflit.
+- Deux lignes d'historique qui revendiquent la mĂȘme version d'un Ă©lĂ©ment sont signalĂ©es, pour que la personne tranche, comme un conflit git.
+- `system.md` porte la version du format des fiches ; une version que le validateur ne connaĂźt pas donne un seul message, jamais une avalanche.
+
+### Changements
+
+- Un changement produit d'abord un rapport d'impact : éléments, écrans, parcours et fichiers de code touchés.
+- AprÚs validation, les rendus HTML touchés sont régénérés, revus si leur aspect change, puis versions et historiques sont mis à jour.
+- Le code suit dans la feature qui porte le changement, jamais en dehors.
+
+### Feature
+
+- Une feature qui touche l'UI a son `ui.md` : écrans et éléments réutilisés, créés ou modifiés, et ce que le code doit respecter.
+- Une feature sans UI n'en a pas, et rien ne se déclenche.
+- Une feature qui ne fait que réutiliser ne change aucune version.
+- Une fois la feature implémentée, son code est vérifié contre son `ui.md` et les liens vers le code sont mis à jour.
+
+### Rendus
+
+- Ăcrans et composants sont rendus en HTML avec les vrais tokens, sans valeur en dur.
+- Les tokens ont deux niveaux : les primitifs (la palette) et les sémantiques, qui pointent vers eux. Un composant ou un écran n'utilise que des sémantiques, et son rendu n'emploie que les tokens que sa fiche déclare : le rapport d'impact n'en oublie aucun.
+- Les tokens sont la seule source ; aidd-ui gĂ©nĂšre `aidd_docs/ui/tokens.css` et n'Ă©crit jamais dans le code. `system.md` nomme le fichier oĂč le code consomme les tokens ; la feature le fait suivre, l'audit compare les deux.
+- Les thĂšmes suivent le module Resolver de DTCG : `tokens.json` porte le premier thĂšme dĂ©clarĂ©, la base ; chaque autre thĂšme a son fichier, qui ne redĂ©finit que des tokens de la base, avec le mĂȘme type. Un rendu montre un thĂšme sous `[data-theme]`, avec les mĂȘmes noms de variables.
+- Une valeur que DTCG ne sait pas porter (`color-mix()`, `%`, `em`) est gardée en CSS brut dans une extension du token ; les `var()` qu'elle cite sont des tokens existants et comptent comme des dépendances.
+- Un écran montre tous ses états dans un seul fichier, avec un sélecteur d'état.
+- La vitrine est générée sans IA, à partir des fiches et de gabarits fixes : identique à chaque génération, elle montre chaque élément, ses variantes, ses états, ses usages et son lien vers le réel.
+- La vitrine affiche la version validée d'un écran et un lien vers le réel.
+
+### Review
+
+- La review design se fait dans une page HTML statique, sans serveur : OK ou pas OK par écran et par état, avec un commentaire ; la page produit la fiche review, que la personne enregistre.
+- Un verdict porte la version revue : il ne vaut plus pour une version suivante.
+- Les verdicts sont enregistrés dans une fiche review, qui vit avec ce qu'elle revoit : `aidd_docs/ui/reviews/` pour une review du design system, le dossier de la tùche pour une review de feature. Quand la personne dit qu'elle a fini, l'IA lit la fiche et continue.
+- Une review partielle garde les Ă©crans restants, et l'IA reprend lĂ oĂč elle s'est arrĂȘtĂ©e.
+
+### i18n et accessibilité
+
+- `system.md` déclare les langues et les sens d'écriture.
+- Les rendus utilisent des propriétés logiques plutÎt que physiques quand un sens droite-gauche est déclaré, et aucune largeur fixe sur du texte.
+- Dates et nombres suivent la langue.
+- Base d'accessibilité : WCAG 2.2 AA, mobile d'abord, texte qui suit la taille choisie par la personne, aucun comportement qui n'existe qu'au survol sur écran tactile.
+
+### Vérification
+
+- La structure se vĂ©rifie Ă chaque Ă©criture dans le design system, quel que soit le skill qui Ă©crit, et Ă la main, par des scripts dĂ©terministes, sans IA, qui tournent sur toute machine oĂč tourne Node :
+ - format de chaque fiche et du fichier de tokens ;
+ - éléments utilisés et alias de tokens qui existent ;
+ - cycle de vie respecté ;
+ - liens vers le code valides, couverture des dossiers UI ;
+ - aucune valeur en dur dans les rendus, aucune couleur en dur dans le code ;
+ - aucun token primitif dans un composant ;
+ - versions en double dans un historique ;
+ - propriétés physiques quand un sens droite-gauche est déclaré.
+- Chaque problÚme porte sa rÚgle et sa gravité. Une erreur bloque la fin du tour ; un avertissement informe sans bloquer : fichier de code sans fiche, trait fin en px. AprÚs une écriture, l'IA voit les problÚmes des fichiers écrits et de ceux qui en dépendent (le rendu de l'élément écrit, les fiches qui le citent), ou tous quand elle a écrit un fichier partagé (tokens, thÚmes, `system.md`), et le nombre des autres erreurs ; en fin de tour, toutes les erreurs ; la sortie du hook est plafonnée, la commande à la main montre tout.
+- Une rĂšgle qui plante devient un problĂšme qui la nomme, jamais un silence.
+- Un seul moteur porte la lecture des fiches, la vérification et, plus tard, le graphe, la génération des tokens, la vitrine et la page de review ; chaque skill l'appelle par sous-commande.
+- L'IA juge le sens : le code fait-il ce que disent `ui.md` et les fiches ?
+- L'audit UI constate et ne corrige rien : élément sans code, code sans élément, écart entre le validé et le réel, doublons proches, valeurs en dur autres que les couleurs dans le code, écart entre `tokens.json` et les tokens du code, mots interdits par le glossaire, textes trop longs dans la langue la plus longue, accessibilité.
+
+### Format
+
+- Les skills suivent le format lean de brainstorm, plan et implement.
+- Les artefacts ne dépendent d'aucune techno ; Figma et Claude Design se branchent, le contrat du projet fait foi.
+
+## Non-goals
+
+- Plusieurs design systems dans un projet, ou une app web et une app mobile avec deux systĂšmes.
+- Synchronisation dans les deux sens avec Figma ou Claude Design, et import de maquettes existantes.
+- Retour à une version précédente du design system.
+- Travail Ă plusieurs, et parcours pour un designer qui ne code pas.
+- Rendu du réel hors web.
+- Détection des valeurs en dur dans du code non web.
+
+## Done-when
+
+- Sur un projet vierge puis sur un projet existant, le flow passe de la marque jusqu'à tous les écrans voulus, chacun validé en review.
+- Les features générées ensuite sont liées au design system, sans dérive, et leur code est vérifié.
+- Un changement de token, de composant et d'écran produit chacun son rapport d'impact, ses régénérations et ses nouvelles versions, sans élément oublié.
+- Deux features parallĂšles sur le mĂȘme Ă©lĂ©ment aboutissent Ă une version en double signalĂ©e.
+- La vitrine générée deux fois de suite est identique.
+- Chaque rÚgle de vérification a un cas qui la fait échouer.
+- Sur Gouvernail et Kairos, le design system est extrait du code existant.
+
+## Open Questions
+
+- TBD: quel projet pilote pour le cas vierge, sachant que Gouvernail et Kairos, pilotes du cas existant, ont déjà une identité ? Gouvernail a déjà ses propres contrÎles de tokens et aucune couleur en dur : il éprouve l'extraction facile, pas le cas d'une app qui dérive.
+
+## Décisions pour les PR suivantes
+
+| PR | Décision |
+| --- | --- |
+| 2 | Un élément modifié sans montée de version se détecte en comparant à la version committée ; le moteur expose le graphe d'usages en JSON pour le rapport d'impact. |
+| 2 | Le flux de changement vit dans `01-system` : il rĂ©gĂ©nĂšre les rendus touchĂ©s, Ă©crans compris, par une sous-commande du moteur, puis s'arrĂȘte et dit qu'une review est attendue, sans nommer le skill qui la conduit. |
+| 2 | Un composant Vue ou Svelte, qui n'a pas de nom exporté, se lie par son seul chemin, quand l'extraction en rencontre un. |
+| 3 | La page de review est un fichier statique : elle produit la fiche review Ă enregistrer, sans serveur. |
+| 4 | `ui.md` cite les éléments par `id@version`, pour que la vérification d'aprÚs code voie ce qui a bougé pendant la feature. |
+| 5 | `system.md` nomme le catalogue de traductions pour les textes trop longs ; l'audit d'une app existante part d'une base d'écarts acceptés, sinon il noie la personne. |
+| 5 | Tailwind et Storybook restent hors détection ; l'audit les nomme comme limites quand il les trouve. |
+
+## Context
+
+- Brief approuvé : [brainstorm.md](./brainstorm.md).
+- Découpage en pull requests : [roadmap.md](./roadmap.md).
diff --git a/docs/CATALOG.md b/docs/CATALOG.md
index b7a04036b..bffe53165 100644
--- a/docs/CATALOG.md
+++ b/docs/CATALOG.md
@@ -135,6 +135,18 @@ Runs synchronous feature delivery, optional async issue automation, and the prod
| ---------- | ----------------------------------------- | ---------- |
| `01-hello` | Smoke-test that confirms the plugin loads | `01-greet` |
+
+
+**Hooks**
+
+| Event | Runs |
+| --- | --- |
+| `SessionStart` | `hooks/ui-hook.cjs` |
+| `PostToolUse` | `hooks/ui-hook.cjs` |
+| `Stop` | `hooks/ui-hook.cjs` |
+
+
+
## đ aidd-telemetry
đ§Ș **Beta.** Measurement: counts the tokens of Claude Code and Codex sessions, per person, session, model, agent, tool, day, repository, task and ticket, and keeps everything on your machine.
diff --git a/docs/assets/plugins/plugins.json b/docs/assets/plugins/plugins.json
index 6d3673ce4..d3d89004e 100644
--- a/docs/assets/plugins/plugins.json
+++ b/docs/assets/plugins/plugins.json
@@ -31,7 +31,7 @@
},
"aidd-ui": {
"status": "alpha",
- "description": "UI and UX design, smoke test only",
+ "description": "UI design system, checked on every write",
"emoji": "đš"
},
"aidd-telemetry": {
diff --git a/plugins/aidd-ui/CATALOG.md b/plugins/aidd-ui/CATALOG.md
index 73912f57f..13965a09b 100644
--- a/plugins/aidd-ui/CATALOG.md
+++ b/plugins/aidd-ui/CATALOG.md
@@ -7,6 +7,7 @@ Auto-generated index of skills, agents, references and assets shipped by the `ai
## Table of Contents
- [`.claude-plugin`](#claude-plugin)
+- [`hooks`](#hooks)
- [`skills`](#skills)
- [`skills/01-hello`](#skills01-hello)
@@ -18,6 +19,12 @@ Auto-generated index of skills, agents, references and assets shipped by the `ai
|------|
| [plugin.json](.claude-plugin/plugin.json) |
+### `hooks`
+
+| File |
+|------|
+| [hooks.json](hooks/hooks.json) |
+
### `skills`
#### `skills/01-hello`
diff --git a/plugins/aidd-ui/README.md b/plugins/aidd-ui/README.md
index 097fb8ab8..c2ce730eb 100644
--- a/plugins/aidd-ui/README.md
+++ b/plugins/aidd-ui/README.md
@@ -6,7 +6,7 @@ UI and UX design.
| | |
| --- | --- |
-| Status | alpha (`0.1.0-alpha.0`) |
+| Status | alpha |
| Install | `/plugin install aidd-ui@aidd-framework` |
| Start with | `/aidd-ui:01-hello` |
| Skills, agents, hooks | [Catalog](../../docs/CATALOG.md#-aidd-ui) |
@@ -15,6 +15,95 @@ UI and UX design.
> Not ready for use.
> Skills and names may change or disappear without notice.
+## Design system
+
+One file per element, under `aidd_docs/ui/`.
+
+```txt
+aidd_docs/ui/
+âââ system.md the design system
+âââ brand.md the brand
+âââ tokens.json tokens of the base theme, DTCG 2025.10
+âââ tokens.resolver.json the other themes, DTCG Resolver 2025.10
+âââ themes/.json the tokens one theme redefines
+âââ assets/.md an icon, image or illustration, its file beside it
+âââ components/.md an atom, molecule or composition
+âââ components/.html its render: every variant and state
+âââ screens/.md a screen
+âââ screens/.html its render: every state
+âââ journeys/.md a user journey
+âââ reviews/.md the verdicts of a review
+```
+
+Every record opens with flat front matter, `key: value` or `key: [a, b]`, and closes with a `## History` table: Version, Change, Breaking (`yes` or `no`), Task. A task is its folder, `aidd_docs/tasks/âŠ`.
+
+| Record | Front matter | Body |
+| --- | --- | --- |
+| every one | `id` (the file name), `kind`, `status` (`proposed`, `validated`, `deprecated`), `version` | `## History` |
+| `system.md` | `format` (`1`), `version` in semver, `platforms`, `breakpoints`, `themes`, `locales`, `directions`, `primitives` (token groups components never cite), `code-roots`, `ignore`, `libraries`; optional `code-tokens` | optional `## Rules`: Id, Rule |
+| `brand.md` | | `## Project`, `## Users`, `## Domain`, `## Platforms`, `## Personality and tone`, `## References`, `## Anti-references`; optional `## Glossary`: Concept, Word, Forbidden |
+| atom, molecule, composition | `uses`, `tokens`, `variants`, `states`, `interactive` (`yes` or `no`), `role`; optional `code`, `implemented-version` | `## Usage`, and `## Keyboard` when interactive |
+| screen | `uses`, `tokens`, `states`, `breakpoints`, `route`; optional `code`, `implemented-version` | |
+| journey | `persona` | `## Steps`: Screen, Action |
+| asset | `type` (`icon`, `image`, `illustration`), `file`, `alt` | |
+| review | `scope`, `screens` | `## Verdicts`: Screen, State, Version, Verdict (`ok`, `ko`, `pending`), Comment |
+
+### Versions
+
+- `status` tracks the design; `implemented-version` is the version the code implements. Code is current when it equals `version`, behind when lower.
+- `code` is `path#Export`, or `package:#Export` for a library component. It is required with `implemented-version`, except for a molecule or composition whose used elements are all implemented.
+- `system.md` takes a history row only when a version is published; its Task cell lists the published tasks, comma-separated. A row that publishes a task which broke an element is breaking and raises the major; a token-only change says `yes` itself. Any other row raises the minor.
+- A verdict counts for the version it names only.
+- A `format` this validator does not read is reported alone.
+
+### Links and tokens
+
+- A link to a file in a folder of its own name covers that folder, styles, hooks and tests; among siblings it covers only the files of its name.
+- An element uses elements of its own level or lower, never itself; an interactive component has `focus`, and `hover` or `disabled` only when they exist.
+- A deprecated element names `replaced-by: `, or `replaced-by: none` with a `## Deprecation` section.
+- A render uses only the tokens its record lists in `tokens`.
+- `code-tokens` names the file where the code consumes the tokens; it is never checked for literals or coverage.
+- Themes: the first theme in `system.md` is held by `tokens.json`. `tokens.resolver.json` has a set whose sources hold `{"$ref": "tokens.json"}`, a `theme` modifier with one context per theme and the base as `default`, and `{"$ref": "#/modifiers/theme"}` in `resolutionOrder`. A context lists `{"$ref": ""}` sources, each redefining base tokens with their type.
+- A value DTCG cannot hold, such as `color-mix()` or `100%`, is raw CSS: `"$extensions": {"dev.ai-driven.css": ""}`. Each `var()` in it must name a token.
+- `$root` is not supported: name the token.
+- A token's CSS name is `--` plus its path joined by `-`.
+
+## Validation
+
+```sh
+node /hooks/aidd-ui.cjs validate [project-root]
+```
+
+It prints `VALID`, or one `â : ()` line per error and `â ` per warning, and exits 1 when an error remains. Warnings: a file under a code root no record links, and a render length of 2px or less.
+
+A hook runs the same check:
+
+| Moment | Prints |
+| --- | --- |
+| Session start | the counts, then the errors |
+| After a write under `aidd_docs/ui/` or a code root | the problems of the written files, their record's render and the records citing it, or all of them after a write to `system.md`, the tokens or a theme; then the count of other errors |
+| End of a turn that wrote such a file | the errors, as a reason to continue, once |
+
+Each output stops at 20 problem lines and names the command that prints the rest.
+
+## Limits
+
+- Without `node` on `PATH`, the hook does nothing.
+- After a write, the problems reach the model in the same turn on Claude Code, Codex, Copilot CLI, OpenCode and Kilo. At turn end, OpenCode and Kilo receive the errors as the next prompt. A forced continuation is never asked again. The turn mark lives in the OS temp directory; without a session id, no end-of-turn check runs.
+- Cursor is unverified.
+- Codex runs the hook only once you trust it; Copilot only in a folder it trusts, and says nothing otherwise.
+- A file written by a shell command, or deleted by a patch, is not seen.
+- A problem a write causes in a record that does not cite the written one, such as a use of a renamed id, appears at turn end only.
+- The Resolver is read in one profile: a `theme` modifier with file sources. Inline sources and other modifiers are not checked.
+- Composite tokens are checked for a `$value`, not for its shape.
+- `code-roots` and `ignore` are path prefixes, not globs. Every folder under a code root is walked, `node_modules`, build output and dot-folders included: ignore them. A linked folder counts as one file no record links.
+- A render shows a variant or state when it holds its `data-variant` or `data-state` attribute; an export exists when its name is a word of the linked file, a comment included.
+- The hook command names `${CLAUDE_PLUGIN_ROOT}` unquoted, like every plugin here: a plugin path with spaces breaks it.
+- In code, only colour literals are found, in web code only, outside tests and mocks; other literals are left to the UI audit.
+- In script files, `.vue` and `.svelte` included, colours are found in string literals only. A Vue or Svelte component needs an export name in its link.
+- A word of hex letters after `#` in a stylesheet or a string, such as `#add`, counts as a colour.
+- Fixed widths on text and long translations are left to the UI audit.
+
## Try it from a checkout
| Scope | Run |
diff --git a/plugins/aidd-ui/hooks/aidd-ui.cjs b/plugins/aidd-ui/hooks/aidd-ui.cjs
new file mode 100644
index 000000000..a553d528e
--- /dev/null
+++ b/plugins/aidd-ui/hooks/aidd-ui.cjs
@@ -0,0 +1,64 @@
+#!/usr/bin/env node
+// Usage: node aidd-ui.cjs [project-root]
+// The design system engine the hook and every skill call, one command per job:
+// validate checks records, history, tokens, renders and code links. Prints VALID, or one line
+// per problem, and exits 1 when an error remains. Prints nothing without a design system.
+const { load, unreadable, UI_DIR, WARNING } = require("./lib/project.cjs");
+const records = require("./lib/records.cjs");
+const history = require("./lib/history.cjs");
+const tokens = require("./lib/tokens.cjs");
+const renders = require("./lib/renders.cjs");
+const code = require("./lib/code.cjs");
+
+const CHECKS = { records, history, tokens, renders, code };
+const VERDICT_VALID = "VALID";
+const EXIT_VALID = 0;
+const EXIT_INVALID = 1;
+const EXIT_USAGE = 2;
+const ERROR_MARK = "â";
+const WARNING_MARK = "â ";
+
+/** Every problem of a loaded project, check by check, then each file no check could read. A
+ * check that throws is one problem naming it, so the others still speak. A design system
+ * written for another format is that one problem alone. */
+function problemsOf(project, checks = CHECKS) {
+ const otherFormat = records.formatProblem(project);
+ if (otherFormat) return [otherFormat];
+ return [
+ ...Object.entries(checks).flatMap(([name, check]) => {
+ try {
+ return check.check(project);
+ } catch (error) {
+ return [{ file: UI_DIR, message: `check "${name}" failed: ${error.message}` }];
+ }
+ }),
+ ...unreadable(project),
+ ];
+}
+
+/** Whether a problem blocks: anything a rule does not call a warning. */
+const isError = (problem) => problem.severity !== WARNING;
+
+/** One `â : ()` line per problem, `â ` for a warning, the file relative to
+ * the project root. */
+const format = (problems) => problems.map((problem) => `${isError(problem) ? ERROR_MARK : WARNING_MARK} ${problem.file}: ${problem.message}${problem.rule ? ` (${problem.rule})` : ""}`);
+
+function validate(root) {
+ const project = load(root);
+ if (project === null) return EXIT_VALID;
+ const problems = problemsOf(project);
+ console.log(problems.length ? format(problems).join("\n") : VERDICT_VALID);
+ return problems.some(isError) ? EXIT_INVALID : EXIT_VALID;
+}
+
+const COMMANDS = { validate };
+
+function main([command, root = process.cwd()]) {
+ if (Object.hasOwn(COMMANDS, command)) return COMMANDS[command](root);
+ console.error(`usage: node aidd-ui.cjs <${Object.keys(COMMANDS).join("|")}> [project-root]`);
+ return EXIT_USAGE;
+}
+
+if (require.main === module) process.exitCode = main(process.argv.slice(2));
+
+module.exports = { problemsOf, format, isError };
diff --git a/plugins/aidd-ui/hooks/hooks.json b/plugins/aidd-ui/hooks/hooks.json
new file mode 100644
index 000000000..9df9f9050
--- /dev/null
+++ b/plugins/aidd-ui/hooks/hooks.json
@@ -0,0 +1,38 @@
+{
+ "hooks": {
+ "SessionStart": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/ui-hook.cjs",
+ "timeout": 30
+ }
+ ]
+ }
+ ],
+ "PostToolUse": [
+ {
+ "matcher": "Write|Edit|MultiEdit",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/ui-hook.cjs",
+ "timeout": 30
+ }
+ ]
+ }
+ ],
+ "Stop": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/ui-hook.cjs",
+ "timeout": 30
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/plugins/aidd-ui/hooks/lib/code.cjs b/plugins/aidd-ui/hooks/lib/code.cjs
new file mode 100644
index 000000000..1e2f33275
--- /dev/null
+++ b/plugins/aidd-ui/hooks/lib/code.cjs
@@ -0,0 +1,172 @@
+// C1 to C8: each code link resolves, every file under a code root is linked or ignored, UI code
+// outside its tests holds no colour literal, and the code's token file exists. Other literals
+// and non-web code are stated limits.
+const fs = require("node:fs");
+const path = require("node:path");
+const { list } = require("./markdown.cjs");
+const { placed, systemOf, isElement, fm } = require("./model.cjs");
+const { PATTERNS, findLiterals, declaresRightToLeft } = require("./literals.cjs");
+const { walk, text, relative, isUnder, checkOf, WARNING } = require("./project.cjs");
+
+const EXPORT_SEPARATOR = "#";
+const PACKAGE_PREFIX = "package:";
+const CODE_ROOTS_KEY = "code-roots";
+const CODE_TOKENS_KEY = "code-tokens"; // where the code consumes the tokens: generated from them, so no literal there is a fault
+const IGNORE_KEY = "ignore";
+// A test or a mock states colours to check them, as a token contract test does.
+const TEST_FOLDER = /(?:^|\/)(?:__tests__|__mocks__)\//;
+const TEST_NAME = /\.(?:test|spec)\.[^/]+$/;
+const STYLESHEET_EXTENSIONS = [".css", ".scss"];
+const SCRIPT_EXTENSIONS = [".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte"];
+const SCANNED_EXTENSIONS = [...STYLESHEET_EXTENSIONS, ...SCRIPT_EXTENSIONS, ".html"];
+
+const REGEX_SPECIALS = /[.*+?^${}()|[\]\\]/g;
+const NOT_LINE_BREAK = /[^\n]/g;
+// a comment, then a string or template literal, then any other character
+const SCRIPT_TOKEN = /\/\/[^\n]*|\/\*[\s\S]*?\*\/|("(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`(?:\\[\s\S]|[^`\\])*`)|[^\n"'`/]+|[^\n]/g;
+
+/** A script with all but its string and template literals blanked, lines kept: a colour in
+ * code lives in a string, and a `#` in a comment is an issue number. */
+const stringsOnly = (source) => source.replace(SCRIPT_TOKEN, (found, literal) => literal ?? found.replace(NOT_LINE_BREAK, " "));
+
+/** A `code-roots` or `ignore` list as normalised path prefixes, so `./src` reads as `src`. */
+const prefixes = (project, key) => list(systemOf(project)?.frontMatter[key]).map((prefix) => path.posix.normalize(prefix));
+/** Whether a normalised prefix names a folder inside the project, never the project itself. */
+const isInside = (prefix) => prefix !== "." && !prefix.startsWith("..") && !path.posix.isAbsolute(prefix);
+const codeRoots = (project) => prefixes(project, CODE_ROOTS_KEY).filter(isInside);
+
+/** A `code` value as `{target, name}`, `target` being a path or `package:`; null when malformed. */
+function parseLink(value) {
+ if (typeof value !== "string") return null;
+ const at = value.lastIndexOf(EXPORT_SEPARATOR);
+ if (at <= 0 || at === value.length - 1) return null;
+ const target = value.slice(0, at);
+ return { target, name: value.slice(at + 1), library: target.startsWith(PACKAGE_PREFIX) ? target.slice(PACKAGE_PREFIX.length) : null };
+}
+
+/** Elements with a `code` value, each with its parsed link. */
+const linked = (project) =>
+ placed(project)
+ .filter((record) => isElement(record) && "code" in fm(record))
+ .map((record) => ({ record, value: fm(record).code, link: parseLink(fm(record).code) }));
+
+const hasWord = (text, word) => new RegExp(`(? walk(path.join(project.root, root)).map((file) => relative(project.root, file)));
+ const tokenFile = codeTokens(project);
+ project.codeTree = { files: [...new Set(files)], ignore: [...prefixes(project, IGNORE_KEY), ...(tokenFile ? [tokenFile] : [])] };
+ }
+ return project.codeTree;
+}
+
+/** Every file under a code root that no `ignore` prefix covers, relative to the project root. */
+function codeFiles(project) {
+ const tree = codeTree(project);
+ return tree.files.filter((file) => !tree.ignore.some((prefix) => isUnder(file, prefix)));
+}
+
+const scanned = (project, extensions) => codeFiles(project).filter((file) => extensions.includes(path.extname(file)));
+
+/** A code file's text as the literal patterns read it: scripts keep only their strings. */
+function searchable(project, file) {
+ const source = text(project, file) ?? "";
+ return SCRIPT_EXTENSIONS.includes(path.extname(file)) ? stringsOnly(source) : source;
+}
+
+function linkProblems(project) {
+ return linked(project).flatMap(({ record, value, link }) => {
+ if (!link) return [{ file: record.file, message: `code "${value}" must be path#Export or package:#Export` }];
+ if (link.library !== null) return [];
+ const full = path.join(project.root, link.target);
+ if (!fs.existsSync(full) || !fs.statSync(full).isFile()) return [{ file: record.file, message: `code file "${link.target}" does not exist` }];
+ const source = text(project, link.target);
+ if (source === null || hasWord(source, link.name)) return [];
+ return [{ file: record.file, message: `export "${link.name}" is not in ${link.target}` }];
+ });
+}
+
+function libraryProblems(project) {
+ const libraries = list(systemOf(project)?.frontMatter.libraries);
+ return linked(project)
+ .filter(({ link }) => link && link.library !== null && !libraries.includes(link.library))
+ .map(({ record, link }) => ({ file: record.file, message: `package "${link.library}" is not in system.md libraries` }));
+}
+
+/** The base name of a file up to its first dot: `ui-button.component.tsx` gives `ui-button`. */
+const stem = (file) => path.posix.basename(file).split(".")[0];
+
+/** Whether a link covers a file. A component in a folder of its own name covers that folder,
+ * styles, hooks and tests included; one among siblings covers only the files of its stem. */
+function covers(target, file) {
+ const folder = path.posix.dirname(target);
+ if (path.posix.basename(folder) === stem(target)) return isUnder(file, folder);
+ return path.posix.dirname(file) === folder && stem(file) === stem(target);
+}
+
+/** The record whose code link covers `file`, project-relative; undefined when none does. */
+const codeOwner = (project, file) => linked(project).find(({ link }) => link && link.library === null && covers(path.posix.normalize(link.target), file))?.record;
+
+function coverageProblems(project) {
+ const targets = linked(project).flatMap(({ link }) => (link && link.library === null ? [path.posix.normalize(link.target)] : []));
+ return codeFiles(project)
+ .filter((file) => !targets.some((target) => covers(target, file)))
+ .map((file) => ({ severity: WARNING, file, message: "no record links it and no ignore covers it" }));
+}
+
+const literalProblems = (pattern, extensions, applies = () => true) => (project) =>
+ applies(project)
+ ? scanned(project, extensions)
+ .filter((file) => !TEST_FOLDER.test(file) && !TEST_NAME.test(file))
+ .flatMap((file) => findLiterals(searchable(project, file), pattern).map((found) => ({ file, ...found })))
+ : [];
+
+function codeTokensProblems(project) {
+ const file = codeTokens(project);
+ if (file === null || fs.existsSync(path.join(project.root, file))) return [];
+ return [{ file: systemOf(project).file, message: `code-tokens "${file}" does not exist` }];
+}
+
+function sharedLinkProblems(project) {
+ const owners = new Map();
+ return linked(project).flatMap(({ record, value }) => {
+ const owner = owners.get(value);
+ if (!owner) {
+ owners.set(value, record);
+ return [];
+ }
+ return [{ file: record.file, message: `code "${value}" is also ${owner.file}'s` }];
+ });
+}
+
+function rootProblems(project) {
+ const system = systemOf(project);
+ return prefixes(project, CODE_ROOTS_KEY)
+ .filter((prefix) => !isInside(prefix))
+ .map((prefix) => ({ file: system.file, message: `code root "${prefix}" must name a folder inside the project` }));
+}
+
+const RULES = {
+ C1: linkProblems,
+ C2: libraryProblems,
+ C3: coverageProblems,
+ C4: literalProblems(PATTERNS.colour, SCANNED_EXTENSIONS),
+ C5: sharedLinkProblems,
+ C6: literalProblems(PATTERNS.physical, STYLESHEET_EXTENSIONS, declaresRightToLeft),
+ C7: rootProblems,
+ C8: codeTokensProblems,
+};
+
+/** Every broken code rule, in rule order. */
+const check = checkOf(RULES);
+
+module.exports = { check, codeRoots, codeOwner };
diff --git a/plugins/aidd-ui/hooks/lib/graph.cjs b/plugins/aidd-ui/hooks/lib/graph.cjs
new file mode 100644
index 000000000..154a620e2
--- /dev/null
+++ b/plugins/aidd-ui/hooks/lib/graph.cjs
@@ -0,0 +1,41 @@
+// Which files a write reaches: the written files, the record each belongs to with its render,
+// and every record citing that one. The feedback after a write names what it broke elsewhere
+// without repeating the whole design system.
+const { list, table } = require("./markdown.cjs");
+const { placed, fm } = require("./model.cjs");
+const { renderOf } = require("./renders.cjs");
+const { codeOwner } = require("./code.cjs");
+const { isUnder, UI_DIR } = require("./project.cjs");
+
+// Files every record reads: a write to one reaches the whole design system.
+const SHARED_FILES = ["system.md", "tokens.json", "tokens.resolver.json"].map((name) => `${UI_DIR}/${name}`);
+const SHARED_DIRS = [`${UI_DIR}/themes`];
+const STEPS_SECTION = "Steps";
+
+const isShared = (file) => SHARED_FILES.includes(file) || SHARED_DIRS.some((dir) => isUnder(file, dir));
+
+/** The ids a record cites: what it uses, what replaces it, the screens a review or journey names. */
+const cited = (record) => [
+ ...list(fm(record).uses),
+ ...list(fm(record)["replaced-by"]),
+ ...(fm(record).kind === "review" ? list(fm(record).screens) : []),
+ ...table(record.sections[STEPS_SECTION]).map((row) => row.Screen),
+];
+
+/** The record a written file belongs to: the record itself, its render, or the code it links. */
+const ownerOf = (project, records, file) => records.find((record) => record.file === file || renderOf(record) === file) ?? codeOwner(project, file);
+
+/** The files a write of `written`, project-relative, reaches; null when it reaches them all. */
+function reached(project, written) {
+ if (written.some(isShared)) return null;
+ const records = placed(project);
+ const files = new Set(written);
+ for (const owner of written.map((file) => ownerOf(project, records, file)).filter(Boolean)) {
+ files.add(owner.file);
+ files.add(renderOf(owner));
+ for (const record of records) if (cited(record).includes(fm(owner).id)) files.add(record.file);
+ }
+ return files;
+}
+
+module.exports = { reached };
diff --git a/plugins/aidd-ui/hooks/lib/history.cjs b/plugins/aidd-ui/hooks/lib/history.cjs
new file mode 100644
index 000000000..99e6791df
--- /dev/null
+++ b/plugins/aidd-ui/hooks/lib/history.cjs
@@ -0,0 +1,138 @@
+// H1 to H7: each versioned record's history, the system version its publications raise, and
+// the task folder each change names.
+const { table, list } = require("./markdown.cjs");
+const { placed, systemOf, isElement, isSystem, problem, fm, INTEGER_VERSION, SEMVER } = require("./model.cjs");
+const { checkOf } = require("./project.cjs");
+
+const HISTORY_SECTION = "History";
+const BREAKING_VALUES = ["yes", "no"];
+const BREAKING = "yes";
+const TASKS_DIR = "aidd_docs/tasks/";
+
+const rowsOf = (record) => table(record.sections[HISTORY_SECTION]);
+/** A system row's Task cell: the tasks one publication lists, comma-separated. */
+const taskList = (cell) => list(cell?.split(",").map((task) => task.trim()).filter(Boolean));
+
+/** A version as comparable numbers: [n] for an element, [major, minor, patch] for the system. */
+function parts(version, system) {
+ if (system) {
+ const match = SEMVER.exec(version ?? "");
+ return match ? match.slice(1).map(Number) : null;
+ }
+ return INTEGER_VERSION.test(version ?? "") ? [Number(version)] : null;
+}
+
+function compare(a, b) {
+ for (let index = 0; index < a.length; index += 1) if (a[index] !== b[index]) return a[index] - b[index];
+ return 0;
+}
+
+/** Rows whose version parses, each with its parts, lowest version first. */
+const byVersion = (rows, system) =>
+ rows
+ .map((row) => ({ row, parts: parts(row.Version, system) }))
+ .filter((entry) => entry.parts)
+ .sort((a, b) => compare(a.parts, b.parts));
+
+/** Records that carry a version, which every history rule reads. */
+const versioned = (project) => placed(project).filter((record) => "version" in fm(record));
+
+function presenceProblems(project) {
+ return versioned(project)
+ .filter((record) => rowsOf(record).length === 0)
+ .map((record) => problem(record, `no "## ${HISTORY_SECTION}" table`));
+}
+
+function duplicateProblems(project) {
+ return versioned(project).flatMap((record) => {
+ const seen = new Set();
+ const claimed = new Set();
+ for (const row of rowsOf(record)) (seen.has(row.Version) ? claimed : seen).add(row.Version);
+ return [...claimed].map((version) => problem(record, `two changes claim version ${version}, renumber one`));
+ });
+}
+
+function currentProblems(project) {
+ return versioned(project).flatMap((record) => {
+ const system = isSystem(record);
+ const current = parts(fm(record).version, system);
+ const rows = byVersion(rowsOf(record), system);
+ if (!current || !rows.length) return [];
+ const highest = rows.at(-1);
+ const problems = compare(current, highest.parts) === 0 ? [] : [`version ${fm(record).version} is not the highest history row, ${highest.row.Version}`];
+ if (!system) {
+ for (let index = 1; index < rows.length; index += 1) {
+ const step = rows[index].parts[0] - rows[index - 1].parts[0];
+ if (step > 1) problems.push(`history jumps from ${rows[index - 1].row.Version} to ${rows[index].row.Version}`);
+ }
+ }
+ return problems.map((message) => problem(record, message));
+ });
+}
+
+function breakingProblems(project) {
+ return versioned(project).flatMap((record) =>
+ rowsOf(record)
+ .filter((row) => !BREAKING_VALUES.includes(row.Breaking))
+ .map((row) => problem(record, `breaking "${row.Breaking}" in version ${row.Version} must be yes or no`))
+ );
+}
+
+/** The tasks that broke an element, read from every element history. */
+function breakingTasks(project) {
+ const tasks = new Set();
+ for (const record of versioned(project).filter((candidate) => !isSystem(candidate))) {
+ for (const row of rowsOf(record)) if (row.Breaking === BREAKING) tasks.add(row.Task);
+ }
+ return tasks;
+}
+
+/** A system row publishes a list of tasks. It breaks, and raises the major version, when one of
+ * them broke an element or when it says so itself, for tokens alone; otherwise it raises the
+ * minor. A task no row lists yet is unpublished, never a problem. */
+function publicationProblems(project) {
+ const system = systemOf(project);
+ if (!system) return [];
+ const breaking = breakingTasks(project);
+ const ordered = byVersion(rowsOf(system), true);
+ return ordered.slice(1).flatMap(({ row, parts: now }, index) => {
+ const before = ordered[index].parts;
+ const broke = taskList(row.Task).find((task) => breaking.has(task));
+ if (broke && row.Breaking !== BREAKING) return [problem(system, `${row.Version} publishes "${broke}", which breaks an element, so it is breaking`)];
+ if (row.Breaking === BREAKING && now[0] <= before[0]) return [problem(system, `${row.Version} is breaking, yet raises no major version`)];
+ if (compare(now.slice(0, 2), before.slice(0, 2)) <= 0) return [problem(system, `${row.Version} raises neither major nor minor`)];
+ return [];
+ });
+}
+
+function taskProblems(project) {
+ return versioned(project).flatMap((record) =>
+ rowsOf(record).flatMap((row) =>
+ (isSystem(record) ? taskList(row.Task) : [row.Task])
+ .filter((task) => !task?.startsWith(TASKS_DIR))
+ .map((task) => problem(record, `task "${task ?? ""}" in version ${row.Version} must be a task folder, ${TASKS_DIR}âŠ`))
+ )
+ );
+}
+
+function deprecationProblems(project) {
+ return versioned(project)
+ .filter((record) => isElement(record) && fm(record).status === "deprecated")
+ .filter((record) => rowsOf(record).length && rowsOf(record).at(-1).Breaking !== BREAKING)
+ .map((record) => problem(record, "deprecated, yet its last history row is not breaking"));
+}
+
+const RULES = {
+ H1: presenceProblems,
+ H2: duplicateProblems,
+ H3: currentProblems,
+ H4: breakingProblems,
+ H5: publicationProblems,
+ H6: deprecationProblems,
+ H7: taskProblems,
+};
+
+/** Every broken history rule, in rule order. */
+const check = checkOf(RULES);
+
+module.exports = { check };
diff --git a/plugins/aidd-ui/hooks/lib/literals.cjs b/plugins/aidd-ui/hooks/lib/literals.cjs
new file mode 100644
index 000000000..3fccbd7e0
--- /dev/null
+++ b/plugins/aidd-ui/hooks/lib/literals.cjs
@@ -0,0 +1,41 @@
+// The literals a render or UI code must not hold, found line by line.
+const { list } = require("./markdown.cjs");
+const { systemOf } = require("./model.cjs");
+const { ERROR, WARNING } = require("./project.cjs");
+
+const RIGHT_TO_LEFT = "rtl";
+const VAR_CALL = /var\([^()]*\)/g; // a fallback inside var() is the token's, not a literal
+const QUERY_PRELUDE = /@(?:media|container)\b[^{]*/g; // a query's lengths are breakpoints, not styles
+const HAIRLINE_PX = 2; // up to 2px: a border or outline width code keeps literal
+const LINE_BREAK = "\n";
+
+/** Literals a render or UI code must not hold, each with the message it earns. */
+const PATTERNS = {
+ colour: {
+ regex: /(? `colour literal "${found}", use a token`,
+ },
+ unit: {
+ regex: /(? `literal "${found}", only tokens carry values`,
+ severity: (found) => (found.endsWith("px") && Math.abs(parseFloat(found)) <= HAIRLINE_PX ? WARNING : ERROR),
+ strip: [VAR_CALL, QUERY_PRELUDE],
+ },
+ physical: {
+ regex: /\b(?:margin|padding|border)-(?:left|right)\b|(? `physical property "${found}", use its logical form`,
+ },
+};
+
+/** Each match of `pattern` in `text`, as `{severity, message}`, lines numbered from 1. */
+function findLiterals(text, pattern) {
+ return text.split(LINE_BREAK).flatMap((line, index) => {
+ const searched = (pattern.strip ?? []).reduce((kept, regex) => kept.replace(regex, ""), line);
+ return [...searched.matchAll(pattern.regex)].map(([found]) => ({ severity: pattern.severity?.(found) ?? ERROR, message: `line ${index + 1}: ${pattern.message(found)}` }));
+ });
+}
+
+/** Whether the design system declares a right-to-left direction. */
+const declaresRightToLeft = (project) => list(systemOf(project)?.frontMatter.directions).includes(RIGHT_TO_LEFT);
+
+module.exports = { PATTERNS, findLiterals, declaresRightToLeft };
diff --git a/plugins/aidd-ui/hooks/lib/markdown.cjs b/plugins/aidd-ui/hooks/lib/markdown.cjs
new file mode 100644
index 000000000..2b0f4a43c
--- /dev/null
+++ b/plugins/aidd-ui/hooks/lib/markdown.cjs
@@ -0,0 +1,97 @@
+// Reads a UI record: flat front matter, `## ` sections, and a section's first table.
+
+const LINE_BREAK = "\n";
+const SECTION_PREFIX = "## ";
+const TABLE_CELL_SEPARATOR = "|";
+
+const ENTRY = /^([a-z][a-z0-9-]*):[ \t]*(.*)$/;
+const LIST = /^\[(.*)\]$/;
+const QUOTED = /^(["'])(.*)\1$/;
+const TABLE_SEPARATOR_ROW = /^\|[\s:|-]+\|$/;
+
+const NO_FRONT_MATTER = "no front matter, open and close it with a --- line";
+
+/** A value without the one pair of quotes around it, if any. */
+const unquote = (value) => value.match(QUOTED)?.[2] ?? value;
+
+/** A front matter value: `[a, b]` as a list, anything else as trimmed text, quotes dropped. */
+function parseValue(raw) {
+ const list = raw.trim().match(LIST);
+ if (!list) return unquote(raw.trim());
+ return list[1]
+ .split(",")
+ .map((item) => unquote(item.trim()))
+ .filter(Boolean);
+}
+
+const isFence = (line) => line.trimEnd() === "---";
+
+/** Front matter between the first two `---` lines, with one problem per line that is neither
+ * `key: value` nor `key: [a, b]`. Lines are numbered as in the file. */
+function parseFrontMatter(lines) {
+ const close = lines.findIndex((line, index) => index > 0 && isFence(line));
+ if (!isFence(lines[0]) || close < 0) return { found: false, values: {}, end: 0, problems: [{ line: 1, message: NO_FRONT_MATTER }] };
+ const values = {};
+ const problems = [];
+ for (let index = 1; index < close; index += 1) {
+ const line = lines[index];
+ if (!line.trim()) continue;
+ const entry = line.match(ENTRY);
+ if (entry) values[entry[1]] = parseValue(entry[2]);
+ else problems.push({ line: index + 1, message: `expected "key: value" or "key: [a, b]", got "${line.trim()}"` });
+ }
+ return { found: true, values, end: close + 1, problems };
+}
+
+/** The body's `## ` sections by name, each with its first line number and its lines. */
+function parseSections(lines, start) {
+ const sections = {};
+ let current;
+ for (let index = start; index < lines.length; index += 1) {
+ const line = lines[index];
+ if (line.startsWith(SECTION_PREFIX)) {
+ current = { line: index + 1, lines: [] };
+ sections[line.slice(SECTION_PREFIX.length).trim()] = current;
+ } else if (current) current.lines.push(line);
+ }
+ return sections;
+}
+
+const cells = (line) => line.split(TABLE_CELL_SEPARATOR).slice(1, -1).map((cell) => cell.trim());
+
+/** The section's first table as rows keyed by its header; [] when it has none. */
+function table(section) {
+ if (!section) return [];
+ const start = section.lines.findIndex((line) => line.trim().startsWith(TABLE_CELL_SEPARATOR));
+ if (start < 0) return [];
+ const block = [];
+ for (const line of section.lines.slice(start)) {
+ if (!line.trim().startsWith(TABLE_CELL_SEPARATOR)) break;
+ block.push(line.trim());
+ }
+ const header = cells(block[0]);
+ return block
+ .slice(1)
+ .filter((line) => !TABLE_SEPARATOR_ROW.test(line))
+ .map((line) => Object.fromEntries(cells(line).map((cell, index) => [header[index], cell])));
+}
+
+/** A record's front matter, its problems, and its sections. */
+function parseRecord(text) {
+ const lines = text.split(LINE_BREAK);
+ const frontMatter = parseFrontMatter(lines);
+ return {
+ hasFrontMatter: frontMatter.found,
+ frontMatter: frontMatter.values,
+ problems: frontMatter.problems,
+ sections: parseSections(lines, frontMatter.end),
+ };
+}
+
+/** A front matter value as a list: a lone value is a list of one, an absent or empty one is empty. */
+function list(value) {
+ if (value === undefined || value === "") return [];
+ return Array.isArray(value) ? value : [value];
+}
+
+module.exports = { parseRecord, table, list };
diff --git a/plugins/aidd-ui/hooks/lib/model.cjs b/plugins/aidd-ui/hooks/lib/model.cjs
new file mode 100644
index 000000000..cc1f75ca5
--- /dev/null
+++ b/plugins/aidd-ui/hooks/lib/model.cjs
@@ -0,0 +1,43 @@
+// The record model every check reads: which kinds exist, where each lives, and the records a
+// rule can trust.
+const SYSTEM_ID = "system";
+const FOLDER_KINDS = {
+ "": ["system", "brand"],
+ components: ["atom", "molecule", "composition"],
+ screens: ["screen"],
+ journeys: ["journey"],
+ assets: ["asset"],
+ reviews: ["review"],
+};
+const COMPONENT_KINDS = FOLDER_KINDS.components;
+const IMPLEMENTED_KINDS = [...COMPONENT_KINDS, "screen"];
+const TOKENED_KINDS = IMPLEMENTED_KINDS; // components and screens cite the tokens their render uses
+const LEVELS = { asset: 0, atom: 1, molecule: 2, composition: 3, screen: 4 }; // an element uses its own level or lower
+
+const INTEGER_VERSION = /^[1-9]\d*$/;
+const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
+
+const problem = (record, message) => ({ file: record.file, message });
+const fm = (record) => record.frontMatter;
+const isElement = (record) => Object.hasOwn(LEVELS, fm(record).kind);
+/** Whether `kind` belongs in `folder`: own keys only, so `constructor` names no folder. */
+const belongs = (folder, kind) => Object.hasOwn(FOLDER_KINDS, folder) && FOLDER_KINDS[folder].includes(kind);
+const isComponent = (record) => COMPONENT_KINDS.includes(fm(record).kind);
+
+/** The records the other rules can read: a file without front matter is R1's alone. */
+const readable = (project) => project.records.filter((record) => record.hasFrontMatter);
+
+/** Readable records whose kind is known and in its folder: kind rules read only those. */
+const placed = (project) => readable(project).filter((record) => belongs(record.folder, fm(record).kind));
+
+/** The first record holding each id, in load order. */
+function byId(project) {
+ const index = new Map();
+ for (const record of readable(project)) if (fm(record).id && !index.has(fm(record).id)) index.set(fm(record).id, record);
+ return index;
+}
+
+const systemOf = (project) => readable(project).find((record) => fm(record).kind === SYSTEM_ID);
+const isSystem = (record) => fm(record).kind === SYSTEM_ID;
+
+module.exports = { FOLDER_KINDS, COMPONENT_KINDS, IMPLEMENTED_KINDS, TOKENED_KINDS, LEVELS, INTEGER_VERSION, SEMVER, problem, fm, isElement, isComponent, isSystem, belongs, readable, placed, byId, systemOf };
diff --git a/plugins/aidd-ui/hooks/lib/project.cjs b/plugins/aidd-ui/hooks/lib/project.cjs
new file mode 100644
index 000000000..bd985f56e
--- /dev/null
+++ b/plugins/aidd-ui/hooks/lib/project.cjs
@@ -0,0 +1,98 @@
+// One read of a project's design system, shared by every check.
+const fs = require("node:fs");
+const path = require("node:path");
+const { parseRecord } = require("./markdown.cjs");
+
+const UI_DIR = "aidd_docs/ui";
+const TOKENS_FILE = "tokens.json";
+const SHOWCASE_DIR = "showcase";
+const RECORD_EXTENSION = ".md";
+const RENDER_EXTENSION = ".html";
+const PATH_SEPARATOR = "/";
+const UNREADABLE = "unreadable";
+const ERROR = "error"; // blocks the end of a turn
+const WARNING = "warning"; // informs, never blocks
+
+const TRAILING_SEPARATORS = /\/+$/;
+const BYTE_ORDER_MARK = /^ï»ż/;
+const WINDOWS_LINE_ENDING = /\r\n/g;
+
+/** A path relative to the project root, with forward slashes on every platform. */
+const relative = (root, file) => path.relative(root, file).split(path.sep).join(PATH_SEPARATOR);
+
+/** Whether `file` is `prefix` or lies under it, both relative to the project root. */
+function isUnder(file, prefix) {
+ const folder = prefix.replace(TRAILING_SEPARATORS, "");
+ return file === folder || file.startsWith(`${folder}${PATH_SEPARATOR}`);
+}
+
+/** Every file under `dir`, sorted, minus the folders `skip` names; [] when it is no directory.
+ * A folder that cannot be listed goes to `unlisted`, never a crash. */
+function walk(dir, skip = () => false, unlisted = () => {}) {
+ let entries;
+ try {
+ entries = fs.readdirSync(dir, { withFileTypes: true });
+ } catch {
+ unlisted(dir);
+ return [];
+ }
+ return entries
+ .sort((a, b) => (a.name < b.name ? -1 : 1)) // by code unit, the same order on every machine
+ .flatMap((entry) => {
+ const full = path.join(dir, entry.name);
+ if (!entry.isDirectory()) return [full];
+ return skip(full) ? [] : walk(full, skip, unlisted);
+ });
+}
+
+/** A file's text, read once per run, without a byte order mark and with `\n` line ends, as
+ * Windows editors may write otherwise; null when it cannot be read, which `unreadable` reports. */
+function text(project, file) {
+ if (!project.texts.has(file)) {
+ let content = null;
+ try {
+ content = fs.readFileSync(path.join(project.root, file), "utf8").replace(BYTE_ORDER_MARK, "").replace(WINDOWS_LINE_ENDING, "\n");
+ } catch {
+ // a dangling link or a folder named like a file: one problem, never a crash
+ }
+ project.texts.set(file, content);
+ }
+ return project.texts.get(file);
+}
+
+/** A check that runs each rule of `rules`, an object keyed by rule id, through `run`: every
+ * problem carries its rule, and is an error unless the rule says it is a warning. */
+const checkOf = (rules, run = (find, project) => find(project)) => (project) =>
+ Object.entries(rules).flatMap(([rule, find]) => run(find, project).map((problem) => ({ rule, severity: ERROR, ...problem })));
+
+/** One problem per file a check could not read. */
+const unreadable = (project) => [...project.texts].filter(([, content]) => content === null).map(([file]) => ({ file, message: UNREADABLE }));
+
+/** A record with its file, folder under `aidd_docs/ui/`, name, front matter and sections; none when unreadable. */
+function readRecord(project, file) {
+ const content = text(project, file);
+ if (content === null) return [];
+ return [
+ {
+ file,
+ folder: path.posix.relative(UI_DIR, path.posix.dirname(file)),
+ name: path.posix.basename(file, RECORD_EXTENSION),
+ ...parseRecord(content),
+ },
+ ];
+}
+
+/** The design system under `root`, or null when the project has none. */
+function load(root) {
+ const uiDir = path.join(root, UI_DIR);
+ if (!fs.existsSync(uiDir) || !fs.statSync(uiDir).isDirectory()) return null;
+ const showcase = path.join(uiDir, SHOWCASE_DIR);
+ const project = { root, texts: new Map(), tokensFile: `${UI_DIR}/${TOKENS_FILE}` };
+ const unlisted = (dir) => project.texts.set(relative(root, dir), null);
+ const files = walk(uiDir, (dir) => dir === showcase, unlisted).map((file) => relative(root, file));
+ project.records = files.filter((file) => file.endsWith(RECORD_EXTENSION)).flatMap((file) => readRecord(project, file));
+ project.renders = files.filter((file) => file.endsWith(RENDER_EXTENSION));
+ return project;
+}
+
+module.exports = { load, walk, text, unreadable, relative, isUnder, checkOf, UI_DIR, RENDER_EXTENSION, ERROR, WARNING };
diff --git a/plugins/aidd-ui/hooks/lib/records.cjs b/plugins/aidd-ui/hooks/lib/records.cjs
new file mode 100644
index 000000000..1de1228de
--- /dev/null
+++ b/plugins/aidd-ui/hooks/lib/records.cjs
@@ -0,0 +1,289 @@
+// R1 to R17: the shape of each record and how records point at one another.
+const fs = require("node:fs");
+const path = require("node:path");
+const { list, table } = require("./markdown.cjs");
+const { checkOf } = require("./project.cjs");
+const { COMPONENT_KINDS, IMPLEMENTED_KINDS, LEVELS, INTEGER_VERSION, SEMVER, problem, fm, isElement, isComponent, isSystem, belongs, readable, placed, byId, systemOf } = require("./model.cjs");
+
+const STATUSES = ["proposed", "validated", "deprecated"];
+const DESIGN_STATUSES = ["proposed", "validated"]; // R16: system, brand and reviews are never deprecated
+const IMPLEMENTED_VERSION = "implemented-version"; // the version of an element its code implements
+const FORMAT = "1"; // the record format this validator reads
+const YES_NO = ["yes", "no"];
+const ASSET_TYPES = ["icon", "image", "illustration"];
+const VERDICTS = ["ok", "ko", "pending"];
+const NO_REPLACEMENT = "none";
+const DEFAULT_STATE = "default";
+const INTERACTIVE_STATE = "focus"; // hover and disabled only when they exist
+const KEYBOARD_SECTION = "Keyboard";
+const USAGE_SECTION = "Usage";
+const DEPRECATION_SECTION = "Deprecation";
+const STEPS_SECTION = "Steps";
+const VERDICTS_SECTION = "Verdicts";
+const BRAND_HEADINGS = ["Project", "Users", "Domain", "Platforms", "Personality and tone", "References", "Anti-references"];
+
+const COMMON_KEYS = ["id", "kind", "status", "version"];
+const COMPONENT_KEYS = ["uses", "tokens", "variants", "states", "interactive", "role"];
+const KIND_KEYS = {
+ system: ["format", "platforms", "breakpoints", "themes", "locales", "directions", "primitives", "code-roots", "ignore", "libraries"],
+ brand: [],
+ atom: COMPONENT_KEYS,
+ molecule: COMPONENT_KEYS,
+ composition: COMPONENT_KEYS,
+ screen: ["uses", "tokens", "states", "breakpoints", "route"],
+ journey: ["persona"],
+ asset: ["type", "file", "alt"],
+ review: ["scope", "screens"],
+};
+const ASSEMBLED_KINDS = ["molecule", "composition"]; // implemented through what they use when no file is their own
+const DESIGN_KINDS = ["system", "brand", "review"];
+const listWithOr = (values) => `${values.slice(0, -1).join(", ")} or ${values.at(-1)}`;
+const isDeprecated = (record) => fm(record).status === "deprecated";
+const folderLabel = (folder) => (folder ? `${folder}/` : "aidd_docs/ui/");
+
+function frontMatterProblems(project) {
+ return project.records.flatMap((record) => record.problems.map((parse) => problem(record, `line ${parse.line}: ${parse.message}`)));
+}
+
+function kindProblems(project) {
+ return readable(project).flatMap((record) => {
+ const kind = fm(record).kind;
+ if (kind === undefined) return [];
+ if (!Object.hasOwn(KIND_KEYS, kind)) return [problem(record, `unknown kind "${kind}"`)];
+ if (belongs(record.folder, kind)) return [];
+ return [problem(record, `kind "${kind}" does not belong in ${folderLabel(record.folder)}`)];
+ });
+}
+
+function valueProblems(record) {
+ const { status, interactive, type, kind } = fm(record);
+ return [
+ ...(status !== undefined && !STATUSES.includes(status) ? [`status "${status}" must be ${listWithOr(STATUSES)}`] : []),
+ ...(COMPONENT_KINDS.includes(kind) && interactive !== undefined && !YES_NO.includes(interactive) ? [`interactive "${interactive}" must be ${listWithOr(YES_NO)}`] : []),
+ ...(kind === "asset" && type !== undefined && !ASSET_TYPES.includes(type) ? [`type "${type}" must be ${listWithOr(ASSET_TYPES)}`] : []),
+ ].map((message) => problem(record, message));
+}
+
+function requiredKeyProblems(project) {
+ const placedRecords = new Set(placed(project));
+ return readable(project).flatMap((record) => {
+ const keys = [...COMMON_KEYS, ...(placedRecords.has(record) ? KIND_KEYS[fm(record).kind] : [])];
+ const missing = keys.filter((key) => !(key in fm(record))).map((key) => problem(record, `missing key "${key}"`));
+ return [...missing, ...(placedRecords.has(record) ? valueProblems(record) : [])];
+ });
+}
+
+function identityProblems(project) {
+ const index = byId(project);
+ return readable(project).flatMap((record) => {
+ const id = fm(record).id;
+ if (id === undefined) return [];
+ if (id !== record.name) return [problem(record, `id "${id}" differs from the file name "${record.name}"`)];
+ const owner = index.get(id);
+ return owner === record ? [] : [problem(record, `id "${id}" already belongs to ${owner.file}`)];
+ });
+}
+
+function versionProblems(project) {
+ return placed(project).flatMap((record) => {
+ const version = fm(record).version;
+ if (version === undefined) return [];
+ if (isSystem(record)) return SEMVER.test(version) ? [] : [problem(record, `version "${version}" must be semver, such as 2.0.0`)];
+ return INTEGER_VERSION.test(version) ? [] : [problem(record, `version "${version}" must be an integer from 1`)];
+ });
+}
+
+function usesProblems(project) {
+ const index = byId(project);
+ return placed(project)
+ .filter(isElement)
+ .flatMap((record) =>
+ list(fm(record).uses).flatMap((id) => {
+ const used = index.get(id);
+ if (!used) return [problem(record, `uses "${id}", which no record has`)];
+ if (used === record) return [problem(record, "uses itself")];
+ const kind = fm(record).kind;
+ const usedKind = fm(used).kind;
+ return LEVELS[usedKind] <= LEVELS[kind] ? [] : [problem(record, `${kind} cannot use ${usedKind} "${id}"`)];
+ })
+ );
+}
+
+function stateProblems(project) {
+ return placed(project).flatMap((record) => {
+ const states = list(fm(record).states);
+ if (fm(record).kind === "screen") return states.includes(DEFAULT_STATE) ? [] : [problem(record, `screen lacks state "${DEFAULT_STATE}"`)];
+ if (!isComponent(record)) return [];
+ const interactive = fm(record).interactive === "yes";
+ return [
+ ...(states.includes(DEFAULT_STATE) ? [] : [`component lacks state "${DEFAULT_STATE}"`]),
+ ...(interactive && !states.includes(INTERACTIVE_STATE) ? [`interactive component lacks state "${INTERACTIVE_STATE}"`] : []),
+ ...(interactive && !(KEYBOARD_SECTION in record.sections) ? [`interactive component lacks a "## ${KEYBOARD_SECTION}" section`] : []),
+ ].map((message) => problem(record, message));
+ });
+}
+
+const isImplemented = (record) => IMPLEMENTED_VERSION in fm(record);
+
+/** Whether an assembled element is implemented through the elements it uses: at least one, all
+ * implemented. Assets are files, never code, so they count for nothing. */
+function implementedThroughUses(record, index) {
+ const used = list(fm(record).uses)
+ .map((id) => index.get(id))
+ .filter((target) => !target || fm(target).kind !== "asset");
+ return ASSEMBLED_KINDS.includes(fm(record).kind) && used.length > 0 && used.every((target) => target && isImplemented(target));
+}
+
+/** What is wrong with an element's implemented version, or null. */
+function implementedProblem(record, index) {
+ const { kind, version } = fm(record);
+ const implemented = fm(record)[IMPLEMENTED_VERSION];
+ if (!IMPLEMENTED_KINDS.includes(kind)) return `${IMPLEMENTED_VERSION} is for components and screens, never a ${kind}`;
+ if (!INTEGER_VERSION.test(implemented)) return `${IMPLEMENTED_VERSION} "${implemented}" must be an integer from 1`;
+ if (INTEGER_VERSION.test(version) && Number(implemented) > Number(version)) return `${IMPLEMENTED_VERSION} ${implemented} is ahead of version ${version}`;
+ if ("code" in fm(record) || implementedThroughUses(record, index)) return null;
+ return ASSEMBLED_KINDS.includes(kind) ? `${IMPLEMENTED_VERSION} needs "code", or every element it uses implemented` : `${IMPLEMENTED_VERSION} needs "code"`;
+}
+
+function implementedProblems(project) {
+ const index = byId(project);
+ return placed(project)
+ .filter(isImplemented)
+ .flatMap((record) => {
+ const message = implementedProblem(record, index);
+ return message ? [problem(record, message)] : [];
+ });
+}
+
+function deprecatedProblems(project) {
+ const index = byId(project);
+ return placed(project)
+ .filter((record) => isElement(record) && isDeprecated(record))
+ .flatMap((record) => {
+ const replacement = fm(record)["replaced-by"];
+ if (replacement === undefined) return [problem(record, 'status deprecated needs "replaced-by"')];
+ if (replacement === NO_REPLACEMENT) {
+ return DEPRECATION_SECTION in record.sections ? [] : [problem(record, `replaced-by none needs a "## ${DEPRECATION_SECTION}" section saying why`)];
+ }
+ const target = index.get(replacement);
+ if (!target) return [problem(record, `replaced-by "${replacement}", which no record has`)];
+ return isDeprecated(target) ? [problem(record, `replaced-by "${replacement}" is deprecated too`)] : [];
+ });
+}
+
+function deprecatedUseProblems(project) {
+ const index = byId(project);
+ return placed(project)
+ .filter((record) => isElement(record) && !isDeprecated(record))
+ .flatMap((record) =>
+ list(fm(record).uses)
+ .filter((id) => index.get(id) && isDeprecated(index.get(id)))
+ .map((id) => problem(record, `uses "${id}", which is deprecated`))
+ );
+}
+
+function breakpointProblems(project) {
+ const system = systemOf(project);
+ if (!system) return [];
+ const declared = list(fm(system).breakpoints);
+ return placed(project)
+ .filter((record) => fm(record).kind === "screen")
+ .flatMap((record) =>
+ list(fm(record).breakpoints)
+ .filter((breakpoint) => !declared.includes(breakpoint))
+ .map((breakpoint) => problem(record, `breakpoint "${breakpoint}" is not in system.md`))
+ );
+}
+
+const screenIds = (project) => new Set(placed(project).filter((record) => fm(record).kind === "screen").map((record) => fm(record).id));
+
+function journeyProblems(project) {
+ const screens = screenIds(project);
+ return placed(project)
+ .filter((record) => fm(record).kind === "journey")
+ .flatMap((record) =>
+ table(record.sections[STEPS_SECTION]).flatMap((row, index) =>
+ screens.has(row.Screen) ? [] : [problem(record, `step ${index + 1} names screen "${row.Screen}", which no screen has`)]
+ )
+ );
+}
+
+function reviewProblems(project) {
+ const index = byId(project);
+ return placed(project)
+ .filter((record) => fm(record).kind === "review")
+ .flatMap((record) => {
+ const rows = table(record.sections[VERDICTS_SECTION]);
+ // A verdict judges one version: a row for an older one leaves the current one unreviewed.
+ const missing = list(fm(record).screens).flatMap((id) => {
+ const screen = index.get(id);
+ const version = screen && fm(screen).version;
+ return list(screen && fm(screen).states)
+ .filter((state) => !rows.some((row) => row.Screen === id && row.State === state && row.Version === version))
+ .map((state) => `no verdict row for ${id} / ${state} at version ${version}`);
+ });
+ const verdicts = rows
+ .filter((row) => !VERDICTS.includes(row.Verdict))
+ .map((row) => `verdict "${row.Verdict}" for ${row.Screen} / ${row.State} must be ${listWithOr(VERDICTS)}`);
+ return [...missing, ...verdicts].map((message) => problem(record, message));
+ });
+}
+
+function brandProblems(project) {
+ return placed(project)
+ .filter((record) => fm(record).kind === "brand")
+ .flatMap((record) => BRAND_HEADINGS.filter((heading) => !(heading in record.sections)).map((heading) => problem(record, `lacks "## ${heading}"`)));
+}
+
+function assetProblems(project) {
+ return placed(project)
+ .filter((record) => fm(record).kind === "asset" && typeof fm(record).file === "string")
+ .filter((record) => !fs.existsSync(path.join(project.root, path.dirname(record.file), fm(record).file)))
+ .map((record) => problem(record, `file "${fm(record).file}" does not exist beside it`));
+}
+
+function designStatusProblems(project) {
+ return placed(project)
+ .filter((record) => DESIGN_KINDS.includes(fm(record).kind))
+ .filter((record) => STATUSES.includes(fm(record).status) && !DESIGN_STATUSES.includes(fm(record).status))
+ .map((record) => problem(record, `status "${fm(record).status}" is for elements: system, brand and reviews are ${listWithOr(DESIGN_STATUSES)}`));
+}
+
+function usageProblems(project) {
+ return placed(project)
+ .filter((record) => isComponent(record) && !(USAGE_SECTION in record.sections))
+ .map((record) => problem(record, `lacks "## ${USAGE_SECTION}", saying when to use it and when not`));
+}
+
+/** The one problem a system.md written for another format earns, since every rule would misread
+ * it; null when the format is this one or not stated, which R3 names. */
+function formatProblem(project) {
+ const system = systemOf(project);
+ const format = system && fm(system).format;
+ return format === undefined || format === FORMAT ? null : problem(system, `format "${format}" is not ${FORMAT}, the one this validator reads`);
+}
+
+const RULES = {
+ R1: frontMatterProblems,
+ R2: kindProblems,
+ R3: requiredKeyProblems,
+ R4: identityProblems,
+ R5: versionProblems,
+ R6: usesProblems,
+ R7: stateProblems,
+ R8: implementedProblems,
+ R9: deprecatedProblems,
+ R10: deprecatedUseProblems,
+ R11: breakpointProblems,
+ R12: journeyProblems,
+ R13: reviewProblems,
+ R14: brandProblems,
+ R15: assetProblems,
+ R16: designStatusProblems,
+ R17: usageProblems,
+};
+
+/** Every broken record rule, in rule order. */
+const check = checkOf(RULES);
+
+module.exports = { check, formatProblem };
diff --git a/plugins/aidd-ui/hooks/lib/renders.cjs b/plugins/aidd-ui/hooks/lib/renders.cjs
new file mode 100644
index 000000000..608940980
--- /dev/null
+++ b/plugins/aidd-ui/hooks/lib/renders.cjs
@@ -0,0 +1,88 @@
+// V1 to V7: each component and screen has its HTML render, showing every variant and state
+// through the tokens its record declares alone.
+const path = require("node:path");
+const { list } = require("./markdown.cjs");
+const { placed, TOKENED_KINDS } = require("./model.cjs");
+const { PATTERNS, findLiterals, declaresRightToLeft } = require("./literals.cjs");
+const { cssNames, cssNameOf } = require("./tokens.cjs");
+const { text, checkOf, RENDER_EXTENSION } = require("./project.cjs");
+
+const LINE_BREAK = "\n";
+
+const CUSTOM_PROPERTY = /var\(\s*(--[\w-]+)/g;
+const DECLARED_PROPERTY = /(--[\w-]+)\s*:/g; // a property the render sets itself, not a token
+
+const renderOf = (record) => `${path.dirname(record.file)}/${record.frontMatter.id}${RENDER_EXTENSION}`;
+const rendered = (project) => placed(project).filter((record) => TOKENED_KINDS.includes(record.frontMatter.kind) && record.frontMatter.id === record.name);
+/** A render's text; empty when unreadable, which the loader reports once. */
+const readRender = (project, file) => text(project, file) ?? "";
+
+function presenceProblems(project) {
+ return rendered(project)
+ .filter((record) => !project.renders.includes(renderOf(record)))
+ .map((record) => ({ file: record.file, message: `no render beside it, expected ${path.basename(renderOf(record))}` }));
+}
+
+function coverageProblems(project) {
+ return rendered(project)
+ .filter((record) => project.renders.includes(renderOf(record)))
+ .flatMap((record) => {
+ const html = text(project, renderOf(record));
+ if (html === null) return [];
+ const wanted = [...list(record.frontMatter.variants).map((value) => `data-variant="${value}"`), ...list(record.frontMatter.states).map((value) => `data-state="${value}"`)];
+ return wanted.filter((attribute) => !html.includes(attribute)).map((attribute) => ({ file: renderOf(record), message: `no ${attribute}` }));
+ });
+}
+
+function referenceProblems(project) {
+ const tokens = cssNames(project);
+ if (tokens === null) return [];
+ return project.renders.flatMap((file) => {
+ const html = readRender(project, file);
+ const names = new Set([...tokens, ...[...html.matchAll(DECLARED_PROPERTY)].map((match) => match[1])]);
+ return html
+ .split(LINE_BREAK)
+ .flatMap((line, index) =>
+ [...line.matchAll(CUSTOM_PROPERTY)]
+ .filter((match) => !names.has(match[1]))
+ .map((match) => ({ file, message: `line ${index + 1}: var(${match[1]}) names no token` }))
+ );
+ });
+}
+
+/** A render uses only the tokens its record declares, so an impact report read from the
+ * records misses none of them. */
+function declaredProblems(project) {
+ const tokens = cssNames(project);
+ if (tokens === null) return [];
+ return rendered(project)
+ .filter((record) => project.renders.includes(renderOf(record)))
+ .flatMap((record) => {
+ const declared = new Set(list(record.frontMatter.tokens).map(cssNameOf));
+ return readRender(project, renderOf(record))
+ .split(LINE_BREAK)
+ .flatMap((line, index) =>
+ [...line.matchAll(CUSTOM_PROPERTY)]
+ .filter((match) => tokens.has(match[1]) && !declared.has(match[1]))
+ .map((match) => ({ file: renderOf(record), message: `line ${index + 1}: var(${match[1]}) is a token ${record.frontMatter.id} does not declare` }))
+ );
+ });
+}
+
+const literalProblems = (pattern, applies = () => true) => (project) =>
+ applies(project) ? project.renders.flatMap((file) => findLiterals(readRender(project, file), pattern).map((found) => ({ file, ...found }))) : [];
+
+const RULES = {
+ V1: presenceProblems,
+ V2: coverageProblems,
+ V3: referenceProblems,
+ V4: literalProblems(PATTERNS.colour),
+ V5: literalProblems(PATTERNS.physical, declaresRightToLeft),
+ V6: literalProblems(PATTERNS.unit),
+ V7: declaredProblems,
+};
+
+/** Every broken render rule, in rule order. */
+const check = checkOf(RULES);
+
+module.exports = { check, renderOf };
diff --git a/plugins/aidd-ui/hooks/lib/tokens.cjs b/plugins/aidd-ui/hooks/lib/tokens.cjs
new file mode 100644
index 000000000..982d2ac1b
--- /dev/null
+++ b/plugins/aidd-ui/hooks/lib/tokens.cjs
@@ -0,0 +1,329 @@
+// T1 to T9: tokens.json against the DTCG 2025.10 format and color modules, themes through its
+// Resolver module, raw CSS extensions, and components and screens citing semantic tokens only.
+// Composite values are checked for presence only.
+const fs = require("node:fs");
+const nodePath = require("node:path");
+const { list } = require("./markdown.cjs");
+const { placed, systemOf, TOKENED_KINDS } = require("./model.cjs");
+const { text, checkOf } = require("./project.cjs");
+
+const RESERVED_KEYS = ["$value", "$type", "$description", "$extensions", "$deprecated", "$extends", "$schema"];
+const ROOT_KEY = "$root"; // a stated limit: a group's own token is a named child here
+const PROPERTY_PREFIX = "$";
+const PATH_SEPARATOR = ".";
+const RAW_CSS_EXTENSION = "dev.ai-driven.css"; // a CSS value DTCG cannot hold, such as color-mix() or 100%
+const RESOLVER_FILE = "tokens.resolver.json";
+const RESOLVER_VERSION = "2025.10";
+const THEME_MODIFIER = "theme";
+const THEME_ORDER_REF = `#/modifiers/${THEME_MODIFIER}`;
+const MISSING = "missing";
+const NOT_JSON = "not JSON";
+
+const PRIMITIVE_TYPES = ["color", "dimension", "fontFamily", "fontWeight", "duration", "cubicBezier", "number"];
+const COMPOSITE_TYPES = ["strokeStyle", "border", "transition", "shadow", "gradient", "typography"];
+const TYPES = [...PRIMITIVE_TYPES, ...COMPOSITE_TYPES];
+const COLOR_SPACES = ["srgb", "srgb-linear", "hsl", "hwb", "lab", "lch", "oklab", "oklch", "display-p3", "a98-rgb", "prophoto-rgb", "rec2020", "xyz-d65", "xyz-d50"];
+const COLOR_COMPONENTS = 3;
+const NO_COMPONENT = "none";
+const DIMENSION_UNITS = ["px", "rem"];
+const DURATION_UNITS = ["ms", "s"];
+const FONT_WEIGHT_MIN = 1;
+const FONT_WEIGHT_MAX = 1000;
+const FONT_WEIGHT_KEYWORDS = ["thin", "hairline", "extra-light", "ultra-light", "light", "normal", "regular", "book", "medium", "semi-bold", "demi-bold", "bold", "extra-bold", "ultra-bold", "black", "heavy", "extra-black", "ultra-black"];
+const BEZIER_POINTS = 4;
+const BEZIER_X_INDEXES = [0, 2];
+
+const FORBIDDEN_IN_NAME = /[{}.]/;
+const ALIAS = /^\{([^{}]+)\}$/;
+const HEX = /^#[0-9a-fA-F]{6}$/;
+const CSS_VARIABLE = /var\(\s*(--[\w-]+)/g;
+
+const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
+const isNumber = (value) => typeof value === "number" && Number.isFinite(value);
+const childKeys = (node) => Object.keys(node).filter((key) => !key.startsWith(PROPERTY_PREFIX));
+const aliasOf = (value) => (typeof value === "string" ? ALIAS.exec(value)?.[1] : undefined);
+
+/** Every token and group of the tree, each with its path and the closest group `$type`. */
+function collect(node, path, groupType, found) {
+ for (const key of Object.keys(node)) {
+ const child = node[key];
+ const childPath = [...path, key];
+ if (key.startsWith(PROPERTY_PREFIX)) {
+ if (key === ROOT_KEY) found.badNames.push({ path: childPath.join(PATH_SEPARATOR), message: `"${ROOT_KEY}" is not supported, name the token` });
+ else if (!RESERVED_KEYS.includes(key)) found.badNames.push({ path: childPath.join(PATH_SEPARATOR), message: `"${key}" is not a reserved key` });
+ continue;
+ }
+ if (FORBIDDEN_IN_NAME.test(key)) found.badNames.push({ path: childPath.join(PATH_SEPARATOR), message: 'a name never holds ".", "{" or "}"' });
+ if (!isObject(child)) continue;
+ const name = childPath.join(PATH_SEPARATOR);
+ if ("$value" in child || childKeys(child).length === 0) found.tokens.set(name, { path: childPath, node: child, groupType });
+ else {
+ found.groups.add(name);
+ collect(child, childPath, child.$type ?? groupType, found);
+ }
+ }
+ return found;
+}
+
+const emptyTree = () => ({ tokens: new Map(), groups: new Set(), badNames: [] });
+
+/** A JSON object of the design system, or the one problem it earns: null when unreadable,
+ * which the loader reports. */
+function readJson(project, file) {
+ if (!fs.existsSync(nodePath.join(project.root, file))) return { problem: MISSING };
+ const source = text(project, file);
+ if (source === null) return { problem: null };
+ try {
+ const value = JSON.parse(source);
+ return isObject(value) ? { value } : { problem: NOT_JSON };
+ } catch {
+ return { problem: NOT_JSON };
+ }
+}
+
+/** A token file's tree: its tokens, groups and bad names, or why it cannot be read. */
+function readTree(project, file) {
+ const json = readJson(project, file);
+ return json.value ? collect(json.value, [], json.value.$type, emptyTree()) : { ...emptyTree(), unusable: true, problem: json.problem };
+}
+
+/** tokens.json's tree, read once per run. */
+function read(project) {
+ project.tokenTree ??= readTree(project, project.tokensFile);
+ return project.tokenTree;
+}
+
+/** A token's raw CSS value when it carries one: DTCG has no form for it, so no type or shape. */
+const rawCssOf = (token) => token.node.$extensions?.[RAW_CSS_EXTENSION];
+const isRaw = (token) => rawCssOf(token) !== undefined;
+
+/** A token's `$type`: its own, else its alias target's, else its closest group's. A cycle
+ * stops at the token it comes back to. */
+function typeOf(tree, token, seen = new Set()) {
+ if (token.node.$type !== undefined) return token.node.$type;
+ const target = tree.tokens.get(aliasOf(token.node.$value));
+ if (!target || seen.has(target)) return token.groupType;
+ seen.add(token);
+ return typeOf(tree, target, seen);
+}
+
+/** Whether following aliases from `value` comes back to the token `name`. */
+function loopsBack(tree, value, name) {
+ const seen = new Set();
+ for (let target = aliasOf(value); tree.tokens.has(target) && !seen.has(target); target = aliasOf(tree.tokens.get(target).node.$value)) {
+ if (target === name) return true;
+ seen.add(target);
+ }
+ return false;
+}
+
+function colorProblem(value) {
+ if (!isObject(value)) return "a color is {colorSpace, components, alpha?, hex?}";
+ if (!COLOR_SPACES.includes(value.colorSpace)) return `colorSpace "${value.colorSpace}" is not a DTCG colour space`;
+ const components = value.components;
+ if (!Array.isArray(components) || components.length !== COLOR_COMPONENTS || !components.every((part) => isNumber(part) || part === NO_COMPONENT)) {
+ return 'components must be three numbers or "none"';
+ }
+ if (value.alpha !== undefined && !(isNumber(value.alpha) && value.alpha >= 0 && value.alpha <= 1)) return "alpha must be a number in 0..1";
+ if (value.hex !== undefined && !HEX.test(value.hex)) return `hex "${value.hex}" must be # and six hex digits`;
+ return null;
+}
+
+function measureProblem(value, units) {
+ if (!isObject(value) || !isNumber(value.value)) return `a measure is {value: number, unit: ${units.join(" or ")}}`;
+ return units.includes(value.unit) ? null : `unit "${value.unit}" must be ${units.join(" or ")}`;
+}
+
+function fontWeightProblem(value) {
+ if (isNumber(value) && value >= FONT_WEIGHT_MIN && value <= FONT_WEIGHT_MAX) return null;
+ if (FONT_WEIGHT_KEYWORDS.includes(value)) return null;
+ return `fontWeight ${JSON.stringify(value)} must be 1 to 1000 or a keyword`;
+}
+
+function cubicBezierProblem(value) {
+ const valid = Array.isArray(value) && value.length === BEZIER_POINTS && value.every(isNumber) && BEZIER_X_INDEXES.every((index) => value[index] >= 0 && value[index] <= 1);
+ return valid ? null : "cubicBezier needs four numbers, x in 0..1";
+}
+
+function fontFamilyProblem(value) {
+ const valid = typeof value === "string" || (Array.isArray(value) && value.length > 0 && value.every((name) => typeof name === "string"));
+ return valid ? null : "fontFamily is a name or a list of names";
+}
+
+const SHAPES = {
+ color: colorProblem,
+ dimension: (value) => measureProblem(value, DIMENSION_UNITS),
+ duration: (value) => measureProblem(value, DURATION_UNITS),
+ fontWeight: fontWeightProblem,
+ cubicBezier: cubicBezierProblem,
+ fontFamily: fontFamilyProblem,
+ number: (value) => (isNumber(value) ? null : "a number token holds a number"),
+};
+
+/** What is wrong with a value of `type`, or null. Aliases and composites are not shapes. */
+const shapeProblem = (type, value) => (aliasOf(value) !== undefined || !SHAPES[type] ? null : SHAPES[type](value));
+
+/** A problem builder for a token file, tokens.json by default, naming the token path first. */
+const tokenProblem = (project, file = project.tokensFile) => (path, message) => ({ file, message: `${path}: ${message}` });
+const cssName = (path) => `--${path.join("-")}`;
+
+function jsonProblems(tree, project) {
+ return tree.problem ? [{ file: project.tokensFile, message: tree.problem }] : [];
+}
+
+function nameProblems(tree, project) {
+ const at = tokenProblem(project);
+ return tree.badNames.map((bad) => at(bad.path, bad.message));
+}
+
+function typeProblems(tree, project) {
+ const at = tokenProblem(project);
+ return [...tree.tokens].flatMap(([name, token]) => {
+ if (isRaw(token)) return typeof rawCssOf(token) === "string" && rawCssOf(token).trim() ? [] : [at(name, `${RAW_CSS_EXTENSION} must be a CSS value`)];
+ if (!("$value" in token.node)) return [at(name, "no $value")];
+ if (aliasProblem(tree, token.node.$value, undefined, name)) return []; // T5 names the broken alias
+ const type = typeOf(tree, token);
+ if (type === undefined) return [at(name, "no $type on it, its alias or a parent group")];
+ return TYPES.includes(type) ? [] : [at(name, `$type "${type}" is not a DTCG type`)];
+ });
+}
+
+function shapeProblems(tree, project) {
+ const at = tokenProblem(project);
+ return [...tree.tokens].flatMap(([name, token]) => {
+ const message = "$value" in token.node && !isRaw(token) ? shapeProblem(typeOf(tree, token), token.node.$value) : null;
+ return message ? [at(name, message)] : [];
+ });
+}
+
+/** What is wrong with an alias, or null: its target must be a token, of `type` when given, that
+ * never leads back to the token `name` holding it. */
+function aliasProblem(tree, value, type, name) {
+ const target = aliasOf(value);
+ if (target === undefined) return null;
+ if (!tree.tokens.has(target)) return tree.groups.has(target) ? `alias {${target}} names a group, not a token` : `alias {${target}} names no token`;
+ if (name !== undefined && loopsBack(tree, value, name)) return `alias {${target}} loops back to ${name}`;
+ const targetType = typeOf(tree, tree.tokens.get(target));
+ return type === undefined || targetType === undefined || targetType === type ? null : `alias {${target}} is a ${targetType}, not a ${type}`;
+}
+
+function aliasProblems(tree, project) {
+ const at = tokenProblem(project);
+ return [...tree.tokens].flatMap(([name, token]) => {
+ const message = aliasProblem(tree, token.node.$value, token.node.$type ?? token.groupType, name);
+ return message ? [at(name, message)] : [];
+ });
+}
+
+/** The Resolver's theme contexts as `{theme: [file]}`, with what keeps it from naming them.
+ * This plugin reads one profile of the module: a set holding tokens.json, then a `theme`
+ * modifier whose contexts are the themes system.md declares, each a list of `$ref` files. */
+function resolverContexts(resolver, themes, project, file) {
+ const at = (message) => ({ file, message });
+ const problems = [];
+ if (resolver.version !== RESOLVER_VERSION) problems.push(at(`version must be "${RESOLVER_VERSION}"`));
+ const holdsTokens = Object.values(isObject(resolver.sets) ? resolver.sets : {}).some((set) => (set?.sources ?? []).some((source) => source?.$ref === nodePath.posix.basename(project.tokensFile)));
+ if (!holdsTokens) problems.push(at(`no set holds ${nodePath.posix.basename(project.tokensFile)}`));
+ const modifier = resolver.modifiers?.[THEME_MODIFIER];
+ if (!isObject(modifier) || !isObject(modifier.contexts)) return { problems: [...problems, at(`no "${THEME_MODIFIER}" modifier with contexts`)], contexts: {} };
+ if (!(resolver.resolutionOrder ?? []).some((step) => step?.$ref === THEME_ORDER_REF)) problems.push(at(`resolutionOrder never applies ${THEME_ORDER_REF}`));
+ if (modifier.default !== themes[0]) problems.push(at(`the theme modifier's default must be "${themes[0]}", the first theme in system.md`));
+ for (const theme of themes) if (!Object.hasOwn(modifier.contexts, theme)) problems.push(at(`no context for theme "${theme}"`));
+ const contexts = {};
+ for (const [theme, sources] of Object.entries(modifier.contexts)) {
+ if (!themes.includes(theme)) problems.push(at(`context "${theme}" is not a theme in system.md`));
+ const refs = (Array.isArray(sources) ? sources : []).map((source) => source?.$ref);
+ if (refs.some((ref) => typeof ref !== "string")) problems.push(at(`context "${theme}": each source is {"$ref": ""}`));
+ contexts[theme] = refs.filter((ref) => typeof ref === "string").map((ref) => nodePath.posix.join(nodePath.posix.dirname(file), ref));
+ }
+ return { problems, contexts };
+}
+
+/** What a theme file earns: each token it redefines exists in tokens.json, keeps its type, and
+ * holds a value of that type. */
+function themeFileProblems(tree, project, theme, file) {
+ const themed = readTree(project, file);
+ if (themed.unusable) return themed.problem ? [{ file, message: themed.problem }] : [];
+ const at = tokenProblem(project, file);
+ return [...themed.tokens].flatMap(([name, token]) => {
+ const base = tree.tokens.get(name);
+ if (!base) return [at(name, `theme "${theme}" redefines no token of tokens.json`)];
+ const type = typeOf(tree, base);
+ if (token.node.$type !== undefined && token.node.$type !== type) return [at(name, `$type "${token.node.$type}" differs from tokens.json's "${type}"`)];
+ const message = shapeProblem(type, token.node.$value) ?? aliasProblem(tree, token.node.$value, type);
+ return message ? [at(name, `theme "${theme}": ${message}`)] : [];
+ });
+}
+
+/** The first theme system.md declares is the base, held by tokens.json; every other one is a
+ * context of tokens.resolver.json, whose files redefine base tokens only. */
+function themeProblems(tree, project) {
+ const themes = list(systemOf(project)?.frontMatter.themes);
+ const file = nodePath.posix.join(nodePath.posix.dirname(project.tokensFile), RESOLVER_FILE);
+ const resolver = readJson(project, file);
+ if (resolver.problem === MISSING && themes.length < 2) return [];
+ if (!resolver.value) return resolver.problem ? [{ file, message: resolver.problem }] : [];
+ const { problems, contexts } = resolverContexts(resolver.value, themes, project, file);
+ if (tree.unusable) return problems; // a theme file is read against tokens.json, which T1 names
+ return [...problems, ...Object.entries(contexts).flatMap(([theme, files]) => files.flatMap((themeFile) => themeFileProblems(tree, project, theme, themeFile)))];
+}
+
+/** A raw CSS value's `var()`s name tokens: they are its dependencies. */
+function rawCssProblems(tree, project) {
+ const at = tokenProblem(project);
+ const names = new Set([...tree.tokens.values()].map((token) => cssName(token.path)));
+ return [...tree.tokens]
+ .filter(([, token]) => typeof rawCssOf(token) === "string")
+ .flatMap(([name, token]) => [...rawCssOf(token).matchAll(CSS_VARIABLE)].filter((match) => !names.has(match[1])).map((match) => at(name, `var(${match[1]}) names no token`)));
+}
+
+/** Components and screens, which cite tokens. */
+const tokened = (project) => placed(project).filter((record) => TOKENED_KINDS.includes(record.frontMatter.kind));
+
+function citedTokenProblems(tree, project) {
+ if (tree.unusable) return [];
+ return tokened(project).flatMap((record) =>
+ list(record.frontMatter.tokens)
+ .filter((name) => !tree.tokens.has(name))
+ .map((name) => ({ file: record.file, message: `token "${name}" is not in tokens.json` }))
+ );
+}
+
+/** Whether a token path sits under a group system.md declares primitive. */
+const isPrimitive = (name, groups) => groups.some((group) => name === group || name.startsWith(`${group}${PATH_SEPARATOR}`));
+
+function primitiveProblems(tree, project) {
+ const groups = list(systemOf(project)?.frontMatter.primitives);
+ return tokened(project).flatMap((record) =>
+ list(record.frontMatter.tokens)
+ .filter((name) => isPrimitive(name, groups))
+ .map((name) => ({ file: record.file, message: `token "${name}" is a primitive, use a semantic token` }))
+ );
+}
+
+const RULES = {
+ T1: jsonProblems,
+ T2: nameProblems,
+ T3: typeProblems,
+ T4: shapeProblems,
+ T5: aliasProblems,
+ T6: themeProblems,
+ T7: citedTokenProblems,
+ T8: primitiveProblems,
+ T9: rawCssProblems,
+};
+
+/** Every broken token rule, in rule order. */
+const check = checkOf(RULES, (find, project) => find(read(project), project));
+
+/** Every token's CSS custom property, `--` plus its path joined by `-`; null when tokens.json
+ * cannot be read, so renders do not repeat T1. */
+function cssNames(project) {
+ const tree = read(project);
+ return tree.unusable ? null : new Set([...tree.tokens.values()].map((token) => cssName(token.path)));
+}
+
+/** A token path's CSS custom property. */
+const cssNameOf = (name) => cssName(name.split(PATH_SEPARATOR));
+
+module.exports = { check, cssNames, cssNameOf };
diff --git a/plugins/aidd-ui/hooks/ui-hook.cjs b/plugins/aidd-ui/hooks/ui-hook.cjs
new file mode 100644
index 000000000..ed67e1993
--- /dev/null
+++ b/plugins/aidd-ui/hooks/ui-hook.cjs
@@ -0,0 +1,145 @@
+// Runs the design system checks at three moments. After a write under aidd_docs/ui/ or a code
+// root, the turn is marked and the problems of the files the write reached come back as context.
+// At Stop, a marked turn gets its errors back once, as the reason to keep working; warnings never
+// block. At session start the counts and the errors are printed as context. Each moment prints
+// at most LIMIT problem lines. A file that cannot be read is one problem; anything else
+// unexpected exits 0 in silence, so a broken check never blocks a session.
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+const { load, relative, isUnder, UI_DIR } = require("./lib/project.cjs");
+const { codeRoots } = require("./lib/code.cjs");
+const { reached } = require("./lib/graph.cjs");
+const { problemsOf, format, isError } = require("./aidd-ui.cjs");
+
+const POST_TOOL_USE = "PostToolUse";
+const SESSION_START = "SessionStart";
+const STOP = "Stop";
+const EXIT_SILENT = 0;
+const LIMIT = 20; // problem lines a hook prints; the validator by hand prints them all
+const ENGINE = path.join(__dirname, "aidd-ui.cjs");
+// A session id is a file-name part, so only these characters reach the mark's path.
+const SAFE_SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
+// Codex's apply_patch passes its patch as tool_input.command, Copilot's Edit as tool_input itself.
+const PATCHED_FILE = /^\*\*\* (?:Add|Update) File: (.+)$/gm;
+
+/** The JSON on stdin, or null when it is none. */
+function readPayload() {
+ try {
+ return JSON.parse(fs.readFileSync(0, "utf8"));
+ } catch {
+ return null;
+ }
+}
+
+/** Whether a project-relative file belongs to the design system or to its code. */
+const isWatched = (project, file) => [UI_DIR, ...codeRoots(project)].some((prefix) => isUnder(file, prefix));
+
+/** The files a write tool touched: Claude Code and Copilot name one, a patch one per header line. */
+function writtenFiles(toolInput) {
+ if (typeof toolInput?.file_path === "string") return [toolInput.file_path];
+ if (typeof toolInput?.path === "string") return [toolInput.path];
+ const patch = typeof toolInput === "string" ? toolInput : toolInput?.command;
+ if (typeof patch !== "string") return [];
+ return [...patch.matchAll(PATCHED_FILE)].map((match) => match[1]);
+}
+
+/** The mark a turn leaves for its Stop: one empty file per session, in the OS temp dir. */
+function markFile(sessionId) {
+ if (typeof sessionId !== "string" || !SAFE_SESSION_ID.test(sessionId)) return null;
+ return path.join(os.tmpdir(), `aidd-ui-turn-${sessionId}`);
+}
+
+/** Creates the mark only where nothing exists, so a link planted at its path is never followed.
+ * The mark serves Stop alone: a mark already there, or one that cannot be written, never costs
+ * the write its feedback. */
+function leaveMark(mark) {
+ try {
+ fs.writeFileSync(mark, "", { flag: "wx", mode: 0o600 });
+ } catch {
+ // already marked this turn, or no writable temp dir
+ }
+}
+
+/** Copilot's payload carries tool_result where Claude Code and Codex carry tool_response. */
+function isCopilot(payload) {
+ return "tool_result" in payload && !("tool_response" in payload);
+}
+
+const errorsOf = (problems) => problems.filter(isError);
+
+/** At most LIMIT problem lines, then one saying how many are left and how to see them. */
+function capped(project, problems) {
+ const rest = problems.length - LIMIT;
+ return [...format(problems.slice(0, LIMIT)), ...(rest > 0 ? [`⊠and ${rest} more: node ${ENGINE} validate ${project.root}`] : [])];
+}
+
+function afterWrite(project, payload) {
+ const written = writtenFiles(payload.tool_input)
+ .map((file) => relative(project.root, path.resolve(project.root, file)))
+ .filter((file) => isWatched(project, file));
+ if (!written.length) return EXIT_SILENT;
+ const mark = markFile(payload.session_id);
+ if (mark) leaveMark(mark);
+ const problems = problemsOf(project);
+ const scope = reached(project, written);
+ const shown = scope ? problems.filter((problem) => scope.has(problem.file)) : problems;
+ if (!shown.length) return EXIT_SILENT;
+ const elsewhere = errorsOf(problems).length - errorsOf(shown).length;
+ const text = [
+ "aidd-ui: the write was saved, fix these problems:",
+ ...capped(project, shown),
+ ...(elsewhere > 0 ? [`${elsewhere} more errors elsewhere in the design system`] : []),
+ ].join("\n");
+ // Each host reads one key: Copilot the top level, Claude Code and Codex hookSpecificOutput.
+ const output = isCopilot(payload)
+ ? { additionalContext: text }
+ : { hookSpecificOutput: { hookEventName: POST_TOOL_USE, additionalContext: text } };
+ process.stdout.write(JSON.stringify(output));
+ return EXIT_SILENT;
+}
+
+/** Whether this Stop ends a turn that wrote a watched file and was not itself forced: consumes
+ * the mark either way, so the next turn starts clean. */
+function consumeMark(payload) {
+ const mark = markFile(payload.session_id);
+ if (!mark || !fs.existsSync(mark)) return false;
+ fs.rmSync(mark, { force: true });
+ return payload.stop_hook_active !== true;
+}
+
+/** Blocks the turn's end once while a marked turn still leaves errors behind. */
+function atStop(project) {
+ const errors = errorsOf(problemsOf(project));
+ if (!errors.length) return EXIT_SILENT;
+ const reason = [`aidd-ui: the design system has ${errors.length} errors, fix them before you finish:`, ...capped(project, errors)].join("\n");
+ process.stdout.write(JSON.stringify({ decision: "block", reason }));
+ return EXIT_SILENT;
+}
+
+function atSessionStart(project) {
+ const problems = problemsOf(project);
+ if (!problems.length) return EXIT_SILENT;
+ const errors = errorsOf(problems);
+ process.stdout.write(`${[`aidd-ui: ${errors.length} errors, ${problems.length - errors.length} warnings`, ...capped(project, errors)].join("\n")}\n`);
+ return EXIT_SILENT;
+}
+
+function run() {
+ const payload = readPayload();
+ if (typeof payload?.hook_event_name !== "string") return EXIT_SILENT;
+ // Stop fires on every turn: one that wrote no watched file costs no read of the project.
+ if (payload.hook_event_name === STOP && !consumeMark(payload)) return EXIT_SILENT;
+ const project = load(typeof payload.cwd === "string" ? payload.cwd : process.cwd());
+ if (project === null) return EXIT_SILENT;
+ if (payload.hook_event_name === POST_TOOL_USE) return afterWrite(project, payload);
+ if (payload.hook_event_name === SESSION_START) return atSessionStart(project);
+ if (payload.hook_event_name === STOP) return atStop(project);
+ return EXIT_SILENT;
+}
+
+try {
+ process.exitCode = run();
+} catch {
+ process.exitCode = EXIT_SILENT;
+}
diff --git a/scripts/__tests__/aidd-ui-code.test.js b/scripts/__tests__/aidd-ui-code.test.js
new file mode 100644
index 000000000..68a151565
--- /dev/null
+++ b/scripts/__tests__/aidd-ui-code.test.js
@@ -0,0 +1,197 @@
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+const { describe, it, beforeEach, afterEach } = require("node:test");
+const { UI, writeProject, edit, put } = require("./fixtures/aidd-ui/project.js");
+
+// Each rule has one mutation of the valid project, and the test asserts the whole problem
+// list: dropping the rule turns its own test red and leaves every other one green. Link
+// mutations land on search-field: it owns no file and is not coded, so none orphans a file.
+const LIB = path.resolve(__dirname, "../../plugins/aidd-ui/hooks/lib");
+const { load } = require(path.join(LIB, "project.cjs"));
+const code = require(path.join(LIB, "code.cjs"));
+
+const SYSTEM = `${UI}/system.md`;
+const BUTTON = `${UI}/components/button.md`;
+const SEARCH = `${UI}/components/search-field.md`;
+const DIALOG = `${UI}/components/dialog.md`;
+const SEARCH_CSS = "src/ui/SearchField.css";
+
+let tmp;
+
+beforeEach(() => {
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "aidd-ui-code-"));
+ writeProject(tmp);
+});
+
+afterEach(() => {
+ fs.rmSync(tmp, { recursive: true, force: true });
+ assert.equal(fs.existsSync(tmp), false);
+});
+
+const problems = () => code.check(load(tmp)).map((problem) => `${problem.file}: ${problem.message}`);
+
+/** Gives search-field a code link. */
+const linkSearch = (link) => edit(tmp, SEARCH, "role: search", `role: search\ncode: ${link}`);
+
+/** Writes a stylesheet search-field links to, so only its content can raise a problem. */
+function linkedStylesheet(css) {
+ put(tmp, SEARCH_CSS, css);
+ linkSearch(`${SEARCH_CSS}#SearchField`);
+}
+
+/** Appends one line to a file of the project. */
+const append = (file, line) => fs.appendFileSync(path.join(tmp, file), `${line}\n`);
+
+describe("aidd-ui code link", () => {
+ it("passes the valid project", () => {
+ assert.deepEqual(problems(), []);
+ });
+
+ describe("C1 links", () => {
+ it("names a code file that does not exist", () => {
+ linkSearch("src/ui/Missing.tsx#SearchField");
+ assert.deepEqual(problems(), [`${SEARCH}: code file "src/ui/Missing.tsx" does not exist`]);
+ });
+
+ it("reads code-roots and ignore written with a leading ./", () => {
+ edit(tmp, SYSTEM, "code-roots: [src/ui]", "code-roots: [./src/ui]");
+ edit(tmp, SYSTEM, "ignore: [src/ui/vendor]", "ignore: [./src/ui/vendor]");
+ assert.deepEqual(problems(), []);
+ });
+
+ it("names an export absent from the file", () => {
+ edit(tmp, BUTTON, "#Button", "#Buttn");
+ assert.deepEqual(problems(), [`${BUTTON}: export "Buttn" is not in src/ui/Button.tsx`]);
+ });
+
+ it("names an export that is only part of a word in the file", () => {
+ edit(tmp, BUTTON, "#Button", "#Butto");
+ assert.deepEqual(problems(), [`${BUTTON}: export "Butto" is not in src/ui/Button.tsx`]);
+ });
+
+ it("reads code-roots and ignore written with a trailing /", () => {
+ edit(tmp, SYSTEM, "code-roots: [src/ui]", "code-roots: [src/ui/]");
+ edit(tmp, SYSTEM, "ignore: [src/ui/vendor]", "ignore: [src/ui/vendor/]");
+ assert.deepEqual(problems(), []);
+ });
+
+ it("names a link without #", () => {
+ linkSearch("src/ui/Button.tsx");
+ assert.deepEqual(problems(), [`${SEARCH}: code "src/ui/Button.tsx" must be path#Export or package:#Export`]);
+ });
+ });
+
+ describe("C2 libraries", () => {
+ it("names a package system.md does not list", () => {
+ edit(tmp, DIALOG, "package:@radix-ui/react-dialog#Root", "package:left-pad#Pad");
+ assert.deepEqual(problems(), [`${DIALOG}: package "left-pad" is not in system.md libraries`]);
+ });
+ });
+
+ describe("C3 coverage", () => {
+ it("names a file under a code root no record links and no ignore covers", () => {
+ put(tmp, "src/ui/Orphan.tsx", "export function Orphan() {}\n");
+ assert.deepEqual(problems(), ["src/ui/Orphan.tsx: no record links it and no ignore covers it"]);
+ });
+
+ it("covers a folder named after the linked file: its styles, hooks and tests", () => {
+ put(tmp, "src/ui/search-field/search-field.component.tsx", "export function SearchField() {}\n");
+ put(tmp, "src/ui/search-field/search-field-filter.ts", "export const filter = () => null;\n");
+ put(tmp, "src/ui/search-field/__tests__/search-field.test.tsx", "test();\n");
+ linkSearch("src/ui/search-field/search-field.component.tsx#SearchField");
+ assert.deepEqual(problems(), []);
+ });
+
+ it("covers only the files of its stem among siblings", () => {
+ put(tmp, "src/ui/Button.module.css", ".ui {}\n");
+ put(tmp, "src/ui/Orphan.tsx", "export function Orphan() {}\n");
+ assert.deepEqual(problems(), ["src/ui/Orphan.tsx: no record links it and no ignore covers it"]);
+ });
+
+ it("accepts a valid link on a proposed record", () => {
+ edit(tmp, SEARCH, "status: validated", "status: proposed");
+ put(tmp, "src/ui/SearchField.tsx", "export function SearchField() {}\n");
+ linkSearch("src/ui/SearchField.tsx#SearchField");
+ assert.deepEqual(problems(), []);
+ });
+ });
+
+ describe("C4 colour literals", () => {
+ it("names a colour literal in UI code, with its line", () => {
+ append("src/ui/Button.tsx", 'const style = "color: #ff0000";');
+ assert.deepEqual(problems(), ['src/ui/Button.tsx: line 4: colour literal "#ff0000", use a token']);
+ });
+
+ it("leaves a hex word in a script comment alone", () => {
+ append("src/ui/Button.tsx", "// toggles #add");
+ assert.deepEqual(problems(), []);
+ });
+
+ it("skips an ignored file", () => {
+ append("src/ui/vendor/reset.css", ".x { color: #ff0000; }");
+ assert.deepEqual(problems(), []);
+ });
+
+ it("skips the code's token file, generated from the tokens", () => {
+ append("src/ui/tokens.css", ".x { color: #ff0000; }");
+ assert.deepEqual(problems(), []);
+ });
+
+ it("skips a mock folder, which states colours to stand in for real data", () => {
+ put(tmp, "src/ui/__mocks__/theme.ts", 'export const primary = "#336699";\n');
+ assert.deepEqual(problems().filter((problem) => !problem.includes("no record links it")), []);
+ });
+
+ it("skips a test, which states colours to check them", () => {
+ put(tmp, "src/ui/Button.test.tsx", 'expect(color).toBe("#336699");\n');
+ assert.deepEqual(problems(), []);
+ });
+ });
+
+ describe("C4 other literals", () => {
+ it("leaves a unit literal in code alone, a stated limit", () => {
+ linkedStylesheet(".SearchField { padding: 16px; }\n");
+ assert.deepEqual(problems(), []);
+ });
+ });
+
+ describe("C5 one record per file", () => {
+ it("names two records sharing one code link", () => {
+ linkSearch("src/ui/Button.tsx#Button");
+ assert.deepEqual(problems(), [`${SEARCH}: code "src/ui/Button.tsx#Button" is also ${BUTTON}'s`]);
+ });
+ });
+
+ describe("C6 logical properties in code", () => {
+ it("names a physical property in a stylesheet when rtl is declared", () => {
+ linkedStylesheet(".SearchField { margin-left: 0; }\n");
+ assert.deepEqual(problems(), [`${SEARCH_CSS}: line 1: physical property "margin-left", use its logical form`]);
+ });
+
+ it("allows it when only ltr is declared", () => {
+ edit(tmp, SYSTEM, "directions: [ltr, rtl]", "directions: [ltr]");
+ linkedStylesheet(".SearchField { margin-left: 0; }\n");
+ assert.deepEqual(problems(), []);
+ });
+ });
+
+ describe("C8 code tokens", () => {
+ it("names a code token file that does not exist", () => {
+ fs.rmSync(path.join(tmp, "src/ui/tokens.css"));
+ assert.deepEqual(problems(), [`${SYSTEM}: code-tokens "src/ui/tokens.css" does not exist`]);
+ });
+ });
+
+ describe("C7 code roots inside the project", () => {
+ it("names a root that is the project or outside it, and walks nothing from it", () => {
+ edit(tmp, SYSTEM, "code-roots: [src/ui]", "code-roots: [src/ui, ., ../shared, /abs]");
+ assert.deepEqual(problems(), [
+ `${SYSTEM}: code root "." must name a folder inside the project`,
+ `${SYSTEM}: code root "../shared" must name a folder inside the project`,
+ `${SYSTEM}: code root "/abs" must name a folder inside the project`,
+ ]);
+ });
+ });
+});
diff --git a/scripts/__tests__/aidd-ui-records.test.js b/scripts/__tests__/aidd-ui-records.test.js
new file mode 100644
index 000000000..b3727a875
--- /dev/null
+++ b/scripts/__tests__/aidd-ui-records.test.js
@@ -0,0 +1,490 @@
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+const { describe, it, beforeEach, afterEach } = require("node:test");
+const { UI, writeProject, edit, put, record, history } = require("./fixtures/aidd-ui/project.js");
+
+// Each rule has one mutation of the valid project, and the test asserts the whole problem
+// list: dropping the rule turns its own test red and leaves every other one green.
+const LIB = path.resolve(__dirname, "../../plugins/aidd-ui/hooks/lib");
+const { load } = require(path.join(LIB, "project.cjs"));
+const records = require(path.join(LIB, "records.cjs"));
+const historyRules = require(path.join(LIB, "history.cjs"));
+
+const SYSTEM = `${UI}/system.md`;
+const BRAND = `${UI}/brand.md`;
+const BUTTON = `${UI}/components/button.md`;
+const SEARCH = `${UI}/components/search-field.md`;
+const DIALOG = `${UI}/components/dialog.md`;
+const OLD = `${UI}/components/old-button.md`;
+const HOME = `${UI}/screens/home.md`;
+const JOURNEY = `${UI}/journeys/onboarding.md`;
+const ICON = `${UI}/assets/icon-search.md`;
+const REVIEW = `${UI}/reviews/first-pass.md`;
+
+let tmp;
+
+beforeEach(() => {
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "aidd-ui-records-"));
+ writeProject(tmp);
+});
+
+afterEach(() => {
+ fs.rmSync(tmp, { recursive: true, force: true });
+ assert.equal(fs.existsSync(tmp), false);
+});
+
+/** Every problem both checks find, as `file: message`. */
+function problems() {
+ const project = load(tmp);
+ return [...records.check(project), ...historyRules.check(project)].map((problem) => `${problem.file}: ${problem.message}`);
+}
+
+const change = (file, from, to) => edit(tmp, file, from, to);
+
+describe("aidd-ui records and history", () => {
+ it("passes the valid project", () => {
+ assert.deepEqual(problems(), []);
+ });
+
+ it("loads nothing without aidd_docs/ui/", () => {
+ fs.rmSync(path.join(tmp, UI), { recursive: true });
+ assert.equal(load(tmp), null);
+ });
+
+ it("loads every record", () => {
+ assert.equal(load(tmp).records.length, 10);
+ });
+
+ describe("R1 front matter", () => {
+ it("names a record without front matter", () => {
+ put(tmp, JOURNEY, "# Onboarding\n");
+ assert.deepEqual(problems(), [`${JOURNEY}: line 1: no front matter, open and close it with a --- line`]);
+ });
+
+ it("names a line that is neither key: value nor key: [a, b]", () => {
+ change(JOURNEY, "persona: a first-time analyst", "persona: a first-time analyst\n - nested");
+ assert.deepEqual(problems(), [`${JOURNEY}: line 7: expected "key: value" or "key: [a, b]", got "- nested"`]);
+ });
+
+ it("reads front matter behind a byte order mark", () => {
+ const file = path.join(tmp, JOURNEY);
+ fs.writeFileSync(file, `\uFEFF${fs.readFileSync(file, "utf8")}`);
+ assert.deepEqual(problems(), []);
+ });
+
+ it("reads a fence line with trailing spaces", () => {
+ change(JOURNEY, "---\nid: onboarding", "--- \nid: onboarding");
+ assert.deepEqual(problems(), []);
+ });
+
+ it("reads CRLF line endings", () => {
+ const file = path.join(tmp, JOURNEY);
+ fs.writeFileSync(file, fs.readFileSync(file, "utf8").replace(/\n/g, "\r\n"));
+ assert.deepEqual(problems(), []);
+ });
+
+ it("unquotes scalar values and list items", () => {
+ change(SEARCH, "id: search-field", "id: 'search-field'");
+ change(SEARCH, "status: validated", 'status: "validated"');
+ change(SEARCH, "uses: [button, icon-search]", `uses: ["button", 'icon-search']`);
+ assert.deepEqual(problems(), []);
+ });
+
+ it("reads an empty value as an empty list", () => {
+ change(OLD, "uses: []", "uses:");
+ assert.deepEqual(problems(), []);
+ });
+ });
+
+ describe("R2 kind and folder", () => {
+ it("names an unknown kind", () => {
+ change(JOURNEY, "kind: journey", "kind: widget");
+ assert.deepEqual(problems(), [`${JOURNEY}: unknown kind "widget"`]);
+ });
+
+ it("names a kind that only an object's prototype holds as unknown", () => {
+ change(JOURNEY, "kind: journey", "kind: toString");
+ assert.deepEqual(problems(), [`${JOURNEY}: unknown kind "toString"`]);
+ });
+
+ it("names a record in a folder that only an object's prototype holds", () => {
+ put(tmp, `${UI}/constructor/extra.md`, record(["id: extra", "kind: atom", "status: proposed", "version: 1"], history(["1", "created", "no", "aidd_docs/tasks/ds-init"])));
+ assert.deepEqual(problems(), [`${UI}/constructor/extra.md: kind "atom" does not belong in constructor/`]);
+ });
+
+ it("names a kind outside its folder", () => {
+ put(tmp, `${UI}/screens/extra.md`, record(["id: extra", "kind: atom", "status: proposed", "version: 1"], history(["1", "created", "no", "aidd_docs/tasks/ds-init"])));
+ assert.deepEqual(problems(), [`${UI}/screens/extra.md: kind "atom" does not belong in screens/`]);
+ });
+ });
+
+ describe("R3 required keys and values", () => {
+ const missing = [
+ [SYSTEM, "locales: [fr, ar]\n", "locales"],
+ [BRAND, "version: 1\n", "version"],
+ [BUTTON, "role: button\n", "role"],
+ [HOME, "route: /\n", "route"],
+ [JOURNEY, "persona: a first-time analyst\n", "persona"],
+ [ICON, "alt: Search\n", "alt"],
+ [REVIEW, "scope: system\n", "scope"],
+ ];
+ for (const [file, line, key] of missing) {
+ it(`names ${key} missing from ${path.basename(file)}`, () => {
+ change(file, line, "");
+ assert.deepEqual(problems(), [`${file}: missing key "${key}"`]);
+ });
+ }
+
+ it("names an unknown status", () => {
+ change(JOURNEY, "status: validated", "status: done");
+ assert.deepEqual(problems(), [`${JOURNEY}: status "done" must be proposed, validated or deprecated`]);
+ });
+
+ it("names an interactive value that is neither yes nor no", () => {
+ change(SEARCH, "interactive: yes", "interactive: maybe");
+ assert.deepEqual(problems(), [`${SEARCH}: interactive "maybe" must be yes or no`]);
+ });
+
+ it("names an unknown asset type", () => {
+ change(ICON, "type: icon", "type: photo");
+ assert.deepEqual(problems(), [`${ICON}: type "photo" must be icon, image or illustration`]);
+ });
+ });
+
+ describe("R4 identity", () => {
+ it("names an id that differs from the file name", () => {
+ change(JOURNEY, "id: onboarding", "id: onboard");
+ assert.deepEqual(problems(), [`${JOURNEY}: id "onboard" differs from the file name "onboarding"`]);
+ });
+
+ it("names an id two records hold", () => {
+ const screen = ["id: brand", "kind: screen", "status: proposed", "version: 1", "uses: []", "tokens: []", "states: [default]", "breakpoints: [mobile]", "route: /brand"];
+ put(tmp, `${UI}/screens/brand.md`, record(screen, history(["1", "created", "no", "aidd_docs/tasks/ds-init"])));
+ assert.deepEqual(problems(), [`${UI}/screens/brand.md: id "brand" already belongs to ${BRAND}`]);
+ });
+ });
+
+ describe("R5 version", () => {
+ for (const bad of ["0", "v2"]) {
+ it(`names element version ${bad}`, () => {
+ change(JOURNEY, "version: 1", `version: ${bad}`);
+ assert.deepEqual(problems(), [`${JOURNEY}: version "${bad}" must be an integer from 1`]);
+ });
+ }
+
+ it("names a system version that is not semver", () => {
+ change(SYSTEM, "version: 2.0.0", "version: 1.0");
+ assert.deepEqual(problems(), [`${SYSTEM}: version "1.0" must be semver, such as 2.0.0`]);
+ });
+ });
+
+ describe("R6 uses", () => {
+ it("names a used id no record has", () => {
+ change(SEARCH, "uses: [button, icon-search]", "uses: [button, icon-search, ghost]");
+ assert.deepEqual(problems(), [`${SEARCH}: uses "ghost", which no record has`]);
+ });
+
+ const levels = [
+ [OLD, "uses: []", "uses: [search-field]", 'atom cannot use molecule "search-field"'],
+ [SEARCH, "uses: [button, icon-search]", "uses: [button, icon-search, dialog]", 'molecule cannot use composition "dialog"'],
+ [HOME, "uses: [search-field, dialog]", "uses: [search-field, dialog, home]", "uses itself"],
+ ];
+ for (const [file, from, to, message] of levels) {
+ it(`names ${message}`, () => {
+ change(file, from, to);
+ assert.deepEqual(problems(), [`${file}: ${message}`]);
+ });
+ }
+ });
+
+ it("accepts an element using another of its own level", () => {
+ change(OLD, "uses: []", "uses: [button]");
+ assert.deepEqual(problems(), []);
+ });
+
+ describe("R7 states and keyboard", () => {
+ it("names an interactive component without focus", () => {
+ change(BUTTON, "states: [default, hover, focus, disabled]", "states: [default, hover, disabled]");
+ assert.deepEqual(problems(), [`${BUTTON}: interactive component lacks state "focus"`]);
+ });
+
+ it("accepts an interactive component without hover or disabled, which exist only when it has them", () => {
+ change(BUTTON, "states: [default, hover, focus, disabled]", "states: [default, focus]");
+ assert.deepEqual(problems(), []);
+ });
+
+ it("names a component without default", () => {
+ change(OLD, "states: [default]", "states: [idle]");
+ assert.deepEqual(problems(), [`${OLD}: component lacks state "default"`]);
+ });
+
+ it("names a screen without default", () => {
+ change(HOME, "states: [default, empty, loading, error]", "states: [empty, loading, error]");
+ assert.deepEqual(problems(), [`${HOME}: screen lacks state "default"`]);
+ });
+
+ it("names an interactive component without a keyboard section", () => {
+ change(DIALOG, "## Keyboard\n\nEscape closes it; focus stays inside while it is open.\n\n", "");
+ assert.deepEqual(problems(), [`${DIALOG}: interactive component lacks a "## Keyboard" section`]);
+ });
+ });
+
+ describe("R8 implemented version", () => {
+ it("names an implemented record without code", () => {
+ change(HOME, "code: src/ui/HomePage.tsx#HomePage\n", "");
+ assert.deepEqual(problems(), [`${HOME}: implemented-version needs "code"`]);
+ });
+
+ it("accepts an implemented molecule without code when everything it uses is implemented", () => {
+ change(SEARCH, "version: 1", "version: 1\nimplemented-version: 1");
+ assert.deepEqual(problems(), []);
+ });
+
+ it("names an implemented molecule without code that uses a component not implemented", () => {
+ change(SEARCH, "version: 1", "version: 1\nimplemented-version: 1");
+ change(BUTTON, "implemented-version: 1\n", "");
+ assert.deepEqual(problems(), [`${SEARCH}: implemented-version needs "code", or every element it uses implemented`]);
+ });
+
+ it("accepts code that lags behind the design, as a feature leaves it until it follows", () => {
+ change(BUTTON, "version: 1", "version: 2");
+ change(BUTTON, "| 1 | created | no | aidd_docs/tasks/ds-init |", "| 1 | created | no | aidd_docs/tasks/ds-init |\n| 2 | larger | no | aidd_docs/tasks/tweak |");
+ assert.deepEqual(problems(), []);
+ });
+
+ it("names an implemented version ahead of the design", () => {
+ change(BUTTON, "implemented-version: 1", "implemented-version: 2");
+ assert.deepEqual(problems(), [`${BUTTON}: implemented-version 2 is ahead of version 1`]);
+ });
+
+ it("names an implemented version that is no integer", () => {
+ change(BUTTON, "implemented-version: 1", "implemented-version: yes");
+ assert.deepEqual(problems(), [`${BUTTON}: implemented-version "yes" must be an integer from 1`]);
+ });
+
+ it("names an implemented version on a record that is never code", () => {
+ change(JOURNEY, "version: 1", "version: 1\nimplemented-version: 1");
+ assert.deepEqual(problems(), [`${JOURNEY}: implemented-version is for components and screens, never a journey`]);
+ });
+ });
+
+ describe("R9 deprecated", () => {
+ it("names a deprecated record without replaced-by", () => {
+ change(OLD, "replaced-by: button\n", "");
+ assert.deepEqual(problems(), [`${OLD}: status deprecated needs "replaced-by"`]);
+ });
+
+ it("names a replacement no record has", () => {
+ change(OLD, "replaced-by: button", "replaced-by: ghost");
+ assert.deepEqual(problems(), [`${OLD}: replaced-by "ghost", which no record has`]);
+ });
+
+ it("names a replacement that is deprecated too", () => {
+ change(OLD, "replaced-by: button", "replaced-by: old-button");
+ assert.deepEqual(problems(), [`${OLD}: replaced-by "old-button" is deprecated too`]);
+ });
+
+ it("names replaced-by none without a deprecation section", () => {
+ change(OLD, "replaced-by: button", "replaced-by: none");
+ assert.deepEqual(problems(), [`${OLD}: replaced-by none needs a "## Deprecation" section saying why`]);
+ });
+ });
+
+ describe("R10 deprecated in use", () => {
+ it("names a live record using a deprecated one", () => {
+ change(SEARCH, "uses: [button, icon-search]", "uses: [button, icon-search, old-button]");
+ assert.deepEqual(problems(), [`${SEARCH}: uses "old-button", which is deprecated`]);
+ });
+
+ it("names an atom using a deprecated asset", () => {
+ put(
+ tmp,
+ `${UI}/assets/icon-old.md`,
+ record(
+ ["id: icon-old", "kind: asset", "status: deprecated", "version: 2", "type: icon", "file: icon-search.svg", "alt: Search", "replaced-by: icon-search"],
+ history(["1", "created", "no", "aidd_docs/tasks/ds-init"], ["2", "deprecated for icon-search", "yes", "aidd_docs/tasks/retire-old-button"])
+ )
+ );
+ change(BUTTON, "uses: []", "uses: [icon-old]");
+ assert.deepEqual(problems(), [`${BUTTON}: uses "icon-old", which is deprecated`]);
+ });
+ });
+
+ describe("R11 breakpoints", () => {
+ it("names a screen breakpoint system.md lacks", () => {
+ change(HOME, "breakpoints: [mobile, desktop]", "breakpoints: [mobile, tablet, desktop]");
+ assert.deepEqual(problems(), [`${HOME}: breakpoint "tablet" is not in system.md`]);
+ });
+ });
+
+ describe("R12 journeys", () => {
+ it("names a step whose screen is absent", () => {
+ change(JOURNEY, "| home | opens a result |", "| ghost | opens a result |");
+ assert.deepEqual(problems(), [`${JOURNEY}: step 2 names screen "ghost", which no screen has`]);
+ });
+ });
+
+ describe("R13 reviews", () => {
+ it("names a screen state with no verdict row", () => {
+ change(REVIEW, "| home | loading | 1 | ko | spinner too small |\n", "");
+ assert.deepEqual(problems(), [`${REVIEW}: no verdict row for home / loading at version 1`]);
+ });
+
+ it("names a verdict for an older version as no verdict for the current one", () => {
+ change(REVIEW, "| home | loading | 1 |", "| home | loading | 0 |");
+ assert.deepEqual(problems(), [`${REVIEW}: no verdict row for home / loading at version 1`]);
+ });
+
+ it("names a verdict that is not ok, ko or pending", () => {
+ change(REVIEW, "| home | default | 1 | ok | |", "| home | default | 1 | maybe | |");
+ assert.deepEqual(problems(), [`${REVIEW}: verdict "maybe" for home / default must be ok, ko or pending`]);
+ });
+ });
+
+ describe("R14 brand", () => {
+ it("names a missing brand heading", () => {
+ change(BRAND, "## Users\n\nAnalysts.\n\n", "");
+ assert.deepEqual(problems(), [`${BRAND}: lacks "## Users"`]);
+ });
+ });
+
+ describe("R15 assets", () => {
+ it("names an asset whose file is absent", () => {
+ change(ICON, "file: icon-search.svg", "file: missing.svg");
+ assert.deepEqual(problems(), [`${ICON}: file "missing.svg" does not exist beside it`]);
+ });
+ });
+
+ describe("R16 system, brand and review statuses", () => {
+ const cases = [
+ [SYSTEM, "status: validated", "deprecated"],
+ [BRAND, "status: validated", "deprecated"],
+ [REVIEW, "status: proposed", "deprecated"],
+ ];
+ for (const [file, from, status] of cases) {
+ it(`names ${path.basename(file)} with status ${status}`, () => {
+ change(file, from, `status: ${status}`);
+ assert.deepEqual(problems(), [`${file}: status "${status}" is for elements: system, brand and reviews are proposed or validated`]);
+ });
+ }
+ });
+
+ describe("R17 usage", () => {
+ it("names a component without a usage section", () => {
+ change(DIALOG, "## Usage\n\nA decision that blocks the page.\n\n", "");
+ assert.deepEqual(problems(), [`${DIALOG}: lacks "## Usage", saying when to use it and when not`]);
+ });
+ });
+
+ describe("H1 history present", () => {
+ it("names a versioned record without a history table", () => {
+ change(JOURNEY, "## History", "## Changes");
+ assert.deepEqual(problems(), [`${JOURNEY}: no "## History" table`]);
+ });
+ });
+
+ describe("H2 one change per version", () => {
+ it("names two rows claiming one version, as a merge of two branches leaves", () => {
+ change(OLD, "| 2 | deprecated for button |", "| 2 | larger label | no | aidd_docs/tasks/ds-init |\n| 2 | deprecated for button |");
+ assert.deepEqual(problems(), [`${OLD}: two changes claim version 2, renumber one`]);
+ });
+ });
+
+ describe("H3 version matches history", () => {
+ it("names a version that is not the highest row", () => {
+ change(JOURNEY, "version: 1", "version: 2");
+ assert.deepEqual(problems(), [`${JOURNEY}: version 2 is not the highest history row, 1`]);
+ });
+
+ it("names a gap in the history", () => {
+ change(BUTTON, "version: 1", "version: 3");
+ change(BUTTON, "| 1 | created | no | aidd_docs/tasks/ds-init |", "| 1 | created | no | aidd_docs/tasks/ds-init |\n| 3 | larger | no | aidd_docs/tasks/ds-init |");
+ assert.deepEqual(problems(), [`${BUTTON}: history jumps from 1 to 3`]);
+ });
+ });
+
+ describe("H4 breaking", () => {
+ it("names a breaking value that is neither yes nor no", () => {
+ change(JOURNEY, "| 1 | created | no |", "| 1 | created | maybe |");
+ assert.deepEqual(problems(), [`${JOURNEY}: breaking "maybe" in version 1 must be yes or no`]);
+ });
+ });
+
+ describe("H5 system version follows its publications", () => {
+ const SECOND = "| 2.0.0 | old-button retired | yes | aidd_docs/tasks/retire-old-button |";
+
+ it("names a publication of a breaking task that is not breaking itself", () => {
+ change(SYSTEM, SECOND, "| 2.0.0 | old-button retired | no | aidd_docs/tasks/retire-old-button |");
+ assert.deepEqual(problems(), [`${SYSTEM}: 2.0.0 publishes "aidd_docs/tasks/retire-old-button", which breaks an element, so it is breaking`]);
+ });
+
+ it("names a breaking publication without a major bump", () => {
+ change(SYSTEM, "version: 2.0.0", "version: 1.1.0");
+ change(SYSTEM, "| 2.0.0 |", "| 1.1.0 |");
+ assert.deepEqual(problems(), [`${SYSTEM}: 1.1.0 is breaking, yet raises no major version`]);
+ });
+
+ it("names a publication that raises neither major nor minor", () => {
+ change(SYSTEM, "version: 2.0.0", "version: 2.0.1");
+ change(SYSTEM, SECOND, `${SECOND}\n| 2.0.1 | button padding | no | aidd_docs/tasks/tweak |`);
+ assert.deepEqual(problems(), [`${SYSTEM}: 2.0.1 raises neither major nor minor`]);
+ });
+
+ it("accepts a breaking publication of tokens alone, which no element history shows", () => {
+ change(SYSTEM, "version: 2.0.0", "version: 3.0.0");
+ change(SYSTEM, SECOND, `${SECOND}\n| 3.0.0 | color.brand renamed | yes | aidd_docs/tasks/rename-brand |`);
+ assert.deepEqual(problems(), []);
+ });
+
+ it("accepts a publication listing several tasks", () => {
+ change(SYSTEM, SECOND, "| 2.0.0 | old-button retired | yes | aidd_docs/tasks/ds-init, aidd_docs/tasks/retire-old-button |");
+ assert.deepEqual(problems(), []);
+ });
+
+ it("reads a newest-first system history by version", () => {
+ change(SYSTEM, `| 1.0.0 | first system | no | aidd_docs/tasks/ds-init |\n${SECOND}`, `${SECOND}\n| 1.0.0 | first system | no | aidd_docs/tasks/ds-init |`);
+ assert.deepEqual(problems(), []);
+ });
+
+ it("accepts an element task no publication lists yet, as two parallel features leave", () => {
+ change(JOURNEY, "| no | aidd_docs/tasks/ds-init |", "| no | aidd_docs/tasks/new-flow |");
+ assert.deepEqual(problems(), []);
+ });
+ });
+
+ describe("H6 deprecation breaks", () => {
+ it("names a deprecated record whose last row is not breaking", () => {
+ change(OLD, "| yes | aidd_docs/tasks/retire-old-button |", "| no | aidd_docs/tasks/retire-old-button |");
+ assert.deepEqual(problems(), [`${OLD}: deprecated, yet its last history row is not breaking`]);
+ });
+ });
+ describe("H7 tasks are folders", () => {
+ it("names an element task that is not a task folder", () => {
+ change(JOURNEY, "| no | aidd_docs/tasks/ds-init |", "| no | ds-init |");
+ assert.deepEqual(problems(), [`${JOURNEY}: task "ds-init" in version 1 must be a task folder, aidd_docs/tasks/âŠ`]);
+ });
+
+ it("names a published task that is not a task folder", () => {
+ change(SYSTEM, "| 2.0.0 | old-button retired | yes | aidd_docs/tasks/retire-old-button |", "| 2.0.0 | old-button retired | yes | aidd_docs/tasks/retire-old-button, PROJ-12 |");
+ assert.deepEqual(problems(), [`${SYSTEM}: task "PROJ-12" in version 2.0.0 must be a task folder, aidd_docs/tasks/âŠ`]);
+ });
+ });
+
+ describe("format", () => {
+ it("names a system.md without a format", () => {
+ change(SYSTEM, "format: 1\n", "");
+ assert.deepEqual(problems(), [`${SYSTEM}: missing key "format"`]);
+ });
+
+ it("reads a format it knows as none of its problems", () => {
+ assert.equal(records.formatProblem(load(tmp)), null);
+ });
+
+ it("names another format as the one problem", () => {
+ change(SYSTEM, "format: 1", "format: 2");
+ assert.deepEqual(records.formatProblem(load(tmp)), { file: SYSTEM, message: 'format "2" is not 1, the one this validator reads' });
+ });
+ });
+});
diff --git a/scripts/__tests__/aidd-ui-tokens-renders.test.js b/scripts/__tests__/aidd-ui-tokens-renders.test.js
new file mode 100644
index 000000000..7c3f732ae
--- /dev/null
+++ b/scripts/__tests__/aidd-ui-tokens-renders.test.js
@@ -0,0 +1,350 @@
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+const { describe, it, beforeEach, afterEach } = require("node:test");
+const { UI, writeProject, edit, editTokens } = require("./fixtures/aidd-ui/project.js");
+
+// Each rule has one mutation of the valid project, and the test asserts the whole problem
+// list: dropping the rule turns its own test red and leaves every other one green.
+const LIB = path.resolve(__dirname, "../../plugins/aidd-ui/hooks/lib");
+const { load } = require(path.join(LIB, "project.cjs"));
+const tokens = require(path.join(LIB, "tokens.cjs"));
+const renders = require(path.join(LIB, "renders.cjs"));
+
+const TOKENS = `${UI}/tokens.json`;
+const SYSTEM = `${UI}/system.md`;
+const BUTTON = `${UI}/components/button.md`;
+const BUTTON_RENDER = `${UI}/components/button.html`;
+const DIALOG = `${UI}/components/dialog.md`;
+const DIALOG_RENDER = `${UI}/components/dialog.html`;
+const HOME = `${UI}/screens/home.md`;
+const RESOLVER = `${UI}/tokens.resolver.json`;
+const DARK = `${UI}/themes/dark.json`;
+const INSERTED_LINE = 27; // the line before | |