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 alpha

🎹 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 in button.html + +let tmp; + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "aidd-ui-tokens-renders-")); + 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 [...tokens.check(project), ...renders.check(project)].map((problem) => `${problem.file}: ${problem.message}`); +} + +/** Adds one token under a `misc` group. */ +const addToken = (name, token) => editTokens(tmp, (tree) => (tree.misc = { ...tree.misc, [name]: token })); + +/** Adds one element to button.html, on the line before . */ +const addToRender = (html) => edit(tmp, BUTTON_RENDER, "", `${html}\n`); + +describe("aidd-ui tokens and renders", () => { + it("passes the valid project", () => { + assert.deepEqual(problems(), []); + }); + + it("names every token's CSS custom property", () => { + const names = tokens.cssNames(load(tmp)); + assert.ok(names.has("--color-text-default")); + assert.equal(names.size, 8); + }); + + describe("T1 JSON", () => { + it("names a tokens file that is not JSON", () => { + fs.writeFileSync(path.join(tmp, TOKENS), "{ not json"); + assert.deepEqual(problems(), [`${TOKENS}: not JSON`]); + }); + + it("reads a tokens file behind a byte order mark, as Windows editors write it", () => { + const file = path.join(tmp, TOKENS); + fs.writeFileSync(file, `ï»ż${fs.readFileSync(file, "utf8")}`); + assert.deepEqual(problems(), []); + }); + + it("names a missing tokens file once, and nothing that depends on it", () => { + fs.rmSync(path.join(tmp, TOKENS)); + assert.deepEqual(problems(), [`${TOKENS}: missing`]); + }); + }); + + describe("T2 names", () => { + it("names a $ key that is not reserved", () => { + editTokens(tmp, (tree) => (tree.color.$foo = 1)); + assert.deepEqual(problems(), [`${TOKENS}: color.$foo: "$foo" is not a reserved key`]); + }); + + it("names a name holding a dot", () => { + addToken("a.b", { $type: "number", $value: 1 }); + assert.deepEqual(problems(), [`${TOKENS}: misc.a.b: a name never holds ".", "{" or "}"`]); + }); + }); + + describe("T3 value and type", () => { + const cases = [ + [{ $type: "number" }, "no $value"], + [{ $value: 1 }, "no $type on it, its alias or a parent group"], + [{ $type: "colour", $value: 1 }, '$type "colour" is not a DTCG type'], + ]; + for (const [token, message] of cases) { + it(`names a token with ${message}`, () => { + addToken("x", token); + assert.deepEqual(problems(), [`${TOKENS}: misc.x: ${message}`]); + }); + } + }); + + describe("T4 primitive shapes", () => { + const cases = [ + [{ $type: "color", $value: { colorSpace: "cmyk", components: [0, 0, 0] } }, 'colorSpace "cmyk" is not a DTCG colour space'], + [{ $type: "color", $value: { colorSpace: "srgb", components: [0, 0] } }, 'components must be three numbers or "none"'], + [{ $type: "color", $value: { colorSpace: "srgb", components: [0, 0, 0], hex: "#fff" } }, 'hex "#fff" must be # and six hex digits'], + [{ $type: "dimension", $value: { value: 1, unit: "em" } }, 'unit "em" must be px or rem'], + [{ $type: "duration", $value: { value: 1, unit: "min" } }, 'unit "min" must be ms or s'], + [{ $type: "fontWeight", $value: 1200 }, "fontWeight 1200 must be 1 to 1000 or a keyword"], + [{ $type: "cubicBezier", $value: [1.5, 0, 0.5, 1] }, "cubicBezier needs four numbers, x in 0..1"], + ]; + for (const [token, message] of cases) { + it(`names ${message}`, () => { + addToken("x", token); + assert.deepEqual(problems(), [`${TOKENS}: misc.x: ${message}`]); + }); + } + }); + + describe("T5 aliases", () => { + it("names an alias to no token", () => { + addToken("x", { $type: "color", $value: "{color.missing}" }); + assert.deepEqual(problems(), [`${TOKENS}: misc.x: alias {color.missing} names no token`]); + }); + + it("names an alias to a group", () => { + addToken("x", { $type: "color", $value: "{color.brand}" }); + assert.deepEqual(problems(), [`${TOKENS}: misc.x: alias {color.brand} names a group, not a token`]); + }); + + it("names an alias to a token of another type", () => { + addToken("x", { $type: "dimension", $value: "{color.brand.primary}" }); + assert.deepEqual(problems(), [`${TOKENS}: misc.x: alias {color.brand.primary} is a color, not a dimension`]); + }); + + it("names each token of an alias cycle, and still checks the rest", () => { + addToken("a", { $value: "{misc.b}" }); + addToken("b", { $value: "{misc.a}" }); + assert.deepEqual(problems(), [`${TOKENS}: misc.a: alias {misc.b} loops back to misc.a`, `${TOKENS}: misc.b: alias {misc.a} loops back to misc.b`]); + }); + + it("ends the walk from a token that leads into a cycle it is not part of", () => { + addToken("a", { $value: "{misc.b}" }); + addToken("b", { $value: "{misc.a}" }); + addToken("c", { $value: "{misc.a}" }); + assert.deepEqual(problems(), [ + `${TOKENS}: misc.c: no $type on it, its alias or a parent group`, + `${TOKENS}: misc.a: alias {misc.b} loops back to misc.a`, + `${TOKENS}: misc.b: alias {misc.a} loops back to misc.b`, + ]); + }); + + it("names a broken alias once, not again for its missing type", () => { + addToken("x", { $value: "{color.missing}" }); + assert.deepEqual(problems(), [`${TOKENS}: misc.x: alias {color.missing} names no token`]); + }); + }); + + describe("T6 themes", () => { + /** Rewrites a JSON file of the project through `change`, which edits it in place. */ + const editJson = (file, change) => { + const full = path.join(tmp, file); + const value = JSON.parse(fs.readFileSync(full, "utf8")); + change(value); + fs.writeFileSync(full, JSON.stringify(value)); + }; + const contexts = (resolver) => resolver.modifiers.theme.contexts; + + it("names a missing resolver when system.md declares more than one theme", () => { + fs.rmSync(path.join(tmp, RESOLVER)); + assert.deepEqual(problems(), [`${RESOLVER}: missing`]); + }); + + it("needs no resolver for one theme", () => { + fs.rmSync(path.join(tmp, RESOLVER)); + edit(tmp, SYSTEM, "themes: [light, dark]", "themes: [light]"); + assert.deepEqual(problems(), []); + }); + + const resolverCases = [ + ["a version other than 2025.10", (r) => (r.version = "2024"), 'version must be "2025.10"'], + ["no set holding tokens.json", (r) => (r.sets.base.sources = []), "no set holds tokens.json"], + ["no theme modifier", (r) => delete r.modifiers.theme, 'no "theme" modifier with contexts'], + ["a resolution order that never applies the themes", (r) => r.resolutionOrder.pop(), "resolutionOrder never applies #/modifiers/theme"], + ["a default that is not the first theme", (r) => (r.modifiers.theme.default = "dark"), `the theme modifier's default must be "light", the first theme in system.md`], + ["a theme without its context", (r) => delete contexts(r).dark, 'no context for theme "dark"'], + ["a context system.md lacks", (r) => (contexts(r).sepia = []), 'context "sepia" is not a theme in system.md'], + ["a source that is no file reference", (r) => contexts(r).dark.push({ color: {} }), 'context "dark": each source is {"$ref": ""}'], + ]; + for (const [name, change, message] of resolverCases) { + it(`names ${name}`, () => { + editJson(RESOLVER, change); + assert.deepEqual(problems(), [`${RESOLVER}: ${message}`]); + }); + } + + it("names a theme file the resolver names but the project lacks", () => { + fs.rmSync(path.join(tmp, DARK)); + assert.deepEqual(problems(), [`${DARK}: missing`]); + }); + + it("names a themed token tokens.json lacks", () => { + editJson(DARK, (tree) => (tree.color.ghost = { $value: "{color.brand.primary}" })); + assert.deepEqual(problems(), [`${DARK}: color.ghost: theme "dark" redefines no token of tokens.json`]); + }); + + it("names a themed token whose type differs from its base", () => { + editJson(DARK, (tree) => (tree.color.brand.primary.$type = "dimension")); + assert.deepEqual(problems(), [`${DARK}: color.brand.primary: $type "dimension" differs from tokens.json's "color"`]); + }); + + it("names a theme value of the wrong shape", () => { + editJson(DARK, (tree) => (tree.color.brand.primary.$value.components = [0.4, 0.6])); + assert.deepEqual(problems(), [`${DARK}: color.brand.primary: theme "dark": components must be three numbers or "none"`]); + }); + + it("names a theme value aliasing a token of another type", () => { + editJson(DARK, (tree) => (tree.color.brand.primary.$value = "{space.md}")); + assert.deepEqual(problems(), [`${DARK}: color.brand.primary: theme "dark": alias {space.md} is a dimension, not a color`]); + }); + }); + + describe("T7 cited tokens", () => { + it("names a component token absent from tokens.json", () => { + edit(tmp, BUTTON, "tokens: [color.action.default,", "tokens: [space.huge, color.action.default,"); + assert.deepEqual(problems(), [`${BUTTON}: token "space.huge" is not in tokens.json`]); + }); + + it("names a screen token absent from tokens.json", () => { + edit(tmp, HOME, "tokens: [space.md,", "tokens: [space.huge, space.md,"); + assert.deepEqual(problems(), [`${HOME}: token "space.huge" is not in tokens.json`]); + }); + }); + + describe("raw CSS values", () => { + it("names a var() in a raw value that names no token, since it is a dependency", () => { + addToken("x", { $extensions: { "dev.ai-driven.css": "color-mix(in srgb, var(--color-brand-gone) 20%, white)" } }); + assert.deepEqual(problems(), [`${TOKENS}: misc.x: var(--color-brand-gone) names no token`]); + }); + + it("accepts a value DTCG cannot hold under its extension", () => { + addToken("x", { $extensions: { "dev.ai-driven.css": "color-mix(in srgb, var(--color-brand-primary) 20%, white)" } }); + assert.deepEqual(problems(), []); + }); + + it("names an empty raw value", () => { + addToken("x", { $extensions: { "dev.ai-driven.css": "" } }); + assert.deepEqual(problems(), [`${TOKENS}: misc.x: dev.ai-driven.css must be a CSS value`]); + }); + + it("names $root, which is not supported", () => { + addToken("x", { $root: { $type: "number", $value: 1 }, y: { $type: "number", $value: 2 } }); + assert.deepEqual(problems(), [`${TOKENS}: misc.x.$root: "$root" is not supported, name the token`]); + }); + }); + + describe("T8 semantic tokens", () => { + it("names a component citing a primitive token", () => { + edit(tmp, BUTTON, "tokens: [color.action.default,", "tokens: [color.brand.primary, color.action.default,"); + assert.deepEqual(problems(), [`${BUTTON}: token "color.brand.primary" is a primitive, use a semantic token`]); + }); + }); + + describe("V1 render present", () => { + it("names a component without its render", () => { + fs.rmSync(path.join(tmp, DIALOG_RENDER)); + assert.deepEqual(problems(), [`${DIALOG}: no render beside it, expected dialog.html`]); + }); + }); + + describe("V2 every variant and state rendered", () => { + it("names a declared variant the render lacks", () => { + edit(tmp, BUTTON_RENDER, 'data-variant="secondary"', 'data-variant="primary"'); + assert.deepEqual(problems(), [`${BUTTON_RENDER}: no data-variant="secondary"`]); + }); + + it("names a declared state the render lacks", () => { + edit(tmp, DIALOG_RENDER, 'data-state="hover"', 'data-state="default"'); + assert.deepEqual(problems(), [`${DIALOG_RENDER}: no data-state="hover"`]); + }); + }); + + describe("V3 token references", () => { + it("names a custom property no token defines", () => { + addToRender('

x

'); + assert.deepEqual(problems(), [`${BUTTON_RENDER}: line ${INSERTED_LINE}: var(--color-missing) names no token`]); + }); + + it("accepts a custom property the render sets itself", () => { + addToRender('

x

'); + assert.deepEqual(problems(), []); + }); + }); + + describe("V4 colour literals", () => { + for (const [literal, found] of [["#336699", "#336699"], ["rgb(0, 0, 0)", "rgb("], ["hsl(0, 0%, 0%)", "hsl("]]) { + it(`names ${found}`, () => { + addToRender(`

x

`); + assert.deepEqual(problems(), [`${BUTTON_RENDER}: line ${INSERTED_LINE}: colour literal "${found}", use a token`]); + }); + } + }); + + describe("V6 unit literals", () => { + for (const literal of ["16px", "1.5rem", "200ms"]) { + it(`names ${literal}`, () => { + addToRender(`

x

`); + assert.deepEqual(problems(), [`${BUTTON_RENDER}: line ${INSERTED_LINE}: literal "${literal}", only tokens carry values`]); + }); + } + }); + + describe("V6 var() fallbacks", () => { + it("leaves a length inside var() alone: the fallback belongs to the token", () => { + addToRender('

x

'); + assert.deepEqual(problems(), []); + }); + }); + + describe("V6 media and container queries", () => { + it("leaves the lengths of @media and @container preludes alone", () => { + addToRender(""); + addToRender(""); + assert.deepEqual(problems(), []); + }); + }); + + describe("V7 declared tokens", () => { + it("names a render using a token its record does not declare", () => { + edit(tmp, `${UI}/components/dialog.html`, "", '

x

\n'); + assert.deepEqual(problems(), [`${DIALOG_RENDER}: line 17: var(--color-text-default) is a token dialog does not declare`]); + }); + }); + + describe("V5 logical properties", () => { + it("names a physical property when rtl is declared", () => { + addToRender('

x

'); + assert.deepEqual(problems(), [`${BUTTON_RENDER}: line ${INSERTED_LINE}: physical property "margin-left", use its logical form`]); + }); + + it("allows it when only ltr is declared", () => { + edit(tmp, SYSTEM, "directions: [ltr, rtl]", "directions: [ltr]"); + addToRender('

x

'); + assert.deepEqual(problems(), []); + }); + }); +}); diff --git a/scripts/__tests__/aidd-ui-validate.test.js b/scripts/__tests__/aidd-ui-validate.test.js new file mode 100644 index 000000000..57bf0e33c --- /dev/null +++ b/scripts/__tests__/aidd-ui-validate.test.js @@ -0,0 +1,376 @@ +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +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"); + +// The entries are scripts a user's machine runs, so each is driven as a subprocess with its +// argument or its hook payload on stdin, which is what ships. +const HOOKS = path.resolve(__dirname, "../../plugins/aidd-ui/hooks"); +const ENGINE = path.join(HOOKS, "aidd-ui.cjs"); +const HOOK = path.join(HOOKS, "ui-hook.cjs"); +const { problemsOf, format } = require(ENGINE); +const { load } = require(path.join(HOOKS, "lib/project.cjs")); + +const BRAND = `${UI}/brand.md`; +const DIALOG = `${UI}/components/dialog.md`; +const TOKENS = `${UI}/tokens.json`; + +/** One mutation from each family: records, tokens, code. */ +const FAILURES = [ + `✗ ${BRAND}: lacks "## Users" (R14)`, + `✗ ${TOKENS}: space.md: unit "em" must be px or rem (T4)`, + `✗ ${DIALOG}: package "left-pad" is not in system.md libraries (C2)`, +]; +const [BRAND_FAILURE, , DIALOG_FAILURE] = FAILURES; + +let tmp; +let sid; +let sessionCount = 0; + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "aidd-ui-validate-")); + writeProject(tmp); + sid = `t${process.pid}-${sessionCount++}`; +}); + +afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + fs.rmSync(path.join(os.tmpdir(), `aidd-ui-turn-${sid}`), { force: true }); + assert.equal(fs.existsSync(tmp), false); +}); + +function breakOnePerFamily() { + edit(tmp, BRAND, "## Users\n\nAnalysts.\n\n", ""); + editTokens(tmp, (tree) => (tree.space.md.$value = { value: 1, unit: "em" })); + edit(tmp, DIALOG, "package:@radix-ui/react-dialog#Root", "package:left-pad#Pad"); +} + +const lines = (text) => text.split("\n").filter(Boolean); + +function validate(root) { + const run = spawnSync(process.execPath, [ENGINE, "validate", root], { encoding: "utf8" }); + return { code: run.status, out: lines(run.stdout), err: run.stderr }; +} + +function hook(input) { + const run = spawnSync(process.execPath, [HOOK], { input, encoding: "utf8" }); + return { code: run.status, out: run.stdout, err: run.stderr }; +} + +// Claude Code and Codex carry tool_response, Copilot tool_result: that is how the hook tells them apart. +const postToolUse = (filePath) => JSON.stringify({ hook_event_name: "PostToolUse", cwd: tmp, session_id: sid, tool_name: "Write", tool_input: { file_path: filePath }, tool_response: {} }); +/** A patch as Codex's apply_patch and Copilot's Edit write it: one header line per file. */ +const patch = (...headers) => `*** Begin Patch\n${headers.map((header) => `*** ${header}\n@@\n+x\n`).join("")}*** End Patch`; +const codexPatch = (...headers) => JSON.stringify({ hook_event_name: "PostToolUse", cwd: tmp, session_id: sid, tool_name: "apply_patch", tool_input: { command: patch(...headers) }, tool_response: {} }); +const copilotPatch = (...headers) => JSON.stringify({ hook_event_name: "PostToolUse", cwd: tmp, session_id: sid, tool_name: "Edit", tool_input: patch(...headers), tool_result: {} }); +const sessionStart = () => JSON.stringify({ hook_event_name: "SessionStart", cwd: tmp, source: "startup" }); +const stop = (active = false) => JSON.stringify({ hook_event_name: "Stop", cwd: tmp, session_id: sid, stop_hook_active: active }); +const silent = { code: 0, out: "", err: "" }; +/** The problems of the files a write reached, then the count of errors it did not reach. */ +const feedbackText = (problems, elsewhere) => + ["aidd-ui: the write was saved, fix these problems:", ...problems, ...(elsewhere ? [`${elsewhere} more errors elsewhere in the design system`] : [])].join("\n"); +// Claude Code and Codex read additionalContext under hookSpecificOutput; Codex fails the hook on any other key. +const claudeFeedback = (problems, elsewhere = 0) => ({ code: 0, out: JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: feedbackText(problems, elsewhere) } }), err: "" }); +// Copilot reads only a top-level additionalContext. +const copilotFeedback = (problems, elsewhere = 0) => ({ code: 0, out: JSON.stringify({ additionalContext: feedbackText(problems, elsewhere) }), err: "" }); +const finish = (errors) => ({ code: 0, out: JSON.stringify({ decision: "block", reason: [`aidd-ui: the design system has ${errors.length} errors, fix them before you finish:`, ...errors].join("\n") }), err: "" }); + +// The plugin's own declaration: every host builds its hooks from this file, so a missing event +// here means no install runs that check. +describe("aidd-ui hooks declaration", () => { + const declared = JSON.parse(fs.readFileSync(path.join(HOOKS, "hooks.json"), "utf8")).hooks; + + it("runs the hook at every moment it handles: session start, after a write, turn end", () => { + for (const event of ["SessionStart", "PostToolUse", "Stop"]) { + const commands = declared[event].flatMap((group) => group.hooks.map((hook) => hook.command)); + assert.deepEqual(commands, [`node \${CLAUDE_PLUGIN_ROOT}/hooks/ui-hook.cjs`]); + } + }); +}); + +describe("aidd-ui validator entries", () => { + it("prints VALID for the valid project", () => { + assert.deepEqual(validate(tmp), { code: 0, out: ["VALID"], err: "" }); + }); + + describe("E8 warnings", () => { + const ORPHAN = "⚠ src/ui/Orphan.tsx: no record links it and no ignore covers it (C3)"; + const orphan = () => fs.writeFileSync(path.join(tmp, "src/ui/Orphan.tsx"), "export function Orphan() {}\n"); + + it("prints a warning and exits 0, since only errors fail the check", () => { + orphan(); + assert.deepEqual(validate(tmp), { code: 0, out: [ORPHAN], err: "" }); + }); + + it("counts warnings at session start without listing them", () => { + orphan(); + assert.deepEqual(hook(sessionStart()), { code: 0, out: "aidd-ui: 0 errors, 1 warnings\n", err: "" }); + }); + + it("never blocks the end of a turn on warnings", () => { + orphan(); + hook(postToolUse(path.join(tmp, "src/ui/Orphan.tsx"))); + assert.deepEqual(hook(stop()), silent); + }); + + it("warns on a hairline width a render keeps literal, and fails on a wider one", () => { + edit(tmp, `${UI}/components/button.html`, "", '

x

\n'); + assert.deepEqual(validate(tmp), { + code: 1, + out: [ + `⚠ ${UI}/components/button.html: line 27: literal "1px", only tokens carry values (V6)`, + `✗ ${UI}/components/button.html: line 27: literal "3px", only tokens carry values (V6)`, + ], + err: "", + }); + }); + }); + + describe("E9 commands", () => { + it("names the commands and exits 2 on one it does not know", () => { + const run = spawnSync(process.execPath, [ENGINE, "valdate", tmp], { encoding: "utf8" }); + assert.deepEqual({ code: run.status, out: run.stdout, err: run.stderr }, { code: 2, out: "", err: "usage: node aidd-ui.cjs [project-root]\n" }); + }); + }); + + describe("E1 no design system", () => { + it("prints nothing without aidd_docs/ui/", () => { + fs.rmSync(path.join(tmp, UI), { recursive: true }); + assert.deepEqual(validate(tmp), { code: 0, out: [], err: "" }); + }); + }); + + describe("E2 every check runs", () => { + it("prints one line per broken rule, from each family, and exits 1", () => { + breakOnePerFamily(); + assert.deepEqual(validate(tmp), { code: 1, out: FAILURES, err: "" }); + }); + + it("prints one line, never an avalanche, for a design system written in another format", () => { + breakOnePerFamily(); + edit(tmp, `${UI}/system.md`, "format: 1", "format: 2"); + assert.deepEqual(validate(tmp), { code: 1, out: [`✗ ${UI}/system.md: format "2" is not 1, the one this validator reads`], err: "" }); + }); + + it("names a check that throws and still runs the others", () => { + breakOnePerFamily(); + const broken = { check: () => { throw new Error("boom"); } }; + const found = problemsOf(load(tmp), { broken, records: require(path.join(HOOKS, "lib/records.cjs")) }); + assert.deepEqual(format(found), [`✗ ${UI}: check "broken" failed: boom`, FAILURES[0]]); + }); + }); + + describe("E3 PostToolUse", () => { + it("hands the problems back when a write lands under aidd_docs/ui/", () => { + breakOnePerFamily(); + assert.deepEqual(hook(postToolUse(path.join(tmp, BRAND))), claudeFeedback([BRAND_FAILURE], 2)); + }); + + it("hands the problems back when a write lands under a code root", () => { + breakOnePerFamily(); + assert.deepEqual(hook(postToolUse(path.join(tmp, "src/ui/Button.tsx"))), claudeFeedback([DIALOG_FAILURE], 2)); + }); + + it("hands the problems back when a write lands under a code root written with a leading ./", () => { + edit(tmp, `${UI}/system.md`, "code-roots: [src/ui]", "code-roots: [./src/ui]"); + breakOnePerFamily(); + assert.deepEqual(hook(postToolUse(path.join(tmp, "src/ui/Button.tsx"))), claudeFeedback([DIALOG_FAILURE], 2)); + }); + + it("hands the problems back when a Codex patch updates a file under aidd_docs/ui/", () => { + breakOnePerFamily(); + assert.deepEqual(hook(codexPatch(`Update File: ${path.join(tmp, BRAND)}`)), claudeFeedback([BRAND_FAILURE], 2)); + }); + + it("hands the problems back when a Copilot patch adds a file under aidd_docs/ui/ after one elsewhere", () => { + breakOnePerFamily(); + assert.deepEqual(hook(copilotPatch(`Add File: ${path.join(tmp, "README.md")}`, `Add File: ${path.join(tmp, BRAND)}`)), copilotFeedback([BRAND_FAILURE], 2)); + }); + + it("hands the problems back when Copilot's Edit replaces a string in a file under aidd_docs/ui/", () => { + breakOnePerFamily(); + const input = JSON.stringify({ hook_event_name: "PostToolUse", cwd: tmp, tool_name: "Edit", tool_input: { path: path.join(tmp, BRAND), old_str: "a", new_str: "b" }, tool_result: {} }); + assert.deepEqual(hook(input), copilotFeedback([BRAND_FAILURE], 2)); + }); + + it("stays silent when a patch touches only files elsewhere", () => { + breakOnePerFamily(); + assert.deepEqual(hook(codexPatch(`Update File: ${path.join(tmp, "README.md")}`)), silent); + }); + + it("names the problem a write causes in a record citing the written one", () => { + edit(tmp, `${UI}/components/button.md`, "kind: atom", "kind: composition"); + assert.deepEqual(hook(postToolUse(path.join(tmp, `${UI}/components/button.md`))), claudeFeedback([`✗ ${UI}/components/search-field.md: molecule cannot use composition "button" (R6)`])); + }); + + it("names the problems of a written record's render", () => { + edit(tmp, `${UI}/components/button.md`, "variants: [primary, secondary]", "variants: [primary, secondary, ghost]"); + assert.deepEqual(hook(postToolUse(path.join(tmp, `${UI}/components/button.md`))), claudeFeedback([`✗ ${UI}/components/button.html: no data-variant="ghost" (V2)`])); + }); + + it("names every problem after a write to a file every record reads", () => { + breakOnePerFamily(); + assert.deepEqual(hook(postToolUse(path.join(tmp, TOKENS))), claudeFeedback(FAILURES)); + }); + + it("stays silent when the written files reach no problem, which Stop names", () => { + breakOnePerFamily(); + assert.deepEqual(hook(postToolUse(path.join(tmp, `${UI}/journeys/onboarding.md`))), silent); + }); + + it("prints at most 20 problem lines, then how many are left and how to see them", () => { + const render = `${UI}/components/button.html`; + edit(tmp, render, "", `${'

x

\n'.repeat(25)}`); + const shown = Array.from({ length: 20 }, (_, index) => `✗ ${render}: line ${27 + index}: colour literal "#000000", use a token (V4)`); + assert.deepEqual(hook(postToolUse(path.join(tmp, render))), claudeFeedback([...shown, `
 and 5 more: node ${ENGINE} validate ${tmp}`])); + }); + + it("stays silent when a write lands elsewhere", () => { + breakOnePerFamily(); + assert.deepEqual(hook(postToolUse(path.join(tmp, "README.md"))), silent); + }); + + it("stays silent on a valid project", () => { + assert.deepEqual(hook(postToolUse(path.join(tmp, BRAND))), silent); + }); + }); + + describe("E7 Stop", () => { + it("blocks once after a write under aidd_docs/ui/, with the problems as the reason", () => { + breakOnePerFamily(); + hook(postToolUse(path.join(tmp, BRAND))); + assert.deepEqual(hook(stop()), finish(FAILURES)); + }); + + it("lets the turn end on the repeated Stop, the one it asked for", () => { + breakOnePerFamily(); + hook(postToolUse(path.join(tmp, BRAND))); + hook(stop()); + assert.deepEqual(hook(stop(true)), silent); + }); + + it("ends the forced turn even when that turn writes a UI file again", () => { + breakOnePerFamily(); + hook(postToolUse(path.join(tmp, BRAND))); + hook(stop()); + hook(postToolUse(path.join(tmp, BRAND))); + assert.deepEqual(hook(stop(true)), silent); + }); + + it("hands the problems back on a second write in the same turn", () => { + breakOnePerFamily(); + hook(postToolUse(path.join(tmp, BRAND))); + assert.deepEqual(hook(postToolUse(path.join(tmp, BRAND))), claudeFeedback([BRAND_FAILURE], 2)); + }); + + it("still hands the problems back when the turn mark cannot be written", () => { + breakOnePerFamily(); + const run = spawnSync(process.execPath, [HOOK], { input: postToolUse(path.join(tmp, BRAND)), encoding: "utf8", env: { ...process.env, TMPDIR: path.join(tmp, "no-such-dir"), TMP: path.join(tmp, "no-such-dir"), TEMP: path.join(tmp, "no-such-dir") } }); + assert.deepEqual({ code: run.status, out: run.stdout, err: run.stderr }, claudeFeedback([BRAND_FAILURE], 2)); + }); + + it("never writes through a link planted at the mark's path", () => { + const target = path.join(tmp, "victim.txt"); + fs.writeFileSync(target, "keep"); + fs.symlinkSync(target, path.join(os.tmpdir(), `aidd-ui-turn-${sid}`)); + hook(postToolUse(path.join(tmp, BRAND))); + assert.equal(fs.readFileSync(target, "utf8"), "keep"); + }); + + it("consumes the turn mark: a later Stop without a new write is silent", () => { + breakOnePerFamily(); + hook(postToolUse(path.join(tmp, BRAND))); + hook(stop()); + assert.deepEqual(hook(stop()), silent); + }); + + it("stays silent at Stop when no UI file was written this turn", () => { + breakOnePerFamily(); + assert.deepEqual(hook(stop()), silent); + }); + + it("stays silent at Stop when the write landed elsewhere", () => { + breakOnePerFamily(); + hook(postToolUse(path.join(tmp, "README.md"))); + assert.deepEqual(hook(stop()), silent); + }); + + it("stays silent at Stop once the problems are fixed", () => { + breakOnePerFamily(); + hook(postToolUse(path.join(tmp, BRAND))); + writeProject(tmp); + assert.deepEqual(hook(stop()), silent); + }); + + it("still hands the problems back when the session id is not a file-name part", () => { + breakOnePerFamily(); + const input = JSON.stringify({ hook_event_name: "PostToolUse", cwd: tmp, session_id: "a/b", tool_name: "Write", tool_input: { file_path: path.join(tmp, BRAND) }, tool_response: {} }); + assert.deepEqual(hook(input), claudeFeedback([BRAND_FAILURE], 2)); + }); + + it("stays silent without a session id, since it cannot tell a turn apart", () => { + breakOnePerFamily(); + hook(JSON.stringify({ hook_event_name: "PostToolUse", cwd: tmp, tool_name: "Write", tool_input: { file_path: path.join(tmp, BRAND) }, tool_response: {} })); + assert.deepEqual(hook(JSON.stringify({ hook_event_name: "Stop", cwd: tmp })), silent); + }); + }); + + describe("E4 SessionStart", () => { + it("prints the counts then the errors, and exits 0", () => { + breakOnePerFamily(); + assert.deepEqual(hook(sessionStart()), { code: 0, out: `aidd-ui: 3 errors, 0 warnings\n${FAILURES.join("\n")}\n`, err: "" }); + }); + + it("stays silent without aidd_docs/ui/", () => { + fs.rmSync(path.join(tmp, UI), { recursive: true }); + assert.deepEqual(hook(sessionStart()), silent); + }); + }); + + describe("E5 anything else is silent", () => { + it("ignores a PostToolUse payload without tool_input.file_path, as a host without matcher sends", () => { + breakOnePerFamily(); + assert.deepEqual(hook(JSON.stringify({ hook_event_name: "PostToolUse", cwd: tmp, tool_input: {} })), silent); + }); + + for (const [name, input] of [["empty", ""], ["non-JSON", "not json"]]) { + it(`ignores ${name} stdin`, () => { + assert.deepEqual(hook(input), silent); + }); + } + }); + + describe("E6 unreadable files", { skip: process.platform === "win32" }, () => { + const DANGLING = `${UI}/dangling.md`; + const UNREADABLE = `✗ ${DANGLING}: unreadable`; + + it("names an unreadable record and checks the rest, in every entry", () => { + fs.symlinkSync(path.join(tmp, "nowhere.md"), path.join(tmp, DANGLING)); + assert.deepEqual(validate(tmp), { code: 1, out: [UNREADABLE], err: "" }); + assert.deepEqual(hook(sessionStart()), { code: 0, out: `aidd-ui: 1 errors, 0 warnings\n${UNREADABLE}\n`, err: "" }); + assert.deepEqual(hook(postToolUse(path.join(tmp, DANGLING))), claudeFeedback([UNREADABLE])); + }); + + it("names an unreadable file under a code root", () => { + fs.symlinkSync(path.join(tmp, "src/ui"), path.join(tmp, "src/ui/Folder.css")); + assert.deepEqual(validate(tmp), { + code: 1, + out: ["⚠ src/ui/Folder.css: no record links it and no ignore covers it (C3)", "✗ src/ui/Folder.css: unreadable"], + err: "", + }); + }); + + it("names an unreadable folder of the design system instead of crashing", { skip: process.getuid?.() === 0 }, () => { + const folder = path.join(tmp, UI, "journeys"); + fs.chmodSync(folder, 0o000); + try { + assert.deepEqual(validate(tmp), { code: 1, out: [`✗ ${UI}/journeys: unreadable`], err: "" }); + } finally { + fs.chmodSync(folder, 0o755); + } + }); + }); +}); diff --git a/scripts/__tests__/fixtures/README.md b/scripts/__tests__/fixtures/README.md index 9b0e42516..7b81906b1 100644 --- a/scripts/__tests__/fixtures/README.md +++ b/scripts/__tests__/fixtures/README.md @@ -1,5 +1,7 @@ # Fixtures +- `aidd-ui/project.js`: one complete, valid aidd-ui project, records, tokens, renders and code, + written and mutated by the `aidd-ui-*.test.js` validator tests. - `architecture-rules/`: skill, action and reference trees the architecture-rule tests read. - `opencode-session-idle.json`, `opencode-tool-part-completed.json`: captured opencode plugin events, read by the CLI's `opencode-hooks-bridge-mapping.integration.test.ts`. diff --git a/scripts/__tests__/fixtures/aidd-ui/project.js b/scripts/__tests__/fixtures/aidd-ui/project.js new file mode 100644 index 000000000..3b10b730d --- /dev/null +++ b/scripts/__tests__/fixtures/aidd-ui/project.js @@ -0,0 +1,302 @@ +const fs = require("node:fs"); +const path = require("node:path"); + +// One complete, valid aidd-ui project: every record kind, tokens, renders, and the code they +// link to. Each validator test writes it, breaks one thing, and expects exactly one problem. + +const UI = "aidd_docs/ui"; + +const history = (...rows) => + ["## History", "", "| Version | Change | Breaking | Task |", "| --- | --- | --- | --- |", ...rows.map((row) => `| ${row.join(" | ")} |`)].join("\n"); + +const FIRST_ROW = ["1", "created", "no", "aidd_docs/tasks/ds-init"]; + +const record = (frontMatter, ...sections) => `---\n${frontMatter.join("\n")}\n---\n\n${sections.join("\n\n")}\n`; + +const KEYBOARD = "## Keyboard\n\nTab focuses it, Enter or Space activates it."; + +const usage = (when) => `## Usage\n\n${when}`; + +const BUTTON_TOKENS = ["color.action.default", "color.text.default", "space.md", "border.thin", "motion.fast", "font.body", "font.weight.bold"]; + +const SYSTEM = record( + [ + "id: system", + "kind: system", + "status: validated", + "version: 2.0.0", + "format: 1", + "platforms: [web]", + "breakpoints: [mobile, desktop]", + "themes: [light, dark]", + "locales: [fr, ar]", + "directions: [ltr, rtl]", + "primitives: [color.brand]", + "code-roots: [src/ui]", + "code-tokens: src/ui/tokens.css", + "ignore: [src/ui/vendor]", + "libraries: [@radix-ui/react-dialog]", + ], + "# Design system", + "## Breakpoints\n\n| Name | From |\n| --- | --- |\n| mobile | 0 |\n| desktop | 64rem |", + history(["1.0.0", "first system", "no", "aidd_docs/tasks/ds-init"], ["2.0.0", "old-button retired", "yes", "aidd_docs/tasks/retire-old-button"]) +); + +const BRAND = record( + ["id: brand", "kind: brand", "status: validated", "version: 1"], + "# Brand", + "## Project\n\nA search tool.", + "## Users\n\nAnalysts.", + "## Domain\n\nPublic records.", + "## Platforms\n\nWeb.", + "## Personality and tone\n\nPlain and calm.", + "## Imposed charter\n\nNone.", + "## References\n\nA library catalogue.", + "## Anti-references\n\nA casino.", + history(FIRST_ROW) +); + +const TOKENS = { + color: { + $type: "color", + brand: { + primary: { + $value: { colorSpace: "srgb", components: [0.2, 0.4, 0.6], hex: "#336699" }, + }, + }, + text: { default: { $value: "{color.brand.primary}" } }, + action: { default: { $value: "{color.brand.primary}" } }, + }, + space: { md: { $type: "dimension", $value: { value: 1, unit: "rem" } } }, + border: { thin: { $type: "dimension", $value: { value: 0.0625, unit: "rem" } } }, + motion: { fast: { $type: "duration", $value: { value: 150, unit: "ms" } } }, + font: { + body: { $type: "fontFamily", $value: ["Inter", "sans-serif"] }, + weight: { bold: { $type: "fontWeight", $value: 700 } }, + }, +}; + +// The dark theme redefines one base token; the Resolver names its file. +const DARK = { color: { brand: { primary: { $value: { colorSpace: "srgb", components: [0.4, 0.6, 0.8], hex: "#6699cc" } } } } }; + +const RESOLVER = { + version: "2025.10", + sets: { base: { sources: [{ $ref: "tokens.json" }] } }, + modifiers: { theme: { contexts: { light: [], dark: [{ $ref: "themes/dark.json" }] }, default: "light" } }, + resolutionOrder: [{ $ref: "#/sets/base" }, { $ref: "#/modifiers/theme" }], +}; + +const ICON = record( + ["id: icon-search", "kind: asset", "status: validated", "version: 1", "type: icon", "file: icon-search.svg", "alt: Search"], + "# Search icon", + history(FIRST_ROW) +); + +const ICON_FILE = '\n'; + +const BUTTON = record( + [ + "id: button", + "kind: atom", + "status: validated", + "version: 1", + "implemented-version: 1", + "uses: []", + `tokens: [${BUTTON_TOKENS.join(", ")}]`, + "variants: [primary, secondary]", + "states: [default, hover, focus, disabled]", + "interactive: yes", + "role: button", + "code: src/ui/Button.tsx#Button", + ], + "# Button", + usage("Any action a person starts; never for navigation."), + KEYBOARD, + history(FIRST_ROW) +); + +const SEARCH_FIELD = record( + [ + "id: search-field", + "kind: molecule", + "status: validated", + "version: 1", + "uses: [button, icon-search]", + "tokens: [space.md, color.text.default]", + "variants: [default]", + "states: [default, hover, focus, disabled]", + "interactive: yes", + "role: search", + ], + "# Search field", + usage("Searching a list; never for a form field."), + KEYBOARD, + history(FIRST_ROW) +); + +const DIALOG = record( + [ + "id: dialog", + "kind: composition", + "status: validated", + "version: 1", + "implemented-version: 1", + "uses: [button]", + "tokens: [space.md]", + "variants: [default]", + "states: [default, hover, focus, disabled]", + "interactive: yes", + "role: dialog", + "code: package:@radix-ui/react-dialog#Root", + ], + "# Dialog", + usage("A decision that blocks the page."), + "## Keyboard\n\nEscape closes it; focus stays inside while it is open.", + history(FIRST_ROW) +); + +const OLD_BUTTON = record( + [ + "id: old-button", + "kind: atom", + "status: deprecated", + "version: 2", + "uses: []", + "tokens: [color.action.default]", + "variants: [default]", + "states: [default]", + "interactive: no", + "role: button", + "replaced-by: button", + ], + "# Old button", + usage("Nowhere: replaced by button."), + history(FIRST_ROW, ["2", "deprecated for button", "yes", "aidd_docs/tasks/retire-old-button"]) +); + +const HOME = record( + [ + "id: home", + "kind: screen", + "status: validated", + "version: 1", + "implemented-version: 1", + "uses: [search-field, dialog]", + "tokens: [space.md, color.text.default]", + "states: [default, empty, loading, error]", + "breakpoints: [mobile, desktop]", + "route: /", + "code: src/ui/HomePage.tsx#HomePage", + ], + "# Home", + history(FIRST_ROW) +); + +const ONBOARDING = record( + ["id: onboarding", "kind: journey", "status: validated", "version: 1", "persona: a first-time analyst"], + "# Onboarding", + "## Steps\n\n| Screen | Action |\n| --- | --- |\n| home | types a query |\n| home | opens a result |", + history(FIRST_ROW) +); + +const REVIEW = record( + ["id: first-pass", "kind: review", "status: proposed", "version: 1", "scope: system", "screens: [home]"], + "# First pass", + [ + "## Verdicts", + "", + "| Screen | State | Version | Verdict | Comment |", + "| --- | --- | --- | --- | --- |", + "| home | default | 1 | ok | |", + "| home | empty | 1 | ok | |", + "| home | loading | 1 | ko | spinner too small |", + "| home | error | 1 | pending | |", + ].join("\n"), + history(FIRST_ROW) +); + +/** A render's style: one custom property per token its record declares, so it uses them all + * and nothing else. */ +const style = (tokens) => [".ui {", ...tokens.map((token, index) => ` --use-${index}: var(--${token.replaceAll(".", "-")});`), "}"].join("\n"); + +const render = (title, tokens, body) => + `\n\n\n\n${title}\n\n\n\n${body.join("\n")}\n\n\n`; + +const statesOf = (variant, states) => + states.map((state) => ``); + +const RENDERS = { + "components/button.html": render("button", BUTTON_TOKENS, [ + ...statesOf("primary", ["default", "hover", "focus", "disabled"]), + ...statesOf("secondary", ["default", "hover", "focus", "disabled"]), + ]), + "components/search-field.html": render("search-field", ["space.md", "color.text.default"], statesOf("default", ["default", "hover", "focus", "disabled"])), + "components/dialog.html": render("dialog", ["space.md"], statesOf("default", ["default", "hover", "focus", "disabled"])), + "components/old-button.html": render("old-button", ["color.action.default"], statesOf("default", ["default"])), + "screens/home.html": render( + "home", + ["space.md", "color.text.default"], + ["default", "empty", "loading", "error"].map((state) => `
Home, ${state}
`) + ), +}; + +const CODE = { + "src/ui/Button.tsx": 'export function Button() {\n return ;\n}\n', + "src/ui/HomePage.tsx": 'import { Button } from "./Button";\n\nexport function HomePage() {\n return
;\n}\n', + "src/ui/tokens.css": ":root {\n --color-brand-primary: #336699;\n --space-md: 1rem;\n}\n", + "src/ui/vendor/reset.css": "body { margin: 0; color: #000000; }\n", +}; + +const FILES = { + [`${UI}/system.md`]: SYSTEM, + [`${UI}/brand.md`]: BRAND, + [`${UI}/tokens.json`]: `${JSON.stringify(TOKENS, null, 2)}\n`, + [`${UI}/tokens.resolver.json`]: `${JSON.stringify(RESOLVER, null, 2)}\n`, + [`${UI}/themes/dark.json`]: `${JSON.stringify(DARK, null, 2)}\n`, + [`${UI}/assets/icon-search.md`]: ICON, + [`${UI}/assets/icon-search.svg`]: ICON_FILE, + [`${UI}/components/button.md`]: BUTTON, + [`${UI}/components/search-field.md`]: SEARCH_FIELD, + [`${UI}/components/dialog.md`]: DIALOG, + [`${UI}/components/old-button.md`]: OLD_BUTTON, + [`${UI}/screens/home.md`]: HOME, + [`${UI}/journeys/onboarding.md`]: ONBOARDING, + [`${UI}/reviews/first-pass.md`]: REVIEW, + ...Object.fromEntries(Object.entries(RENDERS).map(([name, html]) => [`${UI}/${name}`, html])), + ...CODE, +}; + +/** Writes the valid project under `dir`. */ +function writeProject(dir) { + for (const [name, content] of Object.entries(FILES)) { + const file = path.join(dir, name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); + } +} + +/** Replaces every `from` with `to` in one file of the project, failing when `from` is not + * there: an edit whose anchor drifted must never pass as a mutation. */ +function edit(dir, name, from, to) { + const file = path.join(dir, name); + const text = fs.readFileSync(file, "utf8"); + if (!text.includes(from)) throw new Error(`${name}: "${from}" not found, the mutation applies to nothing`); + fs.writeFileSync(file, text.split(from).join(to)); +} + +/** Rewrites `tokens.json` through `change`, which edits the parsed tree in place. */ +function editTokens(dir, change) { + const file = path.join(dir, UI, "tokens.json"); + const tree = JSON.parse(fs.readFileSync(file, "utf8")); + change(tree); + fs.writeFileSync(file, `${JSON.stringify(tree, null, 2)}\n`); +} + +/** Writes one file of the project. */ +function put(dir, name, content) { + const file = path.join(dir, name); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); +} + +module.exports = { UI, writeProject, edit, editTokens, put, record, history };