From fca1cebdc03f5a6b48b46dd947a108baf3e954eb Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Fri, 30 Oct 2026 12:15:02 +0100 Subject: [PATCH 01/26] docs(aidd-ui): frame the ui plugin rework Co-Authored-By: Claude Opus 5.5 --- .../2026_10_30_aidd-ui-rework/brainstorm.md | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-rework/brainstorm.md 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. From 67b659c55665dcecb1a9ee22794aba45f6183492 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Fri, 30 Oct 2026 14:39:03 +0100 Subject: [PATCH 02/26] docs(aidd-ui): specify the ui plugin rework and its roadmap Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XeK3mD4irAZumku9bknHhD --- .../2026_10_30_aidd-ui-rework/roadmap.md | 33 ++++ .../2026_10/2026_10_30_aidd-ui-rework/spec.md | 147 ++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-rework/roadmap.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-rework/spec.md 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..4a1410d59 --- /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. Chaque skill porte ses propres scripts de génération : `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é ; Kilo et opencode perdent sa sortie ; Cursor et Copilot le lancent sur chaque outil. 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..8a39fb546 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-rework/spec.md @@ -0,0 +1,147 @@ +# 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, format W3C DTCG 2025.10 +├── tokens.css généré depuis tokens.json, 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 design system, plateformes, breakpoints, thèmes, langues et sens d'écriture, dossiers UI du code, chemins ignorés, librairies de composants utilisées | +| `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 | +| asset | type (icône, image, illustration), fichier, texte alternatif attendu | +| atome, molécule, composition | éléments utilisés, tokens utilisés, variantes, états (au moins défaut, survol, focus, désactivé, et chargement ou erreur quand ils existent), comportement clavier et rôle accessible, lien vers le code | +| écran | éléments 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 et par état, commentaire, écrans restants | + +### Cycle de vie + +- `proposé` : créé, pas encore validé. +- `validé` : rendu approuvé en review. +- `codé` : un lien vers le code existe et le validateur le confirme. +- `déprécié` : ne doit plus être utilisé, nomme son remplaçant ou dit pourquoi il n'en a pas. +- Le lien vers le code est obligatoire à `codé`, facultatif avant. +- Modifier un élément `codé` le repasse à `proposé` le temps de la review ; il redevient `codé` quand le code suit sa nouvelle version. +- 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 de code, identifié par son chemin et le nom du composant exporté. +- 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. +- 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` : un changement cassant monte la majeure, un autre la mineure. +- 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. + +### 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. +- 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 : OK ou pas OK par écran et par état, avec un commentaire. +- 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, tailles en `rem`, 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 ni dans le code ; + - versions en double dans un historique ; + - propriétés physiques quand un sens droite-gauche est déclaré. +- 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, 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. +- Un composant réparti sur plusieurs fichiers. +- 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é ? + +## Context + +- Brief approuvé : [brainstorm.md](./brainstorm.md). +- Découpage en pull requests : [roadmap.md](./roadmap.md). From 9db5c887bc58093237dd16c9aa5fb6707b2a36d6 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Fri, 30 Oct 2026 14:50:05 +0100 Subject: [PATCH 03/26] docs(aidd-ui): plan the ui foundation validator Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XeK3mD4irAZumku9bknHhD --- .../2026_10_30_aidd-ui-foundation/phase-1.md | 122 ++++++++++++++++++ .../2026_10_30_aidd-ui-foundation/phase-2.md | 77 +++++++++++ .../2026_10_30_aidd-ui-foundation/phase-3.md | 64 +++++++++ .../2026_10_30_aidd-ui-foundation/phase-4.md | 88 +++++++++++++ .../2026_10_30_aidd-ui-foundation/plan.md | 56 ++++++++ 5 files changed, 407 insertions(+) create mode 100644 aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/phase-1.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/phase-2.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/phase-3.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/phase-4.md create mode 100644 aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/plan.md 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..c760e59ce --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/phase-1.md @@ -0,0 +1,122 @@ +--- +status: pending +--- + +# 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..4fb53fa02 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/phase-2.md @@ -0,0 +1,77 @@ +--- +status: pending +--- + +# 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..ba863f296 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/phase-3.md @@ -0,0 +1,64 @@ +--- +status: pending +--- + +# 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..b35d036fc --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/phase-4.md @@ -0,0 +1,88 @@ +--- +status: pending +--- + +# 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/plan.md b/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/plan.md new file mode 100644 index 000000000..c57f7a0da --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/plan.md @@ -0,0 +1,56 @@ +--- +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: pending +--- + +# 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. + +## 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 | + +## 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 | From 4c350902c0ed231bdfdd6de4df71799d9c5c6a37 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Fri, 30 Oct 2026 15:15:59 +0100 Subject: [PATCH 04/26] feat(aidd-ui): validate ui records, tokens, renders and code links on every write Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XeK3mD4irAZumku9bknHhD --- README.md | 2 +- .../2026_10_30_aidd-ui-foundation/phase-1.md | 2 +- .../2026_10_30_aidd-ui-foundation/phase-2.md | 2 +- .../2026_10_30_aidd-ui-foundation/phase-3.md | 2 +- .../2026_10_30_aidd-ui-foundation/phase-4.md | 2 +- .../2026_10_30_aidd-ui-foundation/plan.md | 2 +- .../2026_10_30_aidd-ui-foundation/run.md | 27 ++ docs/CATALOG.md | 11 + plugins/aidd-ui/CATALOG.md | 7 + plugins/aidd-ui/README.md | 61 +++- plugins/aidd-ui/hooks/hooks.json | 27 ++ plugins/aidd-ui/hooks/lib/code.cjs | 114 ++++++ plugins/aidd-ui/hooks/lib/history.cjs | 138 +++++++ plugins/aidd-ui/hooks/lib/markdown.cjs | 94 +++++ plugins/aidd-ui/hooks/lib/project.cjs | 54 +++ plugins/aidd-ui/hooks/lib/records.cjs | 272 ++++++++++++++ plugins/aidd-ui/hooks/lib/renders.cjs | 95 +++++ plugins/aidd-ui/hooks/lib/tokens.cjs | 230 ++++++++++++ plugins/aidd-ui/hooks/ui-hook.cjs | 68 ++++ plugins/aidd-ui/hooks/validate-ui.cjs | 39 ++ scripts/__tests__/aidd-ui-code.test.js | 133 +++++++ scripts/__tests__/aidd-ui-records.test.js | 336 ++++++++++++++++++ .../__tests__/aidd-ui-tokens-renders.test.js | 208 +++++++++++ scripts/__tests__/aidd-ui-validate.test.js | 130 +++++++ scripts/__tests__/fixtures/README.md | 2 + scripts/__tests__/fixtures/aidd-ui/project.js | 286 +++++++++++++++ 26 files changed, 2337 insertions(+), 7 deletions(-) create mode 100644 aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/run.md create mode 100644 plugins/aidd-ui/hooks/hooks.json create mode 100644 plugins/aidd-ui/hooks/lib/code.cjs create mode 100644 plugins/aidd-ui/hooks/lib/history.cjs create mode 100644 plugins/aidd-ui/hooks/lib/markdown.cjs create mode 100644 plugins/aidd-ui/hooks/lib/project.cjs create mode 100644 plugins/aidd-ui/hooks/lib/records.cjs create mode 100644 plugins/aidd-ui/hooks/lib/renders.cjs create mode 100644 plugins/aidd-ui/hooks/lib/tokens.cjs create mode 100644 plugins/aidd-ui/hooks/ui-hook.cjs create mode 100644 plugins/aidd-ui/hooks/validate-ui.cjs create mode 100644 scripts/__tests__/aidd-ui-code.test.js create mode 100644 scripts/__tests__/aidd-ui-records.test.js create mode 100644 scripts/__tests__/aidd-ui-tokens-renders.test.js create mode 100644 scripts/__tests__/aidd-ui-validate.test.js create mode 100644 scripts/__tests__/fixtures/aidd-ui/project.js diff --git a/README.md b/README.md index 7868fb461..437922b16 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ Feature flow, issue-to-PR automation, backlog alpha

🎨 aidd-ui

-1 skill
+1 skill · 2 hooks
UI and UX design, smoke test only
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 index c760e59ce..e0d25f365 100644 --- 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 @@ -1,5 +1,5 @@ --- -status: pending +status: done --- # Instruction: Records and history 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 index 4fb53fa02..7007d5ce8 100644 --- 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 @@ -1,5 +1,5 @@ --- -status: pending +status: done --- # Instruction: Tokens and renders 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 index ba863f296..0e0aa2099 100644 --- 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 @@ -1,5 +1,5 @@ --- -status: pending +status: done --- # Instruction: Code link 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 index b35d036fc..9eb94de74 100644 --- 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 @@ -1,5 +1,5 @@ --- -status: pending +status: done --- # Instruction: Entry, hook and docs 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 index c57f7a0da..ab342b4be 100644 --- 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 @@ -1,6 +1,6 @@ --- 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: pending +status: implemented --- # Plan: aidd-ui foundation, every record and its validator 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/docs/CATALOG.md b/docs/CATALOG.md index b7a04036b..ff09f767e 100644 --- a/docs/CATALOG.md +++ b/docs/CATALOG.md @@ -135,6 +135,17 @@ 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` | + + + ## 📈 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/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..5cae57deb 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,65 @@ 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, DTCG 2025.10 +├── 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. + +| Record | Front matter | Body | +| --- | --- | --- | +| every one | `id` (the file name), `kind`, `status` (`proposed`, `validated`, `coded`, `deprecated`), `version` | `## History` | +| `system.md` | `version` in semver, `platforms`, `breakpoints`, `themes`, `locales`, `directions`, `code-roots`, `ignore`, `libraries` | | +| `brand.md` | | `## Project`, `## Users`, `## Domain`, `## Platforms`, `## Personality and tone`, `## References`, `## Anti-references` | +| atom, molecule, composition | `uses`, `tokens`, `variants`, `states`, `interactive` (`yes` or `no`), `role`, `code` | `## Keyboard` when interactive | +| screen | `uses`, `states`, `breakpoints`, `route`, `code` | | +| journey | `persona` | `## Steps`: Screen, Action | +| asset | `type` (`icon`, `image`, `illustration`), `file`, `alt` | | +| review | `scope`, `screens` | `## Verdicts`: Screen, State, Verdict (`ok`, `ko`, `pending`), Comment | + +- `code` is `path#Export`, or `package:#Export` for a library component; required once `coded`. +- A deprecated element names `replaced-by: `, or `replaced-by: none` with a `## Deprecation` section. +- A theme value is a token extension: `"$extensions": {"dev.ai-driven.themes": {"dark": }}`. +- A token's CSS name is `--` plus its path joined by `-`. + +## Validation + +A hook checks the design system after every write under `aidd_docs/ui/` or a code root, and at session start. By hand: + +```sh +node /hooks/validate-ui.cjs [project-root] +``` + +It prints `VALID`, or one `✗ : ` line per problem and exits 1. + +## Limits + +- Without `node` on `PATH`, the hook does nothing. +- Outside Claude Code, a problem does not come back in the same turn. +- Codex runs the hook only once you trust it. +- The write matcher uses Claude Code's tool names; other hosts are unverified. +- Composite tokens are checked for a `$value`, not for its shape. +- Alias cycles are not detected. +- `code-roots` and `ignore` are path prefixes, not globs. +- Literals are found in web code only, and units in stylesheets only, not in script files. +- A word of hex letters after `#`, 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/hooks.json b/plugins/aidd-ui/hooks/hooks.json new file mode 100644 index 000000000..f2653fed7 --- /dev/null +++ b/plugins/aidd-ui/hooks/hooks.json @@ -0,0 +1,27 @@ +{ + "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 + } + ] + } + ] + } +} diff --git a/plugins/aidd-ui/hooks/lib/code.cjs b/plugins/aidd-ui/hooks/lib/code.cjs new file mode 100644 index 000000000..8390c253b --- /dev/null +++ b/plugins/aidd-ui/hooks/lib/code.cjs @@ -0,0 +1,114 @@ +// C1 to C6: each code link resolves, every file under a code root is linked or ignored, and UI +// code holds no literal a token should carry. Non-web code is a stated limit. +const fs = require("node:fs"); +const path = require("node:path"); +const { list } = require("./markdown.cjs"); +const { placed, systemOf, isElement } = require("./records.cjs"); +const { PATTERNS, findLiterals, declaresRightToLeft } = require("./renders.cjs"); + +const EXPORT_SEPARATOR = "#"; +const PACKAGE_PREFIX = "package:"; +const PATH_SEPARATOR = "/"; +const SCANNED_EXTENSIONS = [".css", ".scss", ".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte", ".html"]; +const STYLESHEET_EXTENSIONS = [".css", ".scss"]; // units in script files are a stated limit + +const REGEX_SPECIALS = /[.*+?^${}()|[\]\\]/g; + +const fm = (record) => record.frontMatter; +const posix = (file) => file.split(path.sep).join(PATH_SEPARATOR); + +/** 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(`(? + list(systemOf(project)?.frontMatter.ignore).some((prefix) => file === prefix || file.startsWith(`${prefix.replace(/\/$/, "")}${PATH_SEPARATOR}`)); + +function walk(dir) { + if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return []; + return fs + .readdirSync(dir, { withFileTypes: true }) + .sort((a, b) => a.name.localeCompare(b.name)) + .flatMap((entry) => (entry.isDirectory() ? walk(path.join(dir, entry.name)) : [path.join(dir, entry.name)])); +} + +/** Every file under a `code-roots` prefix, relative to the project root. */ +function codeFiles(project) { + const roots = list(systemOf(project)?.frontMatter["code-roots"]); + return [...new Set(roots.flatMap((root) => walk(path.join(project.root, root)).map((file) => posix(path.relative(project.root, file)))))]; +} + +const scanned = (project, extensions) => + codeFiles(project).filter((file) => extensions.includes(path.extname(file)) && !isIgnored(project, file)); + +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` }]; + return hasWord(fs.readFileSync(full, "utf8"), link.name) ? [] : [{ 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` })); +} + +function coverageProblems(project) { + const targets = new Set(linked(project).flatMap(({ link }) => (link && link.library === null ? [link.target] : []))); + return codeFiles(project) + .filter((file) => !targets.has(file) && !isIgnored(project, file)) + .map((file) => ({ file, message: "no record links it and no ignore covers it" })); +} + +const literalProblems = (pattern, extensions, applies = () => true) => (project) => + applies(project) + ? scanned(project, extensions).flatMap((file) => + findLiterals(fs.readFileSync(path.join(project.root, file), "utf8"), pattern).map(({ message }) => ({ file, message })) + ) + : []; + +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` }]; + }); +} + +const RULES = [ + linkProblems, // C1 + libraryProblems, // C2 + coverageProblems, // C3 + literalProblems(PATTERNS.colour, SCANNED_EXTENSIONS), // C4 + literalProblems(PATTERNS.unit, STYLESHEET_EXTENSIONS), // C4 + sharedLinkProblems, // C5 + literalProblems(PATTERNS.physical, STYLESHEET_EXTENSIONS, declaresRightToLeft), // C6 +]; + +/** Every broken code rule, as `{file, message}`, in rule order. */ +const check = (project) => RULES.flatMap((rule) => rule(project)); + +module.exports = { check, codeRoots: (project) => list(systemOf(project)?.frontMatter["code-roots"]) }; diff --git a/plugins/aidd-ui/hooks/lib/history.cjs b/plugins/aidd-ui/hooks/lib/history.cjs new file mode 100644 index 000000000..7a54b8fb7 --- /dev/null +++ b/plugins/aidd-ui/hooks/lib/history.cjs @@ -0,0 +1,138 @@ +// H1 to H6: each versioned record's history, and the system version its tasks raise. +const { table } = require("./markdown.cjs"); +const { placed, systemOf, isElement } = require("./records.cjs"); + +const HISTORY_SECTION = "History"; +const BREAKING_VALUES = ["yes", "no"]; +const BREAKING = "yes"; +const SYSTEM_KIND = "system"; + +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 isSystem = (record) => fm(record).kind === SYSTEM_KIND; +const rowsOf = (record) => table(record.sections[HISTORY_SECTION]); + +/** 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; +} + +/** 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 = rowsOf(record) + .map((row) => ({ version: row.Version, parts: parts(row.Version, system) })) + .filter((row) => row.parts) + .sort((a, b) => compare(a.parts, b.parts)); + 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.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].version} to ${rows[index].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`)) + ); +} + +/** For each element task, whether any of its rows breaks, and which records it touches. */ +function elementTasks(project) { + const tasks = new Map(); + for (const record of versioned(project).filter((candidate) => !isSystem(candidate))) { + for (const row of rowsOf(record)) { + const task = tasks.get(row.Task) ?? { breaking: false, records: [] }; + task.breaking ||= row.Breaking === BREAKING; + if (!task.records.includes(record)) task.records.push(record); + tasks.set(row.Task, task); + } + } + return tasks; +} + +/** The system row a task raised, with the row before it; null when the task has none. */ +function systemRow(rows, task) { + const index = rows.findIndex((row) => row.Task === task); + return index < 0 ? null : { row: rows[index], previous: rows[index - 1] }; +} + +function bumpProblems(project) { + const system = systemOf(project); + const rows = system ? rowsOf(system) : []; + return [...elementTasks(project)].flatMap(([task, { breaking, records }]) => { + const found = systemRow(rows, task); + if (!found) return records.map((record) => problem(record, `task "${task}" has no row in system.md`)); + if (!found.previous) return []; + const now = parts(found.row.Version, true); + const before = parts(found.previous.Version, true); + if (!now || !before) return []; + if (breaking && now[0] <= before[0]) { + return [problem(system, `task "${task}" breaks an element, yet system.md ${found.row.Version} raises no major version`)]; + } + if (!breaking && compare(now.slice(0, 2), before.slice(0, 2)) <= 0) { + return [problem(system, `task "${task}" changes an element, yet system.md ${found.row.Version} raises neither major nor minor`)]; + } + return []; + }); +} + +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 = [ + presenceProblems, // H1 + duplicateProblems, // H2 + currentProblems, // H3 + breakingProblems, // H4 + bumpProblems, // H5 + deprecationProblems, // H6 +]; + +/** Every broken history rule, as `{file, message}`, in rule order. */ +const check = (project) => RULES.flatMap((rule) => rule(project)); + +module.exports = { check }; diff --git a/plugins/aidd-ui/hooks/lib/markdown.cjs b/plugins/aidd-ui/hooks/lib/markdown.cjs new file mode 100644 index 000000000..b73eddf28 --- /dev/null +++ b/plugins/aidd-ui/hooks/lib/markdown.cjs @@ -0,0 +1,94 @@ +// Reads a UI record: flat front matter, `## ` sections, and a section's first table. + +const LINE_BREAK = "\n"; +const FENCE = "---"; +const SECTION_PREFIX = "## "; +const TABLE_CELL_SEPARATOR = "|"; +const LIST_SEPARATOR = ","; + +const WINDOWS_LINE_ENDING = /\r\n/g; +const ENTRY = /^([a-z][a-z0-9-]*):[ \t]*(\S.*)$/; +const LIST = /^\[(.*)\]$/; +const TABLE_SEPARATOR_ROW = /^\|[\s:|-]+\|$/; + +const NO_FRONT_MATTER = "no front matter, open and close it with a --- line"; + +/** A front matter value: `[a, b]` as a list, anything else as trimmed text. */ +function parseValue(raw) { + const list = raw.trim().match(LIST); + if (!list) return raw.trim(); + return list[1] + .split(LIST_SEPARATOR) + .map((item) => item.trim()) + .filter(Boolean); +} + +/** 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.indexOf(FENCE, 1); + if (lines[0] !== FENCE || 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.replace(WINDOWS_LINE_ENDING, LINE_BREAK).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 one is empty. */ +function list(value) { + if (value === undefined) return []; + return Array.isArray(value) ? value : [value]; +} + +module.exports = { parseRecord, table, list }; diff --git a/plugins/aidd-ui/hooks/lib/project.cjs b/plugins/aidd-ui/hooks/lib/project.cjs new file mode 100644 index 000000000..3d9ce75a4 --- /dev/null +++ b/plugins/aidd-ui/hooks/lib/project.cjs @@ -0,0 +1,54 @@ +// 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 ROOT_FOLDER = ""; + +/** 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("/"); + +/** Every file under `dir`, sorted, skipping the generated showcase. */ +function walk(dir, top = dir) { + return fs + .readdirSync(dir, { withFileTypes: true }) + .sort((a, b) => a.name.localeCompare(b.name)) + .flatMap((entry) => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return dir === top && entry.name === SHOWCASE_DIR ? [] : walk(full, top); + return [full]; + }); +} + +/** A record with its file, folder under `aidd_docs/ui/`, name, front matter and sections. */ +function readRecord(root, uiDir, full) { + const folder = path.relative(uiDir, path.dirname(full)).split(path.sep).join("/"); + return { + file: relative(root, full), + folder: folder || ROOT_FOLDER, + name: path.basename(full, RECORD_EXTENSION), + ...parseRecord(fs.readFileSync(full, "utf8")), + }; +} + +/** 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 files = walk(uiDir); + const tokensPath = path.join(uiDir, TOKENS_FILE); + return { + root, + records: files.filter((file) => file.endsWith(RECORD_EXTENSION)).map((file) => readRecord(root, uiDir, file)), + tokensFile: relative(root, tokensPath), + tokens: fs.existsSync(tokensPath) ? fs.readFileSync(tokensPath, "utf8") : null, + renders: files.filter((file) => file.endsWith(RENDER_EXTENSION)).map((file) => relative(root, file)), + }; +} + +module.exports = { load, UI_DIR }; diff --git a/plugins/aidd-ui/hooks/lib/records.cjs b/plugins/aidd-ui/hooks/lib/records.cjs new file mode 100644 index 000000000..e0e956425 --- /dev/null +++ b/plugins/aidd-ui/hooks/lib/records.cjs @@ -0,0 +1,272 @@ +// R1 to R16: 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 STATUSES = ["proposed", "validated", "coded", "deprecated"]; +const DESIGN_STATUSES = ["proposed", "validated"]; // R16: system, brand and reviews are never coded +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_STATES = ["hover", "focus", "disabled"]; +const KEYBOARD_SECTION = "Keyboard"; +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 SYSTEM_ID = "system"; + +const COMMON_KEYS = ["id", "kind", "status", "version"]; +const COMPONENT_KEYS = ["uses", "tokens", "variants", "states", "interactive", "role"]; +const KIND_KEYS = { + system: ["platforms", "breakpoints", "themes", "locales", "directions", "code-roots", "ignore", "libraries"], + brand: [], + atom: COMPONENT_KEYS, + molecule: COMPONENT_KEYS, + composition: COMPONENT_KEYS, + screen: ["uses", "states", "breakpoints", "route"], + journey: ["persona"], + asset: ["type", "file", "alt"], + review: ["scope", "screens"], +}; +const FOLDER_KINDS = { + "": ["system", "brand"], + components: ["atom", "molecule", "composition"], + screens: ["screen"], + journeys: ["journey"], + assets: ["asset"], + reviews: ["review"], +}; +const COMPONENT_KINDS = FOLDER_KINDS.components; +const DESIGN_KINDS = ["system", "brand", "review"]; +const LEVELS = { asset: 0, atom: 1, molecule: 2, composition: 3, screen: 4 }; // an element uses only lower levels + +const INTEGER_VERSION = /^[1-9]\d*$/; +const SEMVER = /^\d+\.\d+\.\d+$/; + +const listWithOr = (values) => `${values.slice(0, -1).join(", ")} or ${values.at(-1)}`; +const problem = (record, message) => ({ file: record.file, message }); +const fm = (record) => record.frontMatter; +const isElement = (record) => fm(record).kind in LEVELS; +const isComponent = (record) => COMPONENT_KINDS.includes(fm(record).kind); +const isDeprecated = (record) => fm(record).status === "deprecated"; +const folderLabel = (folder) => (folder ? `${folder}/` : "aidd_docs/ui/"); + +/** 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) => FOLDER_KINDS[record.folder]?.includes(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); + +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 (!(kind in KIND_KEYS)) return [problem(record, `unknown kind "${kind}"`)]; + if (FOLDER_KINDS[record.folder]?.includes(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 (fm(record).kind === SYSTEM_ID) 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`)]; + 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 ? INTERACTIVE_STATES.filter((state) => !states.includes(state)).map((state) => `interactive component lacks state "${state}"`) : []), + ...(interactive && !(KEYBOARD_SECTION in record.sections) ? [`interactive component lacks a "## ${KEYBOARD_SECTION}" section`] : []), + ].map((message) => problem(record, message)); + }); +} + +function codedProblems(project) { + return placed(project) + .filter((record) => isElement(record) && fm(record).status === "coded" && !("code" in fm(record))) + .map((record) => problem(record, 'status coded needs "code"')); +} + +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]); + const missing = list(fm(record).screens).flatMap((id) => + list(index.get(id) && fm(index.get(id)).states) + .filter((state) => !rows.some((row) => row.Screen === id && row.State === state)) + .map((state) => `no verdict row for ${id} / ${state}`) + ); + 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)}`)); +} + +const RULES = [ + frontMatterProblems, // R1 + kindProblems, // R2 + requiredKeyProblems, // R3 + identityProblems, // R4 + versionProblems, // R5 + usesProblems, // R6 + stateProblems, // R7 + codedProblems, // R8 + deprecatedProblems, // R9 + deprecatedUseProblems, // R10 + breakpointProblems, // R11 + journeyProblems, // R12 + reviewProblems, // R13 + brandProblems, // R14 + assetProblems, // R15 + designStatusProblems, // R16 +]; + +/** Every broken record rule, as `{file, message}`, in rule order. */ +const check = (project) => RULES.flatMap((rule) => rule(project)); + +module.exports = { check, readable, placed, byId, systemOf, isElement }; diff --git a/plugins/aidd-ui/hooks/lib/renders.cjs b/plugins/aidd-ui/hooks/lib/renders.cjs new file mode 100644 index 000000000..1d4d7f7f9 --- /dev/null +++ b/plugins/aidd-ui/hooks/lib/renders.cjs @@ -0,0 +1,95 @@ +// V1 to V6: each component and screen has its HTML render, showing every variant and state +// through tokens alone. +const fs = require("node:fs"); +const path = require("node:path"); +const { list } = require("./markdown.cjs"); +const { placed, systemOf } = require("./records.cjs"); +const { cssNames } = require("./tokens.cjs"); + +const RENDERED_KINDS = ["atom", "molecule", "composition", "screen"]; +const RENDER_EXTENSION = ".html"; +const RIGHT_TO_LEFT = "rtl"; +const LINE_BREAK = "\n"; + +const CUSTOM_PROPERTY = /var\(\s*(--[\w-]+)/g; +const VAR_CALL = /var\([^()]*\)/g; // a fallback inside var() is the token's, not a literal + +/** 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`, + strip: VAR_CALL, + }, + physical: { + regex: /\b(?:margin|padding|border)-(?:left|right)\b|(? `physical property "${found}", use its logical form`, + }, +}; + +/** Each match of `pattern` in `text`, as `{line, message}`, lines numbered from 1. */ +function findLiterals(text, pattern) { + return text.split(LINE_BREAK).flatMap((line, index) => { + const searched = pattern.strip ? line.replace(pattern.strip, "") : line; + return [...searched.matchAll(pattern.regex)].map((match) => ({ line: index + 1, message: `line ${index + 1}: ${pattern.message(match[0])}` })); + }); +} + +/** Whether the design system declares a right-to-left direction. */ +const declaresRightToLeft = (project) => list(systemOf(project)?.frontMatter.directions).includes(RIGHT_TO_LEFT); + +const renderOf = (record) => `${path.dirname(record.file)}/${record.frontMatter.id}${RENDER_EXTENSION}`; +const rendered = (project) => placed(project).filter((record) => RENDERED_KINDS.includes(record.frontMatter.kind) && record.frontMatter.id === record.name); +const readRender = (project, file) => fs.readFileSync(path.join(project.root, file), "utf8"); + +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 = readRender(project, renderOf(record)); + 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 names = cssNames(project); + if (names === null) return []; + const defined = new Set(names.values()); + return project.renders.flatMap((file) => + readRender(project, file) + .split(LINE_BREAK) + .flatMap((line, index) => + [...line.matchAll(CUSTOM_PROPERTY)] + .filter((match) => !defined.has(match[1])) + .map((match) => ({ file, message: `line ${index + 1}: var(${match[1]}) names no token` })) + ) + ); +} + +const literalProblems = (pattern, applies = () => true) => (project) => + applies(project) ? project.renders.flatMap((file) => findLiterals(readRender(project, file), pattern).map(({ message }) => ({ file, message }))) : []; + +const RULES = [ + presenceProblems, // V1 + coverageProblems, // V2 + referenceProblems, // V3 + literalProblems(PATTERNS.colour), // V4 + literalProblems(PATTERNS.physical, declaresRightToLeft), // V5 + literalProblems(PATTERNS.unit), // V6 +]; + +/** Every broken render rule, as `{file, message}`, in rule order. */ +const check = (project) => RULES.flatMap((rule) => rule(project)); + +module.exports = { check, PATTERNS, findLiterals, declaresRightToLeft }; diff --git a/plugins/aidd-ui/hooks/lib/tokens.cjs b/plugins/aidd-ui/hooks/lib/tokens.cjs new file mode 100644 index 000000000..7d875b662 --- /dev/null +++ b/plugins/aidd-ui/hooks/lib/tokens.cjs @@ -0,0 +1,230 @@ +// T1 to T8: tokens.json against the DTCG 2025.10 format and color modules, plus this plugin's +// themes extension and its rem rule. Composite values are checked for presence only. +const { list } = require("./markdown.cjs"); +const { placed, systemOf } = require("./records.cjs"); + +const RESERVED_KEYS = ["$value", "$type", "$description", "$extensions", "$deprecated", "$extends", "$schema", "$root"]; +const PROPERTY_PREFIX = "$"; +const PATH_SEPARATOR = "."; +const CSS_PREFIX = "--"; +const CSS_SEPARATOR = "-"; +const THEMES_EXTENSION = "dev.ai-driven.themes"; +const COMPONENT_KINDS = ["atom", "molecule", "composition"]; + +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 SIZE_UNIT = "rem"; +const PIXEL_UNIT = "px"; +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 MAX_ALIAS_DEPTH = 32; // alias cycles are a stated limit; this only stops the walk + +const FORBIDDEN_IN_NAME = /[{}.]/; +const ALIAS = /^\{([^{}]+)\}$/; +const HEX = /^#[0-9a-fA-F]{6}$/; + +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 walk(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 (!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); + walk(child, childPath, child.$type ?? groupType, found); + } + } + return found; +} + +/** The tree read once: its parse error, or its tokens, groups and bad names. */ +function read(project) { + if (project.tokens === null) return { missing: true, tokens: new Map(), groups: new Set(), badNames: [] }; + let tree; + try { + tree = JSON.parse(project.tokens); + } catch { + return { invalid: true, tokens: new Map(), groups: new Set(), badNames: [] }; + } + if (!isObject(tree)) return { invalid: true, tokens: new Map(), groups: new Set(), badNames: [] }; + return walk(tree, [], tree.$type, { tokens: new Map(), groups: new Set(), badNames: [] }); +} + +/** A token's `$type`: its own, else its alias target's, else its closest group's. */ +function typeOf(tree, token, depth = 0) { + if (token.node.$type !== undefined) return token.node.$type; + const target = tree.tokens.get(aliasOf(token.node.$value)); + if (target && depth < MAX_ALIAS_DEPTH) return typeOf(tree, target, depth + 1); + return token.groupType; +} + +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 tokens.json, naming the token path first. */ +const tokenProblem = (project) => (path, message) => ({ file: project.tokensFile, message: `${path}: ${message}` }); +const themesOf = (token) => (isObject(token.node.$extensions?.[THEMES_EXTENSION]) ? token.node.$extensions[THEMES_EXTENSION] : {}); + +function jsonProblems(tree, project) { + return tree.invalid ? [{ file: project.tokensFile, message: "not JSON" }] : []; +} + +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 (!("$value" in token.node)) return [at(name, "no $value")]; + 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 ? shapeProblem(typeOf(tree, token), token.node.$value) : null; + return message ? [at(name, message)] : []; + }); +} + +function aliasProblem(tree, value) { + const target = aliasOf(value); + if (target === undefined || tree.tokens.has(target)) return null; + return tree.groups.has(target) ? `alias {${target}} names a group, not a token` : `alias {${target}} names no token`; +} + +function aliasProblems(tree, project) { + const at = tokenProblem(project); + return [...tree.tokens].flatMap(([name, token]) => { + const message = aliasProblem(tree, token.node.$value); + return message ? [at(name, message)] : []; + }); +} + +function themeProblems(tree, project) { + const at = tokenProblem(project); + const system = systemOf(project); + const declared = list(system?.frontMatter.themes); + return [...tree.tokens].flatMap(([name, token]) => + Object.entries(themesOf(token)).flatMap(([theme, value]) => { + if (system && !declared.includes(theme)) return [at(name, `theme "${theme}" is not in system.md`)]; + const message = shapeProblem(typeOf(tree, token), value) ?? aliasProblem(tree, value); + return message ? [at(name, `theme "${theme}": ${message}`)] : []; + }) + ); +} + +function componentTokenProblems(tree, project) { + if (tree.invalid) return []; + return placed(project) + .filter((record) => COMPONENT_KINDS.includes(record.frontMatter.kind)) + .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` })) + ); +} + +function remProblems(tree, project) { + const at = tokenProblem(project); + return [...tree.tokens] + .filter(([, token]) => typeOf(tree, token) === "dimension") + .filter(([, token]) => [token.node.$value, ...Object.values(themesOf(token))].some((value) => isObject(value) && value.unit === PIXEL_UNIT)) + .map(([name]) => at(name, `sizes are in ${SIZE_UNIT}, not ${PIXEL_UNIT}`)); +} + +const RULES = [ + jsonProblems, // T1 + nameProblems, // T2 + typeProblems, // T3 + shapeProblems, // T4 + aliasProblems, // T5 + themeProblems, // T6 + componentTokenProblems, // T7 + remProblems, // T8 +]; + +/** Every broken token rule, as `{file, message}`, in rule order. */ +function check(project) { + const tree = read(project); + return RULES.flatMap((rule) => rule(tree, project)); +} + +/** Each token path and its CSS custom property, `--` plus the path joined by `-`; null when + * tokens.json cannot be read, so renders do not repeat T1. */ +function cssNames(project) { + const tree = read(project); + if (tree.invalid) return null; + return new Map([...tree.tokens.values()].map((token) => [token.path.join(PATH_SEPARATOR), `${CSS_PREFIX}${token.path.join(CSS_SEPARATOR)}`])); +} + +module.exports = { check, cssNames }; diff --git a/plugins/aidd-ui/hooks/ui-hook.cjs b/plugins/aidd-ui/hooks/ui-hook.cjs new file mode 100644 index 000000000..e022ac42f --- /dev/null +++ b/plugins/aidd-ui/hooks/ui-hook.cjs @@ -0,0 +1,68 @@ +// PostToolUse and SessionStart: runs the design system checks. After a write under +// aidd_docs/ui/ or a code root, problems go back to the agent on stderr with exit 2; at session +// start they are printed as context. Anything unexpected exits 0 in silence: a broken check +// never blocks a session. +const fs = require("node:fs"); +const path = require("node:path"); +const { load, UI_DIR } = require("./lib/project.cjs"); +const { codeRoots } = require("./lib/code.cjs"); +const { problemsOf, format } = require("./validate-ui.cjs"); + +const POST_TOOL_USE = "PostToolUse"; +const SESSION_START = "SessionStart"; +const EXIT_SILENT = 0; +const EXIT_BLOCKING = 2; // Claude Code hands a PostToolUse hook's stderr back to the agent +const PATH_SEPARATOR = "/"; + +/** The JSON object on stdin, or null when there is none or it is not one. */ +function readPayload() { + try { + const parsed = JSON.parse(fs.readFileSync(0, "utf8")); + return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } +} + +/** Whether `file` lies under `prefix`, both relative to the project root. */ +const isUnder = (file, prefix) => { + const folder = prefix.replace(/\/+$/, ""); + return file === folder || file.startsWith(`${folder}${PATH_SEPARATOR}`); +}; + +/** Whether a written file belongs to the design system or to its code. */ +function isWatched(project, filePath) { + const file = path.relative(project.root, path.resolve(project.root, filePath)).split(path.sep).join(PATH_SEPARATOR); + return [UI_DIR, ...codeRoots(project)].some((prefix) => isUnder(file, prefix)); +} + +function afterWrite(project, payload) { + const filePath = payload.tool_input?.file_path; + if (typeof filePath !== "string" || !isWatched(project, filePath)) return EXIT_SILENT; + const problems = problemsOf(project); + if (!problems.length) return EXIT_SILENT; + process.stderr.write(`${format(problems).join("\n")}\n`); + return EXIT_BLOCKING; +} + +function atSessionStart(project) { + const problems = problemsOf(project); + if (problems.length) process.stdout.write(`aidd-ui: ${problems.length} problems\n${format(problems).join("\n")}\n`); + return EXIT_SILENT; +} + +function run() { + const payload = readPayload(); + if (payload === null) 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); + return EXIT_SILENT; +} + +try { + process.exitCode = run(); +} catch { + process.exitCode = EXIT_SILENT; +} diff --git a/plugins/aidd-ui/hooks/validate-ui.cjs b/plugins/aidd-ui/hooks/validate-ui.cjs new file mode 100644 index 000000000..8de9c8798 --- /dev/null +++ b/plugins/aidd-ui/hooks/validate-ui.cjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +// Usage: node validate-ui.cjs [project-root] +// Checks a project's design system under aidd_docs/ui/: records, history, tokens, renders and +// code links. Prints VALID, or one line per problem and exits 1. Prints nothing without one. +const { load } = 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 PROBLEM_MARK = "✗"; + +/** Every problem of a loaded project, check by check. */ +const problemsOf = (project) => CHECKS.flatMap((check) => check.check(project)); + +/** The problems of the design system under `root`, or null when it has none. */ +function validate(root) { + const project = load(root); + return project === null ? null : problemsOf(project); +} + +/** One `✗ : ` line per problem, the file relative to the project root. */ +const format = (problems) => problems.map((problem) => `${PROBLEM_MARK} ${problem.file}: ${problem.message}`); + +function main(args) { + const problems = validate(args[0] ?? process.cwd()); + if (problems === null) return EXIT_VALID; + console.log(problems.length ? format(problems).join("\n") : VERDICT_VALID); + return problems.length ? EXIT_INVALID : EXIT_VALID; +} + +if (require.main === module) process.exitCode = main(process.argv.slice(2)); + +module.exports = { validate, problemsOf, format }; diff --git a/scripts/__tests__/aidd-ui-code.test.js b/scripts/__tests__/aidd-ui-code.test.js new file mode 100644 index 000000000..8b049948b --- /dev/null +++ b/scripts/__tests__/aidd-ui-code.test.js @@ -0,0 +1,133 @@ +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("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 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("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("skips an ignored file", () => { + append("src/ui/tokens.css", ".x { color: #ff0000; }"); + assert.deepEqual(problems(), []); + }); + }); + + describe("C4 unit literals", () => { + it("names a unit literal in a stylesheet", () => { + linkedStylesheet(".SearchField { padding: 16px; }\n"); + assert.deepEqual(problems(), [`${SEARCH_CSS}: line 1: literal "16px", only tokens carry values`]); + }); + + it("leaves units in script files alone, a stated limit", () => { + append("src/ui/Button.tsx", 'const style = "padding: 16px";'); + 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(), []); + }); + }); +}); diff --git a/scripts/__tests__/aidd-ui-records.test.js b/scripts/__tests__/aidd-ui-records.test.js new file mode 100644 index 000000000..d46e57158 --- /dev/null +++ b/scripts/__tests__/aidd-ui-records.test.js @@ -0,0 +1,336 @@ +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"`]); + }); + }); + + 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 outside its folder", () => { + put(tmp, `${UI}/screens/extra.md`, record(["id: extra", "kind: atom", "status: proposed", "version: 1"], history(["1", "created", "no", "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, coded 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: []", "states: [default]", "breakpoints: [mobile]", "route: /brand"]; + put(tmp, `${UI}/screens/brand.md`, record(screen, history(["1", "created", "no", "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: [button]", 'atom cannot use atom "button"'], + [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]", 'screen cannot use screen "home"'], + ]; + for (const [file, from, to, message] of levels) { + it(`names a ${message.split(" ")[0]} using its own level or above`, () => { + change(file, from, to); + assert.deepEqual(problems(), [`${file}: ${message}`]); + }); + } + }); + + describe("R7 states and keyboard", () => { + it("names an interactive component without hover", () => { + change(BUTTON, "states: [default, hover, focus, disabled]", "states: [default, focus, disabled]"); + assert.deepEqual(problems(), [`${BUTTON}: interactive component lacks state "hover"`]); + }); + + 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 coded", () => { + it("names a coded record without code", () => { + change(HOME, "code: src/ui/HomePage.tsx#HomePage\n", ""); + assert.deepEqual(problems(), [`${HOME}: status coded needs "code"`]); + }); + }); + + 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`]); + }); + }); + + 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 | ko | spinner too small |\n", ""); + assert.deepEqual(problems(), [`${REVIEW}: no verdict row for home / loading`]); + }); + + it("names a verdict that is not ok, ko or pending", () => { + change(REVIEW, "| home | default | ok | |", "| home | default | 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", "coded"], + [BRAND, "status: validated", "deprecated"], + [REVIEW, "status: proposed", "coded"], + ]; + 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("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 | 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 | ds-init |", "| 1 | created | no | ds-init |\n| 3 | larger | no | 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 tasks", () => { + it("names a breaking task 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}: task "retire-old-button" breaks an element, yet system.md 1.1.0 raises no major version`]); + }); + + it("names a non-breaking task whose system row raises nothing", () => { + change(BUTTON, "version: 1", "version: 2"); + change(BUTTON, "| 1 | created | no | ds-init |", "| 1 | created | no | ds-init |\n| 2 | larger padding | no | tweak |"); + change(SYSTEM, "version: 2.0.0", "version: 2.0.1"); + change(SYSTEM, "| 2.0.0 | old-button retired | yes | retire-old-button |", "| 2.0.0 | old-button retired | yes | retire-old-button |\n| 2.0.1 | button padding | no | tweak |"); + assert.deepEqual(problems(), [`${SYSTEM}: task "tweak" changes an element, yet system.md 2.0.1 raises neither major nor minor`]); + }); + + it("names a task system.md has no row for", () => { + change(JOURNEY, "| no | ds-init |", "| no | new-flow |"); + assert.deepEqual(problems(), [`${JOURNEY}: task "new-flow" has no row in system.md`]); + }); + }); + + describe("H6 deprecation breaks", () => { + it("names a deprecated record whose last row is not breaking", () => { + change(OLD, "| yes | retire-old-button |", "| no | retire-old-button |"); + assert.deepEqual(problems(), [`${OLD}: deprecated, yet its last history row is not breaking`]); + }); + }); +}); 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..c36f0bd41 --- /dev/null +++ b/scripts/__tests__/aidd-ui-tokens-renders.test.js @@ -0,0 +1,208 @@ +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 INSERTED_LINE = 30; // 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.equal(names.get("color.text.default"), "--color-text-default"); + assert.equal(names.size, 7); + }); + + 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`]); + }); + }); + + 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`]); + }); + }); + + describe("T6 themes", () => { + const themes = (tree) => tree.color.brand.primary.$extensions["dev.ai-driven.themes"]; + + it("names a theme system.md lacks", () => { + editTokens(tmp, (tree) => { + themes(tree).sepia = themes(tree).dark; + delete themes(tree).dark; + }); + assert.deepEqual(problems(), [`${TOKENS}: color.brand.primary: theme "sepia" is not in system.md`]); + }); + + it("names a theme value of the wrong shape", () => { + editTokens(tmp, (tree) => (themes(tree).dark.components = [0.4, 0.6])); + assert.deepEqual(problems(), [`${TOKENS}: color.brand.primary: theme "dark": components must be three numbers or "none"`]); + }); + }); + + describe("T7 component tokens", () => { + it("names a component token absent from tokens.json", () => { + edit(tmp, BUTTON, "tokens: [color.brand.primary,", "tokens: [space.huge, color.brand.primary,"); + assert.deepEqual(problems(), [`${BUTTON}: token "space.huge" is not in tokens.json`]); + }); + }); + + describe("T8 sizes in rem", () => { + it("names a dimension in px", () => { + editTokens(tmp, (tree) => (tree.space.md.$value = { value: 16, unit: "px" })); + assert.deepEqual(problems(), [`${TOKENS}: space.md: sizes are in rem, not px`]); + }); + }); + + 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`]); + }); + }); + + 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("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..8c98b0791 --- /dev/null +++ b/scripts/__tests__/aidd-ui-validate.test.js @@ -0,0 +1,130 @@ +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 VALIDATE = path.join(HOOKS, "validate-ui.cjs"); +const HOOK = path.join(HOOKS, "ui-hook.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"`, + `✗ ${TOKENS}: space.md: sizes are in rem, not px`, + `✗ ${DIALOG}: package "left-pad" is not in system.md libraries`, +]; + +let tmp; + +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "aidd-ui-validate-")); + writeProject(tmp); +}); + +afterEach(() => { + fs.rmSync(tmp, { recursive: true, 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: 16, unit: "px" })); + 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, [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 }; +} + +const postToolUse = (filePath) => JSON.stringify({ hook_event_name: "PostToolUse", cwd: tmp, tool_name: "Write", tool_input: { file_path: filePath } }); +const sessionStart = () => JSON.stringify({ hook_event_name: "SessionStart", cwd: tmp, source: "startup" }); +const silent = { code: 0, out: "", err: "" }; + +describe("aidd-ui validator entries", () => { + it("prints VALID for the valid project", () => { + assert.deepEqual(validate(tmp), { code: 0, out: ["VALID"], err: "" }); + }); + + 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: "" }); + }); + }); + + 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))), { code: 2, out: "", err: `${FAILURES.join("\n")}\n` }); + }); + + 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"))), { code: 2, out: "", err: `${FAILURES.join("\n")}\n` }); + }); + + 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("E4 SessionStart", () => { + it("prints the count then the problems, and exits 0", () => { + breakOnePerFamily(); + assert.deepEqual(hook(sessionStart()), { code: 0, out: `aidd-ui: 3 problems\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); + }); + } + + it("never blocks when a check throws", { skip: process.platform === "win32" }, () => { + fs.symlinkSync(path.join(tmp, "nowhere.md"), path.join(tmp, UI, "dangling.md")); + assert.deepEqual(hook(sessionStart()), silent); + assert.deepEqual(hook(postToolUse(path.join(tmp, BRAND))), silent); + }); + }); +}); 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..0d2def8fa --- /dev/null +++ b/scripts/__tests__/fixtures/aidd-ui/project.js @@ -0,0 +1,286 @@ +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", "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 SYSTEM = record( + [ + "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]", + ], + "# Design system", + "## Breakpoints\n\n| Name | From |\n| --- | --- |\n| mobile | 0 |\n| desktop | 64rem |", + history(["1.0.0", "first system", "no", "ds-init"], ["2.0.0", "old-button retired", "yes", "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" }, + $extensions: { + "dev.ai-driven.themes": { dark: { colorSpace: "srgb", components: [0.4, 0.6, 0.8], hex: "#6699cc" } }, + }, + }, + }, + text: { 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 } }, + }, +}; + +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: coded", + "version: 1", + "uses: []", + "tokens: [color.brand.primary, color.text.default, space.md, border.thin, motion.fast, font.body, font.weight.bold]", + "variants: [primary, secondary]", + "states: [default, hover, focus, disabled]", + "interactive: yes", + "role: button", + "code: src/ui/Button.tsx#Button", + ], + "# Button", + 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", + KEYBOARD, + history(FIRST_ROW) +); + +const DIALOG = record( + [ + "id: dialog", + "kind: composition", + "status: coded", + "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", + "## 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.brand.primary]", + "variants: [default]", + "states: [default]", + "interactive: no", + "role: button", + "replaced-by: button", + ], + "# Old button", + history(FIRST_ROW, ["2", "deprecated for button", "yes", "retire-old-button"]) +); + +const HOME = record( + [ + "id: home", + "kind: screen", + "status: coded", + "version: 1", + "uses: [search-field, dialog]", + "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 | Verdict | Comment |", + "| --- | --- | --- | --- |", + "| home | default | ok | |", + "| home | empty | ok | |", + "| home | loading | ko | spinner too small |", + "| home | error | pending | |", + ].join("\n"), + history(FIRST_ROW) +); + +const STYLE = [ + ".ui {", + " padding-block: var(--space-md);", + " padding-inline: var(--space-md);", + " margin-inline-start: 0;", + " color: var(--color-text-default);", + " background: var(--color-brand-primary);", + " border: var(--border-thin) solid var(--color-brand-primary);", + " font-family: var(--font-body);", + " font-weight: var(--font-weight-bold);", + " transition: background var(--motion-fast);", + " text-align: start;", + "}", +].join("\n"); + +const render = (title, 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", [ + ...statesOf("primary", ["default", "hover", "focus", "disabled"]), + ...statesOf("secondary", ["default", "hover", "focus", "disabled"]), + ]), + "components/search-field.html": render("search-field", statesOf("default", ["default", "hover", "focus", "disabled"])), + "components/dialog.html": render("dialog", statesOf("default", ["default", "hover", "focus", "disabled"])), + "components/old-button.html": render("old-button", statesOf("default", ["default"])), + "screens/home.html": render( + "home", + ["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", +}; + +const FILES = { + [`${UI}/system.md`]: SYSTEM, + [`${UI}/brand.md`]: BRAND, + [`${UI}/tokens.json`]: `${JSON.stringify(TOKENS, 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 }; From d15bb4132ded962ea279261a60ca6b60e890914f Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Fri, 30 Oct 2026 15:34:46 +0100 Subject: [PATCH 05/26] fix(aidd-ui): read real-world records and stylesheets without false alarms Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XeK3mD4irAZumku9bknHhD --- README.md | 2 +- docs/assets/plugins/plugins.json | 2 +- plugins/aidd-ui/README.md | 3 +- plugins/aidd-ui/hooks/lib/code.cjs | 64 +++++++++------ plugins/aidd-ui/hooks/lib/history.cjs | 31 ++++---- plugins/aidd-ui/hooks/lib/markdown.cjs | 25 +++--- plugins/aidd-ui/hooks/lib/project.cjs | 77 +++++++++++++------ plugins/aidd-ui/hooks/lib/records.cjs | 4 +- plugins/aidd-ui/hooks/lib/renders.cjs | 21 ++--- plugins/aidd-ui/hooks/lib/tokens.cjs | 38 +++++---- plugins/aidd-ui/hooks/ui-hook.cjs | 15 +--- plugins/aidd-ui/hooks/validate-ui.cjs | 6 +- scripts/__tests__/aidd-ui-code.test.js | 16 ++++ scripts/__tests__/aidd-ui-records.test.js | 51 ++++++++++++ .../__tests__/aidd-ui-tokens-renders.test.js | 15 +++- scripts/__tests__/aidd-ui-validate.test.js | 29 ++++++- 16 files changed, 280 insertions(+), 119 deletions(-) diff --git a/README.md b/README.md index 437922b16..fd66ff393 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ Feature flow, issue-to-PR automation, backlog alpha

🎨 aidd-ui

1 skill · 2 hooks
-UI and UX design, smoke test only +UI design system, checked on every write 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/README.md b/plugins/aidd-ui/README.md index 5cae57deb..e188f5f86 100644 --- a/plugins/aidd-ui/README.md +++ b/plugins/aidd-ui/README.md @@ -71,7 +71,8 @@ It prints `VALID`, or one `✗ : ` line per problem and exits 1. - Alias cycles are not detected. - `code-roots` and `ignore` are path prefixes, not globs. - Literals are found in web code only, and units in stylesheets only, not in script files. -- A word of hex letters after `#`, such as `#add`, counts as a colour. +- In script files, `.vue` and `.svelte` included, colours are found in string literals only. +- 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 diff --git a/plugins/aidd-ui/hooks/lib/code.cjs b/plugins/aidd-ui/hooks/lib/code.cjs index 8390c253b..511ebab2a 100644 --- a/plugins/aidd-ui/hooks/lib/code.cjs +++ b/plugins/aidd-ui/hooks/lib/code.cjs @@ -3,19 +3,31 @@ const fs = require("node:fs"); const path = require("node:path"); const { list } = require("./markdown.cjs"); -const { placed, systemOf, isElement } = require("./records.cjs"); +const { placed, systemOf, isElement, fm } = require("./records.cjs"); const { PATTERNS, findLiterals, declaresRightToLeft } = require("./renders.cjs"); +const { walk, text, relative, isUnder } = require("./project.cjs"); const EXPORT_SEPARATOR = "#"; const PACKAGE_PREFIX = "package:"; -const PATH_SEPARATOR = "/"; -const SCANNED_EXTENSIONS = [".css", ".scss", ".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte", ".html"]; +const CODE_ROOTS_KEY = "code-roots"; +const IGNORE_KEY = "ignore"; const STYLESHEET_EXTENSIONS = [".css", ".scss"]; // units in script files are a stated limit +const SCRIPT_EXTENSIONS = [".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte"]; +const SCANNED_EXTENSIONS = [...STYLESHEET_EXTENSIONS, ...SCRIPT_EXTENSIONS, ".html"]; +const BLANK = " "; 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]/g; -const fm = (record) => record.frontMatter; -const posix = (file) => file.split(path.sep).join(PATH_SEPARATOR); +/** 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, BLANK)); + +/** 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)); +const codeRoots = (project) => prefixes(project, CODE_ROOTS_KEY); /** A `code` value as `{target, name}`, `target` being a path or `package:`; null when malformed. */ function parseLink(value) { @@ -34,26 +46,30 @@ const linked = (project) => const hasWord = (text, word) => new RegExp(`(? - list(systemOf(project)?.frontMatter.ignore).some((prefix) => file === prefix || file.startsWith(`${prefix.replace(/\/$/, "")}${PATH_SEPARATOR}`)); +const trees = new WeakMap(); -function walk(dir) { - if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return []; - return fs - .readdirSync(dir, { withFileTypes: true }) - .sort((a, b) => a.name.localeCompare(b.name)) - .flatMap((entry) => (entry.isDirectory() ? walk(path.join(dir, entry.name)) : [path.join(dir, entry.name)])); +/** The code side, walked once per run: every file under a code root, and the ignored prefixes. */ +function codeTree(project) { + if (!trees.has(project)) { + const files = codeRoots(project).flatMap((root) => walk(path.join(project.root, root)).map((file) => relative(project.root, file))); + trees.set(project, { files: [...new Set(files)], ignore: prefixes(project, IGNORE_KEY) }); + } + return trees.get(project); } -/** Every file under a `code-roots` prefix, relative to the project root. */ +/** Every file under a code root that no `ignore` prefix covers, relative to the project root. */ function codeFiles(project) { - const roots = list(systemOf(project)?.frontMatter["code-roots"]); - return [...new Set(roots.flatMap((root) => walk(path.join(project.root, root)).map((file) => posix(path.relative(project.root, file)))))]; + 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)) && !isIgnored(project, file)); +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 }) => { @@ -61,7 +77,9 @@ function linkProblems(project) { 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` }]; - return hasWord(fs.readFileSync(full, "utf8"), link.name) ? [] : [{ file: record.file, message: `export "${link.name}" is not in ${link.target}` }]; + 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}` }]; }); } @@ -75,14 +93,14 @@ function libraryProblems(project) { function coverageProblems(project) { const targets = new Set(linked(project).flatMap(({ link }) => (link && link.library === null ? [link.target] : []))); return codeFiles(project) - .filter((file) => !targets.has(file) && !isIgnored(project, file)) + .filter((file) => !targets.has(file)) .map((file) => ({ file, message: "no record links it and no ignore covers it" })); } const literalProblems = (pattern, extensions, applies = () => true) => (project) => applies(project) ? scanned(project, extensions).flatMap((file) => - findLiterals(fs.readFileSync(path.join(project.root, file), "utf8"), pattern).map(({ message }) => ({ file, message })) + findLiterals(searchable(project, file), pattern).map(({ message }) => ({ file, message })) ) : []; @@ -111,4 +129,4 @@ const RULES = [ /** Every broken code rule, as `{file, message}`, in rule order. */ const check = (project) => RULES.flatMap((rule) => rule(project)); -module.exports = { check, codeRoots: (project) => list(systemOf(project)?.frontMatter["code-roots"]) }; +module.exports = { check, codeRoots }; diff --git a/plugins/aidd-ui/hooks/lib/history.cjs b/plugins/aidd-ui/hooks/lib/history.cjs index 7a54b8fb7..1f67917ee 100644 --- a/plugins/aidd-ui/hooks/lib/history.cjs +++ b/plugins/aidd-ui/hooks/lib/history.cjs @@ -1,17 +1,13 @@ // H1 to H6: each versioned record's history, and the system version its tasks raise. const { table } = require("./markdown.cjs"); -const { placed, systemOf, isElement } = require("./records.cjs"); +const { placed, systemOf, isElement, fm, INTEGER_VERSION, SEMVER } = require("./records.cjs"); const HISTORY_SECTION = "History"; const BREAKING_VALUES = ["yes", "no"]; const BREAKING = "yes"; const SYSTEM_KIND = "system"; -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 isSystem = (record) => fm(record).kind === SYSTEM_KIND; const rowsOf = (record) => table(record.sections[HISTORY_SECTION]); @@ -29,6 +25,13 @@ function compare(a, b) { 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)); @@ -51,17 +54,14 @@ function currentProblems(project) { return versioned(project).flatMap((record) => { const system = isSystem(record); const current = parts(fm(record).version, system); - const rows = rowsOf(record) - .map((row) => ({ version: row.Version, parts: parts(row.Version, system) })) - .filter((row) => row.parts) - .sort((a, b) => compare(a.parts, b.parts)); + 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.version}`]; + 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].version} to ${rows[index].version}`); + 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)); @@ -90,10 +90,13 @@ function elementTasks(project) { return tasks; } -/** The system row a task raised, with the row before it; null when the task has none. */ +/** The system row a task raised, with the row before it by version; null when the task has none. */ function systemRow(rows, task) { - const index = rows.findIndex((row) => row.Task === task); - return index < 0 ? null : { row: rows[index], previous: rows[index - 1] }; + const row = rows.find((candidate) => candidate.Task === task); + if (!row) return null; + const ordered = byVersion(rows, true); + const index = ordered.findIndex((entry) => entry.row === row); + return { row, previous: index > 0 ? ordered[index - 1].row : undefined }; } function bumpProblems(project) { diff --git a/plugins/aidd-ui/hooks/lib/markdown.cjs b/plugins/aidd-ui/hooks/lib/markdown.cjs index b73eddf28..6557fdd50 100644 --- a/plugins/aidd-ui/hooks/lib/markdown.cjs +++ b/plugins/aidd-ui/hooks/lib/markdown.cjs @@ -7,27 +7,34 @@ const TABLE_CELL_SEPARATOR = "|"; const LIST_SEPARATOR = ","; const WINDOWS_LINE_ENDING = /\r\n/g; -const ENTRY = /^([a-z][a-z0-9-]*):[ \t]*(\S.*)$/; +const BYTE_ORDER_MARK = /^\uFEFF/; +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 front matter value: `[a, b]` as a list, anything else as trimmed text. */ +/** 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 raw.trim(); + if (!list) return unquote(raw.trim()); return list[1] .split(LIST_SEPARATOR) - .map((item) => item.trim()) + .map((item) => unquote(item.trim())) .filter(Boolean); } +const isFence = (line) => line.trimEnd() === FENCE; + /** 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.indexOf(FENCE, 1); - if (lines[0] !== FENCE || close < 0) return { found: false, values: {}, end: 0, problems: [{ line: 1, message: NO_FRONT_MATTER }] }; + 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) { @@ -75,7 +82,7 @@ function table(section) { /** A record's front matter, its problems, and its sections. */ function parseRecord(text) { - const lines = text.replace(WINDOWS_LINE_ENDING, LINE_BREAK).split(LINE_BREAK); + const lines = text.replace(BYTE_ORDER_MARK, "").replace(WINDOWS_LINE_ENDING, LINE_BREAK).split(LINE_BREAK); const frontMatter = parseFrontMatter(lines); return { hasFrontMatter: frontMatter.found, @@ -85,9 +92,9 @@ function parseRecord(text) { }; } -/** A front matter value as a list: a lone value is a list of one, an absent one is empty. */ +/** 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) return []; + if (value === undefined || value === "") return []; return Array.isArray(value) ? value : [value]; } diff --git a/plugins/aidd-ui/hooks/lib/project.cjs b/plugins/aidd-ui/hooks/lib/project.cjs index 3d9ce75a4..619fb33ab 100644 --- a/plugins/aidd-ui/hooks/lib/project.cjs +++ b/plugins/aidd-ui/hooks/lib/project.cjs @@ -8,47 +8,74 @@ const TOKENS_FILE = "tokens.json"; const SHOWCASE_DIR = "showcase"; const RECORD_EXTENSION = ".md"; const RENDER_EXTENSION = ".html"; -const ROOT_FOLDER = ""; +const PATH_SEPARATOR = "/"; +const UNREADABLE = "unreadable"; + +const TRAILING_SEPARATORS = /\/+$/; /** 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("/"); +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, skipping the generated showcase. */ -function walk(dir, top = dir) { +/** Every file under `dir`, sorted, minus the folders `skip` names; [] when it is no directory. */ +function walk(dir, skip = () => false) { + if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return []; return fs .readdirSync(dir, { withFileTypes: true }) .sort((a, b) => a.name.localeCompare(b.name)) .flatMap((entry) => { const full = path.join(dir, entry.name); - if (entry.isDirectory()) return dir === top && entry.name === SHOWCASE_DIR ? [] : walk(full, top); - return [full]; + if (!entry.isDirectory()) return [full]; + return skip(full) ? [] : walk(full, skip); }); } -/** A record with its file, folder under `aidd_docs/ui/`, name, front matter and sections. */ -function readRecord(root, uiDir, full) { - const folder = path.relative(uiDir, path.dirname(full)).split(path.sep).join("/"); - return { - file: relative(root, full), - folder: folder || ROOT_FOLDER, - name: path.basename(full, RECORD_EXTENSION), - ...parseRecord(fs.readFileSync(full, "utf8")), - }; +/** A file's text, read once per run; 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"); + } 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); +} + +/** 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 files = walk(uiDir); - const tokensPath = path.join(uiDir, TOKENS_FILE); - return { - root, - records: files.filter((file) => file.endsWith(RECORD_EXTENSION)).map((file) => readRecord(root, uiDir, file)), - tokensFile: relative(root, tokensPath), - tokens: fs.existsSync(tokensPath) ? fs.readFileSync(tokensPath, "utf8") : null, - renders: files.filter((file) => file.endsWith(RENDER_EXTENSION)).map((file) => relative(root, file)), - }; + const showcase = path.join(uiDir, SHOWCASE_DIR); + const files = walk(uiDir, (dir) => dir === showcase).map((file) => relative(root, file)); + const project = { root, texts: new Map(), tokensFile: `${UI_DIR}/${TOKENS_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, UI_DIR }; +module.exports = { load, walk, text, unreadable, relative, isUnder, UI_DIR, RENDER_EXTENSION }; diff --git a/plugins/aidd-ui/hooks/lib/records.cjs b/plugins/aidd-ui/hooks/lib/records.cjs index e0e956425..b3ad356bb 100644 --- a/plugins/aidd-ui/hooks/lib/records.cjs +++ b/plugins/aidd-ui/hooks/lib/records.cjs @@ -44,7 +44,7 @@ const DESIGN_KINDS = ["system", "brand", "review"]; const LEVELS = { asset: 0, atom: 1, molecule: 2, composition: 3, screen: 4 }; // an element uses only lower levels const INTEGER_VERSION = /^[1-9]\d*$/; -const SEMVER = /^\d+\.\d+\.\d+$/; +const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/; const listWithOr = (values) => `${values.slice(0, -1).join(", ")} or ${values.at(-1)}`; const problem = (record, message) => ({ file: record.file, message }); @@ -269,4 +269,4 @@ const RULES = [ /** Every broken record rule, as `{file, message}`, in rule order. */ const check = (project) => RULES.flatMap((rule) => rule(project)); -module.exports = { check, readable, placed, byId, systemOf, isElement }; +module.exports = { check, readable, placed, byId, systemOf, isElement, fm, COMPONENT_KINDS, INTEGER_VERSION, SEMVER }; diff --git a/plugins/aidd-ui/hooks/lib/renders.cjs b/plugins/aidd-ui/hooks/lib/renders.cjs index 1d4d7f7f9..08e84e4d3 100644 --- a/plugins/aidd-ui/hooks/lib/renders.cjs +++ b/plugins/aidd-ui/hooks/lib/renders.cjs @@ -1,18 +1,18 @@ // V1 to V6: each component and screen has its HTML render, showing every variant and state // through tokens alone. -const fs = require("node:fs"); const path = require("node:path"); const { list } = require("./markdown.cjs"); -const { placed, systemOf } = require("./records.cjs"); +const { placed, systemOf, COMPONENT_KINDS } = require("./records.cjs"); const { cssNames } = require("./tokens.cjs"); +const { text, RENDER_EXTENSION } = require("./project.cjs"); -const RENDERED_KINDS = ["atom", "molecule", "composition", "screen"]; -const RENDER_EXTENSION = ".html"; +const RENDERED_KINDS = [...COMPONENT_KINDS, "screen"]; const RIGHT_TO_LEFT = "rtl"; const LINE_BREAK = "\n"; const CUSTOM_PROPERTY = /var\(\s*(--[\w-]+)/g; 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 /** Literals a render or UI code must not hold, each with the message it earns. */ const PATTERNS = { @@ -23,7 +23,7 @@ const PATTERNS = { unit: { regex: /(? `literal "${found}", only tokens carry values`, - strip: VAR_CALL, + strip: [VAR_CALL, QUERY_PRELUDE], }, physical: { regex: /\b(?:margin|padding|border)-(?:left|right)\b|(? { - const searched = pattern.strip ? line.replace(pattern.strip, "") : line; + const searched = (pattern.strip ?? []).reduce((kept, regex) => kept.replace(regex, ""), line); return [...searched.matchAll(pattern.regex)].map((match) => ({ line: index + 1, message: `line ${index + 1}: ${pattern.message(match[0])}` })); }); } @@ -44,7 +44,8 @@ const declaresRightToLeft = (project) => list(systemOf(project)?.frontMatter.dir const renderOf = (record) => `${path.dirname(record.file)}/${record.frontMatter.id}${RENDER_EXTENSION}`; const rendered = (project) => placed(project).filter((record) => RENDERED_KINDS.includes(record.frontMatter.kind) && record.frontMatter.id === record.name); -const readRender = (project, file) => fs.readFileSync(path.join(project.root, file), "utf8"); +/** 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) @@ -56,7 +57,8 @@ function coverageProblems(project) { return rendered(project) .filter((record) => project.renders.includes(renderOf(record))) .flatMap((record) => { - const html = readRender(project, renderOf(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}` })); }); @@ -65,13 +67,12 @@ function coverageProblems(project) { function referenceProblems(project) { const names = cssNames(project); if (names === null) return []; - const defined = new Set(names.values()); return project.renders.flatMap((file) => readRender(project, file) .split(LINE_BREAK) .flatMap((line, index) => [...line.matchAll(CUSTOM_PROPERTY)] - .filter((match) => !defined.has(match[1])) + .filter((match) => !names.has(match[1])) .map((match) => ({ file, message: `line ${index + 1}: var(${match[1]}) names no token` })) ) ); diff --git a/plugins/aidd-ui/hooks/lib/tokens.cjs b/plugins/aidd-ui/hooks/lib/tokens.cjs index 7d875b662..32d5b9a2e 100644 --- a/plugins/aidd-ui/hooks/lib/tokens.cjs +++ b/plugins/aidd-ui/hooks/lib/tokens.cjs @@ -1,7 +1,10 @@ // T1 to T8: tokens.json against the DTCG 2025.10 format and color modules, plus this plugin's // themes extension and its rem rule. 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 } = require("./records.cjs"); +const { placed, systemOf, COMPONENT_KINDS } = require("./records.cjs"); +const { text } = require("./project.cjs"); const RESERVED_KEYS = ["$value", "$type", "$description", "$extensions", "$deprecated", "$extends", "$schema", "$root"]; const PROPERTY_PREFIX = "$"; @@ -9,7 +12,8 @@ const PATH_SEPARATOR = "."; const CSS_PREFIX = "--"; const CSS_SEPARATOR = "-"; const THEMES_EXTENSION = "dev.ai-driven.themes"; -const COMPONENT_KINDS = ["atom", "molecule", "composition"]; +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"]; @@ -58,17 +62,23 @@ function walk(node, path, groupType, found) { return found; } -/** The tree read once: its parse error, or its tokens, groups and bad names. */ +const emptyTree = () => ({ tokens: new Map(), groups: new Set(), badNames: [] }); + +/** A tree no rule can read, with its T1 problem; null when unreadable, which the loader reports. */ +const unusable = (problem) => ({ ...emptyTree(), unusable: true, problem }); + +/** The tree: its tokens, groups and bad names, or why it cannot be read. */ function read(project) { - if (project.tokens === null) return { missing: true, tokens: new Map(), groups: new Set(), badNames: [] }; + if (!fs.existsSync(nodePath.join(project.root, project.tokensFile))) return unusable(MISSING); + const source = text(project, project.tokensFile); + if (source === null) return unusable(null); let tree; try { - tree = JSON.parse(project.tokens); + tree = JSON.parse(source); } catch { - return { invalid: true, tokens: new Map(), groups: new Set(), badNames: [] }; + return unusable(NOT_JSON); } - if (!isObject(tree)) return { invalid: true, tokens: new Map(), groups: new Set(), badNames: [] }; - return walk(tree, [], tree.$type, { tokens: new Map(), groups: new Set(), badNames: [] }); + return isObject(tree) ? walk(tree, [], tree.$type, emptyTree()) : unusable(NOT_JSON); } /** A token's `$type`: its own, else its alias target's, else its closest group's. */ @@ -130,7 +140,7 @@ const tokenProblem = (project) => (path, message) => ({ file: project.tokensFile const themesOf = (token) => (isObject(token.node.$extensions?.[THEMES_EXTENSION]) ? token.node.$extensions[THEMES_EXTENSION] : {}); function jsonProblems(tree, project) { - return tree.invalid ? [{ file: project.tokensFile, message: "not JSON" }] : []; + return tree.problem ? [{ file: project.tokensFile, message: tree.problem }] : []; } function nameProblems(tree, project) { @@ -184,7 +194,7 @@ function themeProblems(tree, project) { } function componentTokenProblems(tree, project) { - if (tree.invalid) return []; + if (tree.unusable) return []; return placed(project) .filter((record) => COMPONENT_KINDS.includes(record.frontMatter.kind)) .flatMap((record) => @@ -219,12 +229,12 @@ function check(project) { return RULES.flatMap((rule) => rule(tree, project)); } -/** Each token path and its CSS custom property, `--` plus the path joined by `-`; null when - * tokens.json cannot be read, so renders do not repeat T1. */ +/** 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); - if (tree.invalid) return null; - return new Map([...tree.tokens.values()].map((token) => [token.path.join(PATH_SEPARATOR), `${CSS_PREFIX}${token.path.join(CSS_SEPARATOR)}`])); + if (tree.unusable) return null; + return new Set([...tree.tokens.values()].map((token) => `${CSS_PREFIX}${token.path.join(CSS_SEPARATOR)}`)); } module.exports = { check, cssNames }; diff --git a/plugins/aidd-ui/hooks/ui-hook.cjs b/plugins/aidd-ui/hooks/ui-hook.cjs index e022ac42f..7e1dd0d47 100644 --- a/plugins/aidd-ui/hooks/ui-hook.cjs +++ b/plugins/aidd-ui/hooks/ui-hook.cjs @@ -1,10 +1,10 @@ // PostToolUse and SessionStart: runs the design system checks. After a write under // aidd_docs/ui/ or a code root, problems go back to the agent on stderr with exit 2; at session -// start they are printed as context. Anything unexpected exits 0 in silence: a broken check -// never blocks a session. +// start they are printed as context. 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 path = require("node:path"); -const { load, UI_DIR } = require("./lib/project.cjs"); +const { load, relative, isUnder, UI_DIR } = require("./lib/project.cjs"); const { codeRoots } = require("./lib/code.cjs"); const { problemsOf, format } = require("./validate-ui.cjs"); @@ -12,7 +12,6 @@ const POST_TOOL_USE = "PostToolUse"; const SESSION_START = "SessionStart"; const EXIT_SILENT = 0; const EXIT_BLOCKING = 2; // Claude Code hands a PostToolUse hook's stderr back to the agent -const PATH_SEPARATOR = "/"; /** The JSON object on stdin, or null when there is none or it is not one. */ function readPayload() { @@ -24,15 +23,9 @@ function readPayload() { } } -/** Whether `file` lies under `prefix`, both relative to the project root. */ -const isUnder = (file, prefix) => { - const folder = prefix.replace(/\/+$/, ""); - return file === folder || file.startsWith(`${folder}${PATH_SEPARATOR}`); -}; - /** Whether a written file belongs to the design system or to its code. */ function isWatched(project, filePath) { - const file = path.relative(project.root, path.resolve(project.root, filePath)).split(path.sep).join(PATH_SEPARATOR); + const file = relative(project.root, path.resolve(project.root, filePath)); return [UI_DIR, ...codeRoots(project)].some((prefix) => isUnder(file, prefix)); } diff --git a/plugins/aidd-ui/hooks/validate-ui.cjs b/plugins/aidd-ui/hooks/validate-ui.cjs index 8de9c8798..82b10fbb9 100644 --- a/plugins/aidd-ui/hooks/validate-ui.cjs +++ b/plugins/aidd-ui/hooks/validate-ui.cjs @@ -2,7 +2,7 @@ // Usage: node validate-ui.cjs [project-root] // Checks a project's design system under aidd_docs/ui/: records, history, tokens, renders and // code links. Prints VALID, or one line per problem and exits 1. Prints nothing without one. -const { load } = require("./lib/project.cjs"); +const { load, unreadable } = require("./lib/project.cjs"); const records = require("./lib/records.cjs"); const history = require("./lib/history.cjs"); const tokens = require("./lib/tokens.cjs"); @@ -15,8 +15,8 @@ const EXIT_VALID = 0; const EXIT_INVALID = 1; const PROBLEM_MARK = "✗"; -/** Every problem of a loaded project, check by check. */ -const problemsOf = (project) => CHECKS.flatMap((check) => check.check(project)); +/** Every problem of a loaded project, check by check, then each file no check could read. */ +const problemsOf = (project) => [...CHECKS.flatMap((check) => check.check(project)), ...unreadable(project)]; /** The problems of the design system under `root`, or null when it has none. */ function validate(root) { diff --git a/scripts/__tests__/aidd-ui-code.test.js b/scripts/__tests__/aidd-ui-code.test.js index 8b049948b..3004288d7 100644 --- a/scripts/__tests__/aidd-ui-code.test.js +++ b/scripts/__tests__/aidd-ui-code.test.js @@ -55,6 +55,12 @@ describe("aidd-ui code link", () => { 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/tokens.css]", "ignore: [./src/ui/tokens.css]"); + 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`]); @@ -93,6 +99,11 @@ describe("aidd-ui code link", () => { 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/tokens.css", ".x { color: #ff0000; }"); assert.deepEqual(problems(), []); @@ -105,6 +116,11 @@ describe("aidd-ui code link", () => { assert.deepEqual(problems(), [`${SEARCH_CSS}: line 1: literal "16px", only tokens carry values`]); }); + it("leaves the lengths of a @container prelude alone", () => { + linkedStylesheet("@container (min-width: 768px) { .SearchField { padding: var(--space-md); } }\n"); + assert.deepEqual(problems(), []); + }); + it("leaves units in script files alone, a stated limit", () => { append("src/ui/Button.tsx", 'const style = "padding: 16px";'); assert.deepEqual(problems(), []); diff --git a/scripts/__tests__/aidd-ui-records.test.js b/scripts/__tests__/aidd-ui-records.test.js index d46e57158..19f9b4cfe 100644 --- a/scripts/__tests__/aidd-ui-records.test.js +++ b/scripts/__tests__/aidd-ui-records.test.js @@ -67,6 +67,35 @@ describe("aidd-ui records and history", () => { 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", () => { @@ -216,6 +245,19 @@ describe("aidd-ui records and history", () => { 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", "ds-init"], ["2", "deprecated for icon-search", "yes", "retire-old-button"]) + ) + ); + change(BUTTON, "uses: []", "uses: [icon-old]"); + assert.deepEqual(problems(), [`${BUTTON}: uses "icon-old", which is deprecated`]); + }); }); describe("R11 breakpoints", () => { @@ -321,6 +363,15 @@ describe("aidd-ui records and history", () => { assert.deepEqual(problems(), [`${SYSTEM}: task "tweak" changes an element, yet system.md 2.0.1 raises neither major nor minor`]); }); + it("reads a newest-first system history by version", () => { + change( + SYSTEM, + "| 1.0.0 | first system | no | ds-init |\n| 2.0.0 | old-button retired | yes | retire-old-button |", + "| 2.0.0 | old-button retired | yes | retire-old-button |\n| 1.0.0 | first system | no | ds-init |" + ); + assert.deepEqual(problems(), []); + }); + it("names a task system.md has no row for", () => { change(JOURNEY, "| no | ds-init |", "| no | new-flow |"); assert.deepEqual(problems(), [`${JOURNEY}: task "new-flow" has no row in system.md`]); diff --git a/scripts/__tests__/aidd-ui-tokens-renders.test.js b/scripts/__tests__/aidd-ui-tokens-renders.test.js index c36f0bd41..53d8b2a91 100644 --- a/scripts/__tests__/aidd-ui-tokens-renders.test.js +++ b/scripts/__tests__/aidd-ui-tokens-renders.test.js @@ -51,7 +51,7 @@ describe("aidd-ui tokens and renders", () => { it("names every token's CSS custom property", () => { const names = tokens.cssNames(load(tmp)); - assert.equal(names.get("color.text.default"), "--color-text-default"); + assert.ok(names.has("--color-text-default")); assert.equal(names.size, 7); }); @@ -60,6 +60,11 @@ describe("aidd-ui tokens and renders", () => { fs.writeFileSync(path.join(tmp, TOKENS), "{ not json"); assert.deepEqual(problems(), [`${TOKENS}: not JSON`]); }); + + 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", () => { @@ -193,6 +198,14 @@ describe("aidd-ui tokens and renders", () => { } }); + describe("V6 media and container queries", () => { + it("leaves the lengths of @media and @container preludes alone", () => { + addToRender(""); + addToRender(""); + assert.deepEqual(problems(), []); + }); + }); + describe("V5 logical properties", () => { it("names a physical property when rtl is declared", () => { addToRender('

x

'); diff --git a/scripts/__tests__/aidd-ui-validate.test.js b/scripts/__tests__/aidd-ui-validate.test.js index 8c98b0791..0a7ed2480 100644 --- a/scripts/__tests__/aidd-ui-validate.test.js +++ b/scripts/__tests__/aidd-ui-validate.test.js @@ -87,6 +87,12 @@ describe("aidd-ui validator entries", () => { assert.deepEqual(hook(postToolUse(path.join(tmp, "src/ui/Button.tsx"))), { code: 2, out: "", err: `${FAILURES.join("\n")}\n` }); }); + 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"))), { code: 2, out: "", err: `${FAILURES.join("\n")}\n` }); + }); + it("stays silent when a write lands elsewhere", () => { breakOnePerFamily(); assert.deepEqual(hook(postToolUse(path.join(tmp, "README.md"))), silent); @@ -120,11 +126,26 @@ describe("aidd-ui validator entries", () => { assert.deepEqual(hook(input), silent); }); } + }); - it("never blocks when a check throws", { skip: process.platform === "win32" }, () => { - fs.symlinkSync(path.join(tmp, "nowhere.md"), path.join(tmp, UI, "dangling.md")); - assert.deepEqual(hook(sessionStart()), silent); - assert.deepEqual(hook(postToolUse(path.join(tmp, BRAND))), 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 problems\n${UNREADABLE}\n`, err: "" }); + assert.deepEqual(hook(postToolUse(path.join(tmp, BRAND))), { code: 2, out: "", err: `${UNREADABLE}\n` }); + }); + + 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", "✗ src/ui/Folder.css: unreadable"], + err: "", + }); }); }); }); From df6b4da922834734df9c89e66c545a78a4fa7914 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Fri, 30 Oct 2026 16:22:45 +0100 Subject: [PATCH 06/26] fix(aidd-ui): reject code roots outside the project and report unreadable folders Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XeK3mD4irAZumku9bknHhD --- .../2026_10/2026_10_30_aidd-ui-rework/spec.md | 2 +- plugins/aidd-ui/hooks/lib/code.cjs | 16 +++++++++++++-- plugins/aidd-ui/hooks/lib/project.cjs | 20 +++++++++++++------ plugins/aidd-ui/hooks/lib/records.cjs | 2 +- scripts/__tests__/aidd-ui-code.test.js | 11 ++++++++++ scripts/__tests__/aidd-ui-validate.test.js | 10 ++++++++++ 6 files changed, 51 insertions(+), 10 deletions(-) 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 index 8a39fb546..862115072 100644 --- 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 @@ -37,7 +37,7 @@ Chaque fiche porte en tête : `id` (égal au nom du fichier, unique), `kind`, `s | `system.md` | version du design system, plateformes, breakpoints, thèmes, langues et sens d'écriture, dossiers UI du code, chemins ignorés, librairies de composants utilisées | | `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 | | asset | type (icône, image, illustration), fichier, texte alternatif attendu | -| atome, molécule, composition | éléments utilisés, tokens utilisés, variantes, états (au moins défaut, survol, focus, désactivé, et chargement ou erreur quand ils existent), comportement clavier et rôle accessible, lien vers le code | +| atome, molécule, composition | éléments utilisés, tokens utilisés, variantes, états (au moins défaut ; survol, focus et désactivé quand il est interactif ; chargement ou erreur quand ils existent), comportement clavier et rôle accessible, lien vers le code | | écran | éléments 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 et par état, commentaire, écrans restants | diff --git a/plugins/aidd-ui/hooks/lib/code.cjs b/plugins/aidd-ui/hooks/lib/code.cjs index 511ebab2a..8074a8ce5 100644 --- a/plugins/aidd-ui/hooks/lib/code.cjs +++ b/plugins/aidd-ui/hooks/lib/code.cjs @@ -1,4 +1,4 @@ -// C1 to C6: each code link resolves, every file under a code root is linked or ignored, and UI +// C1 to C7: each code link resolves, every file under a code root is linked or ignored, and UI // code holds no literal a token should carry. Non-web code is a stated limit. const fs = require("node:fs"); const path = require("node:path"); @@ -15,6 +15,8 @@ const STYLESHEET_EXTENSIONS = [".css", ".scss"]; // units in script files are a const SCRIPT_EXTENSIONS = [".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte"]; const SCANNED_EXTENSIONS = [...STYLESHEET_EXTENSIONS, ...SCRIPT_EXTENSIONS, ".html"]; const BLANK = " "; +const CURRENT_FOLDER = "."; +const PARENT_FOLDER = ".."; const REGEX_SPECIALS = /[.*+?^${}()|[\]\\]/g; const NOT_LINE_BREAK = /[^\n]/g; @@ -27,7 +29,9 @@ const stringsOnly = (source) => source.replace(SCRIPT_TOKEN, (found, literal) => /** 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)); -const codeRoots = (project) => prefixes(project, CODE_ROOTS_KEY); +/** Whether a normalised prefix names a folder inside the project, never the project itself. */ +const isInside = (prefix) => prefix !== CURRENT_FOLDER && !prefix.startsWith(PARENT_FOLDER) && !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) { @@ -116,6 +120,13 @@ function sharedLinkProblems(project) { }); } +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 = [ linkProblems, // C1 libraryProblems, // C2 @@ -124,6 +135,7 @@ const RULES = [ literalProblems(PATTERNS.unit, STYLESHEET_EXTENSIONS), // C4 sharedLinkProblems, // C5 literalProblems(PATTERNS.physical, STYLESHEET_EXTENSIONS, declaresRightToLeft), // C6 + rootProblems, // C7 ]; /** Every broken code rule, as `{file, message}`, in rule order. */ diff --git a/plugins/aidd-ui/hooks/lib/project.cjs b/plugins/aidd-ui/hooks/lib/project.cjs index 619fb33ab..093620bb4 100644 --- a/plugins/aidd-ui/hooks/lib/project.cjs +++ b/plugins/aidd-ui/hooks/lib/project.cjs @@ -22,16 +22,23 @@ function isUnder(file, prefix) { return file === folder || file.startsWith(`${folder}${PATH_SEPARATOR}`); } -/** Every file under `dir`, sorted, minus the folders `skip` names; [] when it is no directory. */ -function walk(dir, skip = () => false) { +/** 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 = () => {}) { if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return []; - return fs - .readdirSync(dir, { withFileTypes: true }) + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + unlisted(dir); + return []; + } + return entries .sort((a, b) => a.name.localeCompare(b.name)) .flatMap((entry) => { const full = path.join(dir, entry.name); if (!entry.isDirectory()) return [full]; - return skip(full) ? [] : walk(full, skip); + return skip(full) ? [] : walk(full, skip, unlisted); }); } @@ -71,8 +78,9 @@ 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 files = walk(uiDir, (dir) => dir === showcase).map((file) => relative(root, file)); 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; diff --git a/plugins/aidd-ui/hooks/lib/records.cjs b/plugins/aidd-ui/hooks/lib/records.cjs index b3ad356bb..d3c7ea9f8 100644 --- a/plugins/aidd-ui/hooks/lib/records.cjs +++ b/plugins/aidd-ui/hooks/lib/records.cjs @@ -269,4 +269,4 @@ const RULES = [ /** Every broken record rule, as `{file, message}`, in rule order. */ const check = (project) => RULES.flatMap((rule) => rule(project)); -module.exports = { check, readable, placed, byId, systemOf, isElement, fm, COMPONENT_KINDS, INTEGER_VERSION, SEMVER }; +module.exports = { check, placed, systemOf, isElement, fm, COMPONENT_KINDS, INTEGER_VERSION, SEMVER }; diff --git a/scripts/__tests__/aidd-ui-code.test.js b/scripts/__tests__/aidd-ui-code.test.js index 3004288d7..58d31b4b0 100644 --- a/scripts/__tests__/aidd-ui-code.test.js +++ b/scripts/__tests__/aidd-ui-code.test.js @@ -146,4 +146,15 @@ describe("aidd-ui code link", () => { assert.deepEqual(problems(), []); }); }); + + 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-validate.test.js b/scripts/__tests__/aidd-ui-validate.test.js index 0a7ed2480..fe21889d9 100644 --- a/scripts/__tests__/aidd-ui-validate.test.js +++ b/scripts/__tests__/aidd-ui-validate.test.js @@ -147,5 +147,15 @@ describe("aidd-ui validator entries", () => { 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); + } + }); }); }); From 125ad7cfa35c496fdbccbb08c50120cba942524b Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Fri, 30 Oct 2026 18:08:24 +0100 Subject: [PATCH 07/26] feat(aidd-ui): fit the ui format to a real component library A link now covers its file's folder, so a component's styles, hooks and tests need no record of their own; a file at a code root still covers only itself. A molecule or composition with no file of its own is coded once every component it uses is. Code is checked for colour literals only. Components need a usage section and cite semantic tokens, never a group system.md declares primitive. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XeK3mD4irAZumku9bknHhD --- .../2026_10_30_aidd-ui-foundation/phase-5.md | 64 +++++++++++++++++++ .../2026_10_30_aidd-ui-foundation/plan.md | 1 + .../2026_10/2026_10_30_aidd-ui-rework/spec.md | 19 +++--- plugins/aidd-ui/README.md | 11 ++-- plugins/aidd-ui/hooks/lib/code.cjs | 14 ++-- plugins/aidd-ui/hooks/lib/records.cjs | 28 +++++++- plugins/aidd-ui/hooks/lib/tokens.cjs | 19 +++++- scripts/__tests__/aidd-ui-code.test.js | 27 ++++---- scripts/__tests__/aidd-ui-records.test.js | 18 ++++++ .../__tests__/aidd-ui-tokens-renders.test.js | 11 +++- scripts/__tests__/fixtures/aidd-ui/project.js | 12 +++- 11 files changed, 184 insertions(+), 40 deletions(-) create mode 100644 aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/phase-5.md 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..8a9acb0d4 --- /dev/null +++ b/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/phase-5.md @@ -0,0 +1,64 @@ +--- +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 | C3: a link to `src/ui/search/SearchField.tsx` beside a hook and a `__tests__/` file | no problem: the link covers its folder | system | +| Edge case | C3: `src/ui/Orphan.css` beside `src/ui/Button.tsx` at the code root | one problem: a file at a root covers only itself | 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 | T9: button citing `color.brand.primary` | one problem | 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 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`: T9 names a component token under a `primitives` group. +4. `README.md`: the format table and limits follow. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| ---- | -------------------------------- | +| 1 | the spec names every rule below and moves unit literals and forbidden words to the audit | +| 2 | 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 index ab342b4be..4510b608c 100644 --- 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 @@ -15,6 +15,7 @@ Source: aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-rework/spec.md, pull request | 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 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 index 862115072..1425aa710 100644 --- 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 @@ -34,10 +34,10 @@ Chaque fiche porte en tête : `id` (égal au nom du fichier, unique), `kind`, `s | Fiche | Contient en plus | | --- | --- | -| `system.md` | version du design system, plateformes, breakpoints, thèmes, langues et sens d'écriture, dossiers UI du code, chemins ignorés, librairies de composants utilisées | -| `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 | +| `system.md` | version du design system, plateformes, breakpoints, thèmes, langues et sens d'écriture, groupes de tokens primitifs, dossiers UI du code, 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 | éléments utilisés, tokens utilisés, variantes, états (au moins défaut ; survol, focus et désactivé quand il est interactif ; chargement ou erreur quand ils existent), comportement clavier et rôle accessible, lien vers le code | +| 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 ; survol, focus et désactivé quand il est interactif ; chargement ou erreur quand ils existent), comportement clavier et rôle accessible, lien vers le code | | écran | éléments 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 et par état, commentaire, écrans restants | @@ -46,15 +46,15 @@ Chaque fiche porte en tête : `id` (égal au nom du fichier, unique), `kind`, `s - `proposé` : créé, pas encore validé. - `validé` : rendu approuvé en review. -- `codé` : un lien vers le code existe et le validateur le confirme. +- `codé` : un lien vers le code existe et le validateur le confirme ; une molécule ou une composition sans fichier à elle est codée quand chaque composant qu'elle utilise l'est. - `déprécié` : ne doit plus être utilisé, nomme son remplaçant ou dit pourquoi il n'en a pas. -- Le lien vers le code est obligatoire à `codé`, facultatif avant. +- Le lien vers le code est obligatoire à `codé`, facultatif avant, sauf pour une molécule ou une composition codée par ce qu'elle utilise. - Modifier un élément `codé` le repasse à `proposé` le temps de la review ; il redevient `codé` quand le code suit sa nouvelle version. - 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 de code, identifié par son chemin et le nom du composant exporté. +- Un élément correspond à un seul fichier d'entrée, identifié par son chemin et le nom du composant exporté. Ce lien couvre aussi le dossier du fichier (styles, hooks, tests), sauf quand le fichier est à la racine d'un dossier UI. - 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. @@ -82,6 +82,7 @@ Chaque fiche porte en tête : `id` (égal au nom du fichier, unique), `kind`, `s ### 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 n'utilise que des sémantiques. - 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. @@ -106,11 +107,12 @@ Chaque fiche porte en tête : `id` (égal au nom du fichier, unique), `kind`, `s - é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 ni dans le code ; + - 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é. - 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, textes trop longs dans la langue la plus longue, accessibilité. +- 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, mots interdits par le glossaire, textes trop longs dans la langue la plus longue, accessibilité. ### Format @@ -120,7 +122,6 @@ Chaque fiche porte en tête : `id` (égal au nom du fichier, unique), `kind`, `s ## Non-goals - Plusieurs design systems dans un projet, ou une app web et une app mobile avec deux systèmes. -- Un composant réparti sur plusieurs fichiers. - 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. diff --git a/plugins/aidd-ui/README.md b/plugins/aidd-ui/README.md index e188f5f86..ddb153275 100644 --- a/plugins/aidd-ui/README.md +++ b/plugins/aidd-ui/README.md @@ -38,15 +38,16 @@ Every record opens with flat front matter, `key: value` or `key: [a, b]`, and cl | Record | Front matter | Body | | --- | --- | --- | | every one | `id` (the file name), `kind`, `status` (`proposed`, `validated`, `coded`, `deprecated`), `version` | `## History` | -| `system.md` | `version` in semver, `platforms`, `breakpoints`, `themes`, `locales`, `directions`, `code-roots`, `ignore`, `libraries` | | -| `brand.md` | | `## Project`, `## Users`, `## Domain`, `## Platforms`, `## Personality and tone`, `## References`, `## Anti-references` | -| atom, molecule, composition | `uses`, `tokens`, `variants`, `states`, `interactive` (`yes` or `no`), `role`, `code` | `## Keyboard` when interactive | +| `system.md` | `version` in semver, `platforms`, `breakpoints`, `themes`, `locales`, `directions`, `primitives` (token groups components never cite), `code-roots`, `ignore`, `libraries` | 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`, `code` | `## Usage`, and `## Keyboard` when interactive | | screen | `uses`, `states`, `breakpoints`, `route`, `code` | | | journey | `persona` | `## Steps`: Screen, Action | | asset | `type` (`icon`, `image`, `illustration`), `file`, `alt` | | | review | `scope`, `screens` | `## Verdicts`: Screen, State, Verdict (`ok`, `ko`, `pending`), Comment | -- `code` is `path#Export`, or `package:#Export` for a library component; required once `coded`. +- `code` is `path#Export`, or `package:#Export` for a library component; required once `coded`, except for a molecule or composition whose used components are all coded. +- A link covers its file's folder too, styles, hooks and tests, unless the file sits at a code root. - A deprecated element names `replaced-by: `, or `replaced-by: none` with a `## Deprecation` section. - A theme value is a token extension: `"$extensions": {"dev.ai-driven.themes": {"dark": }}`. - A token's CSS name is `--` plus its path joined by `-`. @@ -70,7 +71,7 @@ It prints `VALID`, or one `✗ : ` line per problem and exits 1. - Composite tokens are checked for a `$value`, not for its shape. - Alias cycles are not detected. - `code-roots` and `ignore` are path prefixes, not globs. -- Literals are found in web code only, and units in stylesheets only, not in script files. +- In code, only colour literals are found, in web code only; other literals are left to the UI audit. - In script files, `.vue` and `.svelte` included, colours are found in string literals only. - 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. diff --git a/plugins/aidd-ui/hooks/lib/code.cjs b/plugins/aidd-ui/hooks/lib/code.cjs index 8074a8ce5..9230a2193 100644 --- a/plugins/aidd-ui/hooks/lib/code.cjs +++ b/plugins/aidd-ui/hooks/lib/code.cjs @@ -1,5 +1,5 @@ // C1 to C7: each code link resolves, every file under a code root is linked or ignored, and UI -// code holds no literal a token should carry. Non-web code is a stated limit. +// code holds no colour literal. Other literals and non-web code are stated limits. const fs = require("node:fs"); const path = require("node:path"); const { list } = require("./markdown.cjs"); @@ -11,12 +11,13 @@ const EXPORT_SEPARATOR = "#"; const PACKAGE_PREFIX = "package:"; const CODE_ROOTS_KEY = "code-roots"; const IGNORE_KEY = "ignore"; -const STYLESHEET_EXTENSIONS = [".css", ".scss"]; // units in script files are a stated limit +const STYLESHEET_EXTENSIONS = [".css", ".scss"]; const SCRIPT_EXTENSIONS = [".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte"]; const SCANNED_EXTENSIONS = [...STYLESHEET_EXTENSIONS, ...SCRIPT_EXTENSIONS, ".html"]; const BLANK = " "; const CURRENT_FOLDER = "."; const PARENT_FOLDER = ".."; +const TRAILING_SEPARATORS = /\/+$/; const REGEX_SPECIALS = /[.*+?^${}()|[\]\\]/g; const NOT_LINE_BREAK = /[^\n]/g; @@ -94,10 +95,14 @@ function libraryProblems(project) { .map(({ record, link }) => ({ file: record.file, message: `package "${link.library}" is not in system.md libraries` })); } +/** A link covers its file and, unless that file sits at a code root, the file's whole folder: + * a component's styles, hooks and tests live beside it. */ function coverageProblems(project) { - const targets = new Set(linked(project).flatMap(({ link }) => (link && link.library === null ? [link.target] : []))); + const targets = linked(project).flatMap(({ link }) => (link && link.library === null ? [path.posix.normalize(link.target)] : [])); + const roots = codeRoots(project).map((root) => root.replace(TRAILING_SEPARATORS, "")); + const folders = targets.map((target) => path.posix.dirname(target)).filter((folder) => !roots.includes(folder)); return codeFiles(project) - .filter((file) => !targets.has(file)) + .filter((file) => !targets.includes(file) && !folders.some((folder) => isUnder(file, folder))) .map((file) => ({ file, message: "no record links it and no ignore covers it" })); } @@ -132,7 +137,6 @@ const RULES = [ libraryProblems, // C2 coverageProblems, // C3 literalProblems(PATTERNS.colour, SCANNED_EXTENSIONS), // C4 - literalProblems(PATTERNS.unit, STYLESHEET_EXTENSIONS), // C4 sharedLinkProblems, // C5 literalProblems(PATTERNS.physical, STYLESHEET_EXTENSIONS, declaresRightToLeft), // C6 rootProblems, // C7 diff --git a/plugins/aidd-ui/hooks/lib/records.cjs b/plugins/aidd-ui/hooks/lib/records.cjs index d3c7ea9f8..1c40e5be3 100644 --- a/plugins/aidd-ui/hooks/lib/records.cjs +++ b/plugins/aidd-ui/hooks/lib/records.cjs @@ -1,4 +1,4 @@ -// R1 to R16: the shape of each record and how records point at one another. +// 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"); @@ -12,6 +12,7 @@ const NO_REPLACEMENT = "none"; const DEFAULT_STATE = "default"; const INTERACTIVE_STATES = ["hover", "focus", "disabled"]; const KEYBOARD_SECTION = "Keyboard"; +const USAGE_SECTION = "Usage"; const DEPRECATION_SECTION = "Deprecation"; const STEPS_SECTION = "Steps"; const VERDICTS_SECTION = "Verdicts"; @@ -21,7 +22,7 @@ const SYSTEM_ID = "system"; const COMMON_KEYS = ["id", "kind", "status", "version"]; const COMPONENT_KEYS = ["uses", "tokens", "variants", "states", "interactive", "role"]; const KIND_KEYS = { - system: ["platforms", "breakpoints", "themes", "locales", "directions", "code-roots", "ignore", "libraries"], + system: ["platforms", "breakpoints", "themes", "locales", "directions", "primitives", "code-roots", "ignore", "libraries"], brand: [], atom: COMPONENT_KEYS, molecule: COMPONENT_KEYS, @@ -40,6 +41,7 @@ const FOLDER_KINDS = { reviews: ["review"], }; const COMPONENT_KINDS = FOLDER_KINDS.components; +const ASSEMBLED_KINDS = ["molecule", "composition"]; // coded through what they use when no file is their own const DESIGN_KINDS = ["system", "brand", "review"]; const LEVELS = { asset: 0, atom: 1, molecule: 2, composition: 3, screen: 4 }; // an element uses only lower levels @@ -150,10 +152,23 @@ function stateProblems(project) { }); } +/** Whether an assembled element is coded through the components it uses: at least one, all + * coded. Assets are files, never code, so they count for nothing. */ +function codedThroughUses(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 && fm(target).status === "coded"); +} + function codedProblems(project) { + const index = byId(project); return placed(project) .filter((record) => isElement(record) && fm(record).status === "coded" && !("code" in fm(record))) - .map((record) => problem(record, 'status coded needs "code"')); + .filter((record) => !codedThroughUses(record, index)) + .map((record) => + problem(record, ASSEMBLED_KINDS.includes(fm(record).kind) ? 'status coded needs "code", or every element it uses coded' : 'status coded needs "code"') + ); } function deprecatedProblems(project) { @@ -247,6 +262,12 @@ function designStatusProblems(project) { .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`)); +} + const RULES = [ frontMatterProblems, // R1 kindProblems, // R2 @@ -264,6 +285,7 @@ const RULES = [ brandProblems, // R14 assetProblems, // R15 designStatusProblems, // R16 + usageProblems, // R17 ]; /** Every broken record rule, as `{file, message}`, in rule order. */ diff --git a/plugins/aidd-ui/hooks/lib/tokens.cjs b/plugins/aidd-ui/hooks/lib/tokens.cjs index 32d5b9a2e..a68c3f260 100644 --- a/plugins/aidd-ui/hooks/lib/tokens.cjs +++ b/plugins/aidd-ui/hooks/lib/tokens.cjs @@ -1,5 +1,5 @@ -// T1 to T8: tokens.json against the DTCG 2025.10 format and color modules, plus this plugin's -// themes extension and its rem rule. Composite values are checked for presence only. +// T1 to T9: tokens.json against the DTCG 2025.10 format and color modules, plus this plugin's +// themes extension, its rem rule, and components 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"); @@ -204,6 +204,20 @@ function componentTokenProblems(tree, project) { ); } +/** 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 placed(project) + .filter((record) => COMPONENT_KINDS.includes(record.frontMatter.kind)) + .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` })) + ); +} + function remProblems(tree, project) { const at = tokenProblem(project); return [...tree.tokens] @@ -221,6 +235,7 @@ const RULES = [ themeProblems, // T6 componentTokenProblems, // T7 remProblems, // T8 + primitiveProblems, // T9 ]; /** Every broken token rule, as `{file, message}`, in rule order. */ diff --git a/scripts/__tests__/aidd-ui-code.test.js b/scripts/__tests__/aidd-ui-code.test.js index 58d31b4b0..50542a66d 100644 --- a/scripts/__tests__/aidd-ui-code.test.js +++ b/scripts/__tests__/aidd-ui-code.test.js @@ -85,6 +85,19 @@ describe("aidd-ui code link", () => { assert.deepEqual(problems(), ["src/ui/Orphan.tsx: no record links it and no ignore covers it"]); }); + it("covers the folder of a linked file: its styles, hooks and tests", () => { + put(tmp, "src/ui/search/SearchField.tsx", "export function SearchField() {}\n"); + put(tmp, "src/ui/search/search-field.hook.ts", "export const useSearch = () => null;\n"); + put(tmp, "src/ui/search/__tests__/SearchField.test.tsx", "test();\n"); + linkSearch("src/ui/search/SearchField.tsx#SearchField"); + assert.deepEqual(problems(), []); + }); + + it("never lets a file at a code root cover its siblings", () => { + put(tmp, "src/ui/Orphan.css", ".orphan {}\n"); + assert.deepEqual(problems(), ["src/ui/Orphan.css: 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"); @@ -110,19 +123,9 @@ describe("aidd-ui code link", () => { }); }); - describe("C4 unit literals", () => { - it("names a unit literal in a stylesheet", () => { + describe("C4 other literals", () => { + it("leaves a unit literal in code alone, a stated limit", () => { linkedStylesheet(".SearchField { padding: 16px; }\n"); - assert.deepEqual(problems(), [`${SEARCH_CSS}: line 1: literal "16px", only tokens carry values`]); - }); - - it("leaves the lengths of a @container prelude alone", () => { - linkedStylesheet("@container (min-width: 768px) { .SearchField { padding: var(--space-md); } }\n"); - assert.deepEqual(problems(), []); - }); - - it("leaves units in script files alone, a stated limit", () => { - append("src/ui/Button.tsx", 'const style = "padding: 16px";'); assert.deepEqual(problems(), []); }); }); diff --git a/scripts/__tests__/aidd-ui-records.test.js b/scripts/__tests__/aidd-ui-records.test.js index 19f9b4cfe..c1f5cffe9 100644 --- a/scripts/__tests__/aidd-ui-records.test.js +++ b/scripts/__tests__/aidd-ui-records.test.js @@ -216,6 +216,17 @@ describe("aidd-ui records and history", () => { change(HOME, "code: src/ui/HomePage.tsx#HomePage\n", ""); assert.deepEqual(problems(), [`${HOME}: status coded needs "code"`]); }); + + it("accepts a coded molecule without code when everything it uses is coded", () => { + change(SEARCH, "status: validated", "status: coded"); + assert.deepEqual(problems(), []); + }); + + it("names a coded molecule without code that uses a component not coded", () => { + change(SEARCH, "status: validated", "status: coded"); + change(BUTTON, "status: coded", "status: validated"); + assert.deepEqual(problems(), [`${SEARCH}: status coded needs "code", or every element it uses coded`]); + }); }); describe("R9 deprecated", () => { @@ -314,6 +325,13 @@ describe("aidd-ui records and history", () => { } }); + 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"); diff --git a/scripts/__tests__/aidd-ui-tokens-renders.test.js b/scripts/__tests__/aidd-ui-tokens-renders.test.js index 53d8b2a91..96f2efe6a 100644 --- a/scripts/__tests__/aidd-ui-tokens-renders.test.js +++ b/scripts/__tests__/aidd-ui-tokens-renders.test.js @@ -52,7 +52,7 @@ describe("aidd-ui tokens and renders", () => { 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, 7); + assert.equal(names.size, 8); }); describe("T1 JSON", () => { @@ -142,11 +142,18 @@ describe("aidd-ui tokens and renders", () => { describe("T7 component tokens", () => { it("names a component token absent from tokens.json", () => { - edit(tmp, BUTTON, "tokens: [color.brand.primary,", "tokens: [space.huge, color.brand.primary,"); + 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`]); }); }); + describe("T9 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("T8 sizes in rem", () => { it("names a dimension in px", () => { editTokens(tmp, (tree) => (tree.space.md.$value = { value: 16, unit: "px" })); diff --git a/scripts/__tests__/fixtures/aidd-ui/project.js b/scripts/__tests__/fixtures/aidd-ui/project.js index 0d2def8fa..f00e935f4 100644 --- a/scripts/__tests__/fixtures/aidd-ui/project.js +++ b/scripts/__tests__/fixtures/aidd-ui/project.js @@ -15,6 +15,8 @@ const record = (frontMatter, ...sections) => `---\n${frontMatter.join("\n")}\n-- const KEYBOARD = "## Keyboard\n\nTab focuses it, Enter or Space activates it."; +const usage = (when) => `## Usage\n\n${when}`; + const SYSTEM = record( [ "id: system", @@ -26,6 +28,7 @@ const SYSTEM = record( "themes: [light, dark]", "locales: [fr, ar]", "directions: [ltr, rtl]", + "primitives: [color.brand]", "code-roots: [src/ui]", "ignore: [src/ui/tokens.css]", "libraries: [@radix-ui/react-dialog]", @@ -61,6 +64,7 @@ const TOKENS = { }, }, 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" } } }, @@ -86,7 +90,7 @@ const BUTTON = record( "status: coded", "version: 1", "uses: []", - "tokens: [color.brand.primary, color.text.default, space.md, border.thin, motion.fast, font.body, font.weight.bold]", + "tokens: [color.action.default, color.text.default, space.md, border.thin, motion.fast, font.body, font.weight.bold]", "variants: [primary, secondary]", "states: [default, hover, focus, disabled]", "interactive: yes", @@ -94,6 +98,7 @@ const BUTTON = record( "code: src/ui/Button.tsx#Button", ], "# Button", + usage("Any action a person starts; never for navigation."), KEYBOARD, history(FIRST_ROW) ); @@ -112,6 +117,7 @@ const SEARCH_FIELD = record( "role: search", ], "# Search field", + usage("Searching a list; never for a form field."), KEYBOARD, history(FIRST_ROW) ); @@ -131,6 +137,7 @@ const DIALOG = record( "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) ); @@ -142,7 +149,7 @@ const OLD_BUTTON = record( "status: deprecated", "version: 2", "uses: []", - "tokens: [color.brand.primary]", + "tokens: [color.action.default]", "variants: [default]", "states: [default]", "interactive: no", @@ -150,6 +157,7 @@ const OLD_BUTTON = record( "replaced-by: button", ], "# Old button", + usage("Nowhere: replaced by button."), history(FIRST_ROW, ["2", "deprecated for button", "yes", "retire-old-button"]) ); From eecbe0b3fd69f446feb7370f7a237180f7a96899 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Fri, 30 Oct 2026 19:49:36 +0100 Subject: [PATCH 08/26] fix(aidd-ui): fit the format to two real design systems Extracting Gouvernail and Kairos into the format showed rules that fail real projects. A link covers its folder only when the folder bears the file's name, else the files of its stem, so a flat folder no longer hides its siblings. An element may use its own level, never itself. Only focus is required of an interactive component. The rem rule goes. The first theme is the base and a themed token gives every other one; an alias must match its type; a value DTCG cannot hold rides a raw CSS extension; $root is named unsupported instead of dropped. A render may set its own custom properties. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XeK3mD4irAZumku9bknHhD --- .../2026_10_30_aidd-ui-foundation/phase-5.md | 25 +++++-- .../2026_10/2026_10_30_aidd-ui-rework/spec.md | 12 ++-- plugins/aidd-ui/README.md | 7 +- plugins/aidd-ui/hooks/lib/code.cjs | 18 +++-- plugins/aidd-ui/hooks/lib/records.cjs | 9 +-- plugins/aidd-ui/hooks/lib/renders.cjs | 15 +++-- plugins/aidd-ui/hooks/lib/tokens.cjs | 65 +++++++++++-------- scripts/__tests__/aidd-ui-code.test.js | 17 ++--- scripts/__tests__/aidd-ui-records.test.js | 22 +++++-- .../__tests__/aidd-ui-tokens-renders.test.js | 60 ++++++++++++++--- scripts/__tests__/aidd-ui-validate.test.js | 4 +- 11 files changed, 173 insertions(+), 81 deletions(-) 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 index 8a9acb0d4..00ee1c282 100644 --- 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 @@ -31,12 +31,16 @@ status: done | ------------ | ----------------------- | -------------------- | ------------------------------- | | 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 | C3: a link to `src/ui/search/SearchField.tsx` beside a hook and a `__tests__/` file | no problem: the link covers its folder | system | -| Edge case | C3: `src/ui/Orphan.css` beside `src/ui/Button.tsx` at the code root | one problem: a file at a root covers only itself | 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 | T9: button citing `color.brand.primary` | 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 @@ -45,7 +49,7 @@ status: done > The spec follows what Gouvernail showed, and stays lean. -1. `spec.md`: a link covers its 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. +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 @@ -53,12 +57,21 @@ status: done 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`: T9 names a component token under a `primitives` group. +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 | 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 | +| 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-rework/spec.md b/aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-rework/spec.md index 1425aa710..0a3e1037d 100644 --- 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 @@ -37,7 +37,7 @@ Chaque fiche porte en tête : `id` (égal au nom du fichier, unique), `kind`, `s | `system.md` | version du design system, plateformes, breakpoints, thèmes, langues et sens d'écriture, groupes de tokens primitifs, dossiers UI du code, 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 ; survol, focus et désactivé quand il est interactif ; chargement ou erreur quand ils existent), comportement clavier et rôle accessible, lien vers le code | +| 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, é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 et par état, commentaire, écrans restants | @@ -54,7 +54,8 @@ Chaque fiche porte en tête : `id` (égal au nom du fichier, unique), `kind`, `s ### Lien avec le code -- Un élément correspond à un seul fichier d'entrée, identifié par son chemin et le nom du composant exporté. Ce lien couvre aussi le dossier du fichier (styles, hooks, tests), sauf quand le fichier est à la racine d'un dossier UI. +- 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. @@ -83,6 +84,9 @@ Chaque fiche porte en tête : `id` (égal au nom du fichier, unique), `kind`, `s - É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 n'utilise que des sémantiques. +- `tokens.json` est la seule source des tokens ; ceux du code en sont générés. +- Le premier thème déclaré est la base ; un token qui varie selon le thème donne une valeur pour chacun des autres. 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. - 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. @@ -98,7 +102,7 @@ Chaque fiche porte en tête : `id` (égal au nom du fichier, unique), `kind`, `s - `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, tailles en `rem`, aucun comportement qui n'existe qu'au survol sur écran tactile. +- 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 @@ -112,7 +116,7 @@ Chaque fiche porte en tête : `id` (égal au nom du fichier, unique), `kind`, `s - versions en double dans un historique ; - propriétés physiques quand un sens droite-gauche est déclaré. - 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, mots interdits par le glossaire, textes trop longs dans la langue la plus longue, accessibilité. +- 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 diff --git a/plugins/aidd-ui/README.md b/plugins/aidd-ui/README.md index ddb153275..57f74f502 100644 --- a/plugins/aidd-ui/README.md +++ b/plugins/aidd-ui/README.md @@ -47,9 +47,12 @@ Every record opens with flat front matter, `key: value` or `key: [a, b]`, and cl | review | `scope`, `screens` | `## Verdicts`: Screen, State, Verdict (`ok`, `ko`, `pending`), Comment | - `code` is `path#Export`, or `package:#Export` for a library component; required once `coded`, except for a molecule or composition whose used components are all coded. -- A link covers its file's folder too, styles, hooks and tests, unless the file sits at a code root. +- 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 theme value is a token extension: `"$extensions": {"dev.ai-driven.themes": {"dark": }}`. +- A theme value is a token extension: `"$extensions": {"dev.ai-driven.themes": {"dark": }}`. The first theme in `system.md` is the base, held by `$value`; a themed token gives every other one. +- A value DTCG cannot hold, such as `color-mix()` or `100%`, is raw CSS: `"$extensions": {"dev.ai-driven.css": ""}`, checked for presence only. +- `$root` is not supported: name the token. - A token's CSS name is `--` plus its path joined by `-`. ## Validation diff --git a/plugins/aidd-ui/hooks/lib/code.cjs b/plugins/aidd-ui/hooks/lib/code.cjs index 9230a2193..affd80b8a 100644 --- a/plugins/aidd-ui/hooks/lib/code.cjs +++ b/plugins/aidd-ui/hooks/lib/code.cjs @@ -17,7 +17,6 @@ const SCANNED_EXTENSIONS = [...STYLESHEET_EXTENSIONS, ...SCRIPT_EXTENSIONS, ".ht const BLANK = " "; const CURRENT_FOLDER = "."; const PARENT_FOLDER = ".."; -const TRAILING_SEPARATORS = /\/+$/; const REGEX_SPECIALS = /[.*+?^${}()|[\]\\]/g; const NOT_LINE_BREAK = /[^\n]/g; @@ -95,14 +94,21 @@ function libraryProblems(project) { .map(({ record, link }) => ({ file: record.file, message: `package "${link.library}" is not in system.md libraries` })); } -/** A link covers its file and, unless that file sits at a code root, the file's whole folder: - * a component's styles, hooks and tests live beside it. */ +/** 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); +} + function coverageProblems(project) { const targets = linked(project).flatMap(({ link }) => (link && link.library === null ? [path.posix.normalize(link.target)] : [])); - const roots = codeRoots(project).map((root) => root.replace(TRAILING_SEPARATORS, "")); - const folders = targets.map((target) => path.posix.dirname(target)).filter((folder) => !roots.includes(folder)); return codeFiles(project) - .filter((file) => !targets.includes(file) && !folders.some((folder) => isUnder(file, folder))) + .filter((file) => !targets.some((target) => covers(target, file))) .map((file) => ({ file, message: "no record links it and no ignore covers it" })); } diff --git a/plugins/aidd-ui/hooks/lib/records.cjs b/plugins/aidd-ui/hooks/lib/records.cjs index 1c40e5be3..97d265bc3 100644 --- a/plugins/aidd-ui/hooks/lib/records.cjs +++ b/plugins/aidd-ui/hooks/lib/records.cjs @@ -10,7 +10,7 @@ const ASSET_TYPES = ["icon", "image", "illustration"]; const VERDICTS = ["ok", "ko", "pending"]; const NO_REPLACEMENT = "none"; const DEFAULT_STATE = "default"; -const INTERACTIVE_STATES = ["hover", "focus", "disabled"]; +const INTERACTIVE_STATE = "focus"; // hover and disabled only when they exist const KEYBOARD_SECTION = "Keyboard"; const USAGE_SECTION = "Usage"; const DEPRECATION_SECTION = "Deprecation"; @@ -43,7 +43,7 @@ const FOLDER_KINDS = { const COMPONENT_KINDS = FOLDER_KINDS.components; const ASSEMBLED_KINDS = ["molecule", "composition"]; // coded through what they use when no file is their own const DESIGN_KINDS = ["system", "brand", "review"]; -const LEVELS = { asset: 0, atom: 1, molecule: 2, composition: 3, screen: 4 }; // an element uses only lower levels +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+)$/; @@ -131,9 +131,10 @@ function usesProblems(project) { 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}"`)]; + return LEVELS[usedKind] <= LEVELS[kind] ? [] : [problem(record, `${kind} cannot use ${usedKind} "${id}"`)]; }) ); } @@ -146,7 +147,7 @@ function stateProblems(project) { const interactive = fm(record).interactive === "yes"; return [ ...(states.includes(DEFAULT_STATE) ? [] : [`component lacks state "${DEFAULT_STATE}"`]), - ...(interactive ? INTERACTIVE_STATES.filter((state) => !states.includes(state)).map((state) => `interactive component lacks state "${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)); }); diff --git a/plugins/aidd-ui/hooks/lib/renders.cjs b/plugins/aidd-ui/hooks/lib/renders.cjs index 08e84e4d3..6bbb774c1 100644 --- a/plugins/aidd-ui/hooks/lib/renders.cjs +++ b/plugins/aidd-ui/hooks/lib/renders.cjs @@ -11,6 +11,7 @@ const RIGHT_TO_LEFT = "rtl"; 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 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 @@ -65,17 +66,19 @@ function coverageProblems(project) { } function referenceProblems(project) { - const names = cssNames(project); - if (names === null) return []; - return project.renders.flatMap((file) => - readRender(project, file) + 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` })) - ) - ); + ); + }); } const literalProblems = (pattern, applies = () => true) => (project) => diff --git a/plugins/aidd-ui/hooks/lib/tokens.cjs b/plugins/aidd-ui/hooks/lib/tokens.cjs index a68c3f260..f8b326251 100644 --- a/plugins/aidd-ui/hooks/lib/tokens.cjs +++ b/plugins/aidd-ui/hooks/lib/tokens.cjs @@ -1,17 +1,19 @@ -// T1 to T9: tokens.json against the DTCG 2025.10 format and color modules, plus this plugin's -// themes extension, its rem rule, and components citing semantic tokens only. Composite values are checked for presence only. +// T1 to T8: tokens.json against the DTCG 2025.10 format and color modules, plus this plugin's +// themes and raw CSS extensions, and components 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, COMPONENT_KINDS } = require("./records.cjs"); const { text } = require("./project.cjs"); -const RESERVED_KEYS = ["$value", "$type", "$description", "$extensions", "$deprecated", "$extends", "$schema", "$root"]; +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 CSS_PREFIX = "--"; const CSS_SEPARATOR = "-"; const THEMES_EXTENSION = "dev.ai-driven.themes"; +const RAW_CSS_EXTENSION = "dev.ai-driven.css"; // a CSS value DTCG cannot hold, such as color-mix() or 100% const MISSING = "missing"; const NOT_JSON = "not JSON"; @@ -23,8 +25,6 @@ const COLOR_COMPONENTS = 3; const NO_COMPONENT = "none"; const DIMENSION_UNITS = ["px", "rem"]; const DURATION_UNITS = ["ms", "s"]; -const SIZE_UNIT = "rem"; -const PIXEL_UNIT = "px"; 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"]; @@ -47,7 +47,8 @@ function walk(node, path, groupType, found) { const child = node[key]; const childPath = [...path, key]; if (key.startsWith(PROPERTY_PREFIX)) { - if (!RESERVED_KEYS.includes(key)) found.badNames.push({ path: childPath.join(PATH_SEPARATOR), message: `"${key}" is not a reserved key` }); + 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 "}"' }); @@ -81,6 +82,10 @@ function read(project) { return isObject(tree) ? walk(tree, [], tree.$type, emptyTree()) : unusable(NOT_JSON); } +/** 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. */ function typeOf(tree, token, depth = 0) { if (token.node.$type !== undefined) return token.node.$type; @@ -151,7 +156,9 @@ function nameProblems(tree, project) { 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)) 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`)]; @@ -161,36 +168,47 @@ function typeProblems(tree, project) { function shapeProblems(tree, project) { const at = tokenProblem(project); return [...tree.tokens].flatMap(([name, token]) => { - const message = "$value" in token.node ? shapeProblem(typeOf(tree, token), token.node.$value) : null; + const message = "$value" in token.node && !isRaw(token) ? shapeProblem(typeOf(tree, token), token.node.$value) : null; return message ? [at(name, message)] : []; }); } -function aliasProblem(tree, value) { +/** What is wrong with an alias, or null: its target must be a token, of `type` when given. */ +function aliasProblem(tree, value, type) { const target = aliasOf(value); - if (target === undefined || tree.tokens.has(target)) return null; - return tree.groups.has(target) ? `alias {${target}} names a group, not a token` : `alias {${target}} names no token`; + 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`; + 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); + const message = aliasProblem(tree, token.node.$value, token.node.$type ?? token.groupType); return message ? [at(name, message)] : []; }); } +/** The first theme system.md declares is the base, held by `$value`; a themed token gives + * every other one. */ function themeProblems(tree, project) { const at = tokenProblem(project); const system = systemOf(project); - const declared = list(system?.frontMatter.themes); - return [...tree.tokens].flatMap(([name, token]) => - Object.entries(themesOf(token)).flatMap(([theme, value]) => { - if (system && !declared.includes(theme)) return [at(name, `theme "${theme}" is not in system.md`)]; - const message = shapeProblem(typeOf(tree, token), value) ?? aliasProblem(tree, value); + const [base, ...others] = list(system?.frontMatter.themes); + return [...tree.tokens].flatMap(([name, token]) => { + const themes = themesOf(token); + if (Object.keys(themes).length === 0) return []; + const missing = system ? others.filter((theme) => !(theme in themes)).map((theme) => at(name, `no value for theme "${theme}"`)) : []; + const values = Object.entries(themes).flatMap(([theme, value]) => { + if (system && theme === base) return [at(name, `theme "${theme}" is the base, its value is $value`)]; + if (system && !others.includes(theme)) return [at(name, `theme "${theme}" is not in system.md`)]; + const type = typeOf(tree, token); + const message = shapeProblem(type, value) ?? aliasProblem(tree, value, type); return message ? [at(name, `theme "${theme}": ${message}`)] : []; - }) - ); + }); + return [...missing, ...values]; + }); } function componentTokenProblems(tree, project) { @@ -218,14 +236,6 @@ function primitiveProblems(tree, project) { ); } -function remProblems(tree, project) { - const at = tokenProblem(project); - return [...tree.tokens] - .filter(([, token]) => typeOf(tree, token) === "dimension") - .filter(([, token]) => [token.node.$value, ...Object.values(themesOf(token))].some((value) => isObject(value) && value.unit === PIXEL_UNIT)) - .map(([name]) => at(name, `sizes are in ${SIZE_UNIT}, not ${PIXEL_UNIT}`)); -} - const RULES = [ jsonProblems, // T1 nameProblems, // T2 @@ -234,8 +244,7 @@ const RULES = [ aliasProblems, // T5 themeProblems, // T6 componentTokenProblems, // T7 - remProblems, // T8 - primitiveProblems, // T9 + primitiveProblems, // T8 ]; /** Every broken token rule, as `{file, message}`, in rule order. */ diff --git a/scripts/__tests__/aidd-ui-code.test.js b/scripts/__tests__/aidd-ui-code.test.js index 50542a66d..cd4c6b633 100644 --- a/scripts/__tests__/aidd-ui-code.test.js +++ b/scripts/__tests__/aidd-ui-code.test.js @@ -85,17 +85,18 @@ describe("aidd-ui code link", () => { assert.deepEqual(problems(), ["src/ui/Orphan.tsx: no record links it and no ignore covers it"]); }); - it("covers the folder of a linked file: its styles, hooks and tests", () => { - put(tmp, "src/ui/search/SearchField.tsx", "export function SearchField() {}\n"); - put(tmp, "src/ui/search/search-field.hook.ts", "export const useSearch = () => null;\n"); - put(tmp, "src/ui/search/__tests__/SearchField.test.tsx", "test();\n"); - linkSearch("src/ui/search/SearchField.tsx#SearchField"); + 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("never lets a file at a code root cover its siblings", () => { - put(tmp, "src/ui/Orphan.css", ".orphan {}\n"); - assert.deepEqual(problems(), ["src/ui/Orphan.css: no record links it and no ignore covers it"]); + 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", () => { diff --git a/scripts/__tests__/aidd-ui-records.test.js b/scripts/__tests__/aidd-ui-records.test.js index c1f5cffe9..058e45006 100644 --- a/scripts/__tests__/aidd-ui-records.test.js +++ b/scripts/__tests__/aidd-ui-records.test.js @@ -177,22 +177,32 @@ describe("aidd-ui records and history", () => { }); const levels = [ - [OLD, "uses: []", "uses: [button]", 'atom cannot use atom "button"'], + [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]", 'screen cannot use screen "home"'], + [HOME, "uses: [search-field, dialog]", "uses: [search-field, dialog, home]", "uses itself"], ]; for (const [file, from, to, message] of levels) { - it(`names a ${message.split(" ")[0]} using its own level or above`, () => { + 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 hover", () => { - change(BUTTON, "states: [default, hover, focus, disabled]", "states: [default, focus, disabled]"); - assert.deepEqual(problems(), [`${BUTTON}: interactive component lacks state "hover"`]); + 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", () => { diff --git a/scripts/__tests__/aidd-ui-tokens-renders.test.js b/scripts/__tests__/aidd-ui-tokens-renders.test.js index 96f2efe6a..3729bb115 100644 --- a/scripts/__tests__/aidd-ui-tokens-renders.test.js +++ b/scripts/__tests__/aidd-ui-tokens-renders.test.js @@ -121,6 +121,16 @@ describe("aidd-ui tokens and renders", () => { 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 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", () => { @@ -131,7 +141,24 @@ describe("aidd-ui tokens and renders", () => { themes(tree).sepia = themes(tree).dark; delete themes(tree).dark; }); - assert.deepEqual(problems(), [`${TOKENS}: color.brand.primary: theme "sepia" is not in system.md`]); + assert.deepEqual(problems(), [ + `${TOKENS}: color.brand.primary: no value for theme "dark"`, + `${TOKENS}: color.brand.primary: theme "sepia" is not in system.md`, + ]); + }); + + it("names a themed token lacking a declared theme", () => { + editTokens(tmp, (tree) => delete themes(tree).dark); + editTokens(tmp, (tree) => (themes(tree).light = themes(tree).dark ?? { colorSpace: "srgb", components: [1, 1, 1] })); + assert.deepEqual(problems(), [ + `${TOKENS}: color.brand.primary: no value for theme "dark"`, + `${TOKENS}: color.brand.primary: theme "light" is the base, its value is $value`, + ]); + }); + + it("names a theme value aliasing a token of another type", () => { + editTokens(tmp, (tree) => (themes(tree).dark = "{space.md}")); + assert.deepEqual(problems(), [`${TOKENS}: color.brand.primary: theme "dark": alias {space.md} is a dimension, not a color`]); }); it("names a theme value of the wrong shape", () => { @@ -147,17 +174,27 @@ describe("aidd-ui tokens and renders", () => { }); }); - describe("T9 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("raw CSS values", () => { + 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 sizes in rem", () => { - it("names a dimension in px", () => { - editTokens(tmp, (tree) => (tree.space.md.$value = { value: 16, unit: "px" })); - assert.deepEqual(problems(), [`${TOKENS}: space.md: sizes are in rem, not px`]); + 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`]); }); }); @@ -185,6 +222,11 @@ describe("aidd-ui tokens and renders", () => { 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", () => { diff --git a/scripts/__tests__/aidd-ui-validate.test.js b/scripts/__tests__/aidd-ui-validate.test.js index fe21889d9..a83e59482 100644 --- a/scripts/__tests__/aidd-ui-validate.test.js +++ b/scripts/__tests__/aidd-ui-validate.test.js @@ -19,7 +19,7 @@ const TOKENS = `${UI}/tokens.json`; /** One mutation from each family: records, tokens, code. */ const FAILURES = [ `✗ ${BRAND}: lacks "## Users"`, - `✗ ${TOKENS}: space.md: sizes are in rem, not px`, + `✗ ${TOKENS}: space.md: unit "em" must be px or rem`, `✗ ${DIALOG}: package "left-pad" is not in system.md libraries`, ]; @@ -37,7 +37,7 @@ afterEach(() => { function breakOnePerFamily() { edit(tmp, BRAND, "## Users\n\nAnalysts.\n\n", ""); - editTokens(tmp, (tree) => (tree.space.md.$value = { value: 16, unit: "px" })); + editTokens(tmp, (tree) => (tree.space.md.$value = { value: 1, unit: "em" })); edit(tmp, DIALOG, "package:@radix-ui/react-dialog#Root", "package:left-pad#Pad"); } From 1e1f07679caaa5626d64104eee35512e4dbdbe51 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Fri, 30 Oct 2026 21:35:32 +0100 Subject: [PATCH 09/26] fix(aidd-ui): hand problems back on Codex and Copilot, not only Claude Code The hook read only Claude Code's tool_input.file_path and answered only on stderr. Codex sends apply_patch's patch in tool_input.command; Copilot sends either a patch string as tool_input or {path, old_str, new_str}, and reads only additionalContext on stdout. The hook now reads every one of these shapes and answers on both channels, with exit 2. Verified against real hosts: a token added to a component without a definition is quoted back by the model on Claude Code, Codex and Copilot CLI. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XeK3mD4irAZumku9bknHhD --- plugins/aidd-ui/README.md | 6 ++-- plugins/aidd-ui/hooks/ui-hook.cjs | 27 ++++++++++++----- scripts/__tests__/aidd-ui-validate.test.js | 35 +++++++++++++++++++--- 3 files changed, 54 insertions(+), 14 deletions(-) diff --git a/plugins/aidd-ui/README.md b/plugins/aidd-ui/README.md index 57f74f502..feb782358 100644 --- a/plugins/aidd-ui/README.md +++ b/plugins/aidd-ui/README.md @@ -68,9 +68,9 @@ It prints `VALID`, or one `✗ : ` line per problem and exits 1. ## Limits - Without `node` on `PATH`, the hook does nothing. -- Outside Claude Code, a problem does not come back in the same turn. -- Codex runs the hook only once you trust it. -- The write matcher uses Claude Code's tool names; other hosts are unverified. +- A problem comes back in the same turn on Claude Code, Codex and Copilot CLI. On OpenCode and Kilo it does not; 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. - Composite tokens are checked for a `$value`, not for its shape. - Alias cycles are not detected. - `code-roots` and `ignore` are path prefixes, not globs. diff --git a/plugins/aidd-ui/hooks/ui-hook.cjs b/plugins/aidd-ui/hooks/ui-hook.cjs index 7e1dd0d47..204c5749a 100644 --- a/plugins/aidd-ui/hooks/ui-hook.cjs +++ b/plugins/aidd-ui/hooks/ui-hook.cjs @@ -1,7 +1,8 @@ // PostToolUse and SessionStart: runs the design system checks. After a write under -// aidd_docs/ui/ or a code root, problems go back to the agent on stderr with exit 2; at session -// start they are printed as context. A file that cannot be read is one problem; anything else -// unexpected exits 0 in silence, so a broken check never blocks a session. +// aidd_docs/ui/ or a code root, problems go back to the agent with exit 2: on stderr, which +// Claude Code and Codex read, and as additionalContext on stdout, the only channel Copilot +// reads. At session start they are printed as context. 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 path = require("node:path"); const { load, relative, isUnder, UI_DIR } = require("./lib/project.cjs"); @@ -11,7 +12,9 @@ const { problemsOf, format } = require("./validate-ui.cjs"); const POST_TOOL_USE = "PostToolUse"; const SESSION_START = "SessionStart"; const EXIT_SILENT = 0; -const EXIT_BLOCKING = 2; // Claude Code hands a PostToolUse hook's stderr back to the agent +const EXIT_BLOCKING = 2; +// 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 object on stdin, or null when there is none or it is not one. */ function readPayload() { @@ -29,12 +32,22 @@ function isWatched(project, filePath) { return [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].trim()); +} + function afterWrite(project, payload) { - const filePath = payload.tool_input?.file_path; - if (typeof filePath !== "string" || !isWatched(project, filePath)) return EXIT_SILENT; + if (!writtenFiles(payload.tool_input).some((file) => isWatched(project, file))) return EXIT_SILENT; const problems = problemsOf(project); if (!problems.length) return EXIT_SILENT; - process.stderr.write(`${format(problems).join("\n")}\n`); + const text = format(problems).join("\n"); + process.stderr.write(`${text}\n`); + process.stdout.write(JSON.stringify({ additionalContext: text })); return EXIT_BLOCKING; } diff --git a/scripts/__tests__/aidd-ui-validate.test.js b/scripts/__tests__/aidd-ui-validate.test.js index a83e59482..a626ff780 100644 --- a/scripts/__tests__/aidd-ui-validate.test.js +++ b/scripts/__tests__/aidd-ui-validate.test.js @@ -54,8 +54,14 @@ function hook(input) { } const postToolUse = (filePath) => JSON.stringify({ hook_event_name: "PostToolUse", cwd: tmp, tool_name: "Write", tool_input: { file_path: filePath } }); +/** 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, tool_name: "apply_patch", tool_input: { command: patch(...headers) } }); +const copilotPatch = (...headers) => JSON.stringify({ hook_event_name: "PostToolUse", cwd: tmp, tool_name: "Edit", tool_input: patch(...headers) }); const sessionStart = () => JSON.stringify({ hook_event_name: "SessionStart", cwd: tmp, source: "startup" }); const silent = { code: 0, out: "", err: "" }; +// Claude Code and Codex read stderr at exit 2, Copilot only the additionalContext on stdout. +const handedBack = (problems) => ({ code: 2, out: JSON.stringify({ additionalContext: problems.join("\n") }), err: `${problems.join("\n")}\n` }); describe("aidd-ui validator entries", () => { it("prints VALID for the valid project", () => { @@ -79,18 +85,39 @@ describe("aidd-ui validator entries", () => { 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))), { code: 2, out: "", err: `${FAILURES.join("\n")}\n` }); + assert.deepEqual(hook(postToolUse(path.join(tmp, BRAND))), handedBack(FAILURES)); }); 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"))), { code: 2, out: "", err: `${FAILURES.join("\n")}\n` }); + assert.deepEqual(hook(postToolUse(path.join(tmp, "src/ui/Button.tsx"))), handedBack(FAILURES)); }); 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"))), { code: 2, out: "", err: `${FAILURES.join("\n")}\n` }); + assert.deepEqual(hook(postToolUse(path.join(tmp, "src/ui/Button.tsx"))), handedBack(FAILURES)); + }); + + 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)}`)), handedBack(FAILURES)); + }); + + 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)}`)), handedBack(FAILURES)); + }); + + 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" } }); + assert.deepEqual(hook(input), handedBack(FAILURES)); + }); + + it("stays silent when a patch touches only files elsewhere", () => { + breakOnePerFamily(); + assert.deepEqual(hook(codexPatch(`Update File: ${path.join(tmp, "README.md")}`)), silent); }); it("stays silent when a write lands elsewhere", () => { @@ -136,7 +163,7 @@ describe("aidd-ui validator entries", () => { 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 problems\n${UNREADABLE}\n`, err: "" }); - assert.deepEqual(hook(postToolUse(path.join(tmp, BRAND))), { code: 2, out: "", err: `${UNREADABLE}\n` }); + assert.deepEqual(hook(postToolUse(path.join(tmp, BRAND))), handedBack([UNREADABLE])); }); it("names an unreadable file under a code root", () => { From 1ed8965b54e9631775823c7710edd834cbe86b52 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 31 Oct 2026 05:39:02 +0100 Subject: [PATCH 10/26] fix(aidd-ui): say the write was saved when handing problems back A Codex model read exit 2 after a write as a refused edit and reported no file changed, though the file was written. The hand-back now opens with a line saying the write landed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XeK3mD4irAZumku9bknHhD --- plugins/aidd-ui/hooks/ui-hook.cjs | 3 ++- scripts/__tests__/aidd-ui-validate.test.js | 6 +++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/plugins/aidd-ui/hooks/ui-hook.cjs b/plugins/aidd-ui/hooks/ui-hook.cjs index 204c5749a..9fc9341e8 100644 --- a/plugins/aidd-ui/hooks/ui-hook.cjs +++ b/plugins/aidd-ui/hooks/ui-hook.cjs @@ -45,7 +45,8 @@ function afterWrite(project, payload) { if (!writtenFiles(payload.tool_input).some((file) => isWatched(project, file))) return EXIT_SILENT; const problems = problemsOf(project); if (!problems.length) return EXIT_SILENT; - const text = format(problems).join("\n"); + // Exit 2 after a write reads as a refused edit to some models: say the write landed. + const text = [`aidd-ui: the write was saved, fix ${problems.length} problems:`, ...format(problems)].join("\n"); process.stderr.write(`${text}\n`); process.stdout.write(JSON.stringify({ additionalContext: text })); return EXIT_BLOCKING; diff --git a/scripts/__tests__/aidd-ui-validate.test.js b/scripts/__tests__/aidd-ui-validate.test.js index a626ff780..a3c8c94df 100644 --- a/scripts/__tests__/aidd-ui-validate.test.js +++ b/scripts/__tests__/aidd-ui-validate.test.js @@ -61,7 +61,11 @@ const copilotPatch = (...headers) => JSON.stringify({ hook_event_name: "PostTool const sessionStart = () => JSON.stringify({ hook_event_name: "SessionStart", cwd: tmp, source: "startup" }); const silent = { code: 0, out: "", err: "" }; // Claude Code and Codex read stderr at exit 2, Copilot only the additionalContext on stdout. -const handedBack = (problems) => ({ code: 2, out: JSON.stringify({ additionalContext: problems.join("\n") }), err: `${problems.join("\n")}\n` }); +// The first line says the write landed: a Codex model read a bare exit 2 as a refused edit. +const handedBack = (problems) => { + const text = [`aidd-ui: the write was saved, fix ${problems.length} problems:`, ...problems].join("\n"); + return { code: 2, out: JSON.stringify({ additionalContext: text }), err: `${text}\n` }; +}; describe("aidd-ui validator entries", () => { it("prints VALID for the valid project", () => { From 8920c9bbfcce1db1ab1c2e43899a1b014809fc6a Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 31 Oct 2026 05:46:38 +0100 Subject: [PATCH 11/26] docs(aidd-ui): note that codex shows a handed-back problem as a blocked edit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XeK3mD4irAZumku9bknHhD --- plugins/aidd-ui/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/aidd-ui/README.md b/plugins/aidd-ui/README.md index feb782358..58c50d53c 100644 --- a/plugins/aidd-ui/README.md +++ b/plugins/aidd-ui/README.md @@ -69,6 +69,7 @@ It prints `VALID`, or one `✗ : ` line per problem and exits 1. - Without `node` on `PATH`, the hook does nothing. - A problem comes back in the same turn on Claude Code, Codex and Copilot CLI. On OpenCode and Kilo it does not; Cursor is unverified. +- Codex shows the problem as a blocked edit, though the file is written; a small model may report the write as failed. - 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. - Composite tokens are checked for a `$value`, not for its shape. From 645ba0c8bf431de3b316aaf45601c41f6a2a1a0e Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 31 Oct 2026 07:36:47 +0100 Subject: [PATCH 12/26] fix(aidd-ui): hand problems back at turn end, one feedback shape per host Exit 2 after a write read as a refused edit on Codex. The hook now exits 0 and answers in the shape each host reads: hookSpecificOutput.additionalContext on Claude Code and Codex, top-level additionalContext on Copilot. A write marks the turn; Stop blocks once while problems remain, and not again on a forced continuation. Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01XeK3mDvAh1xZvZGAKEVhJn --- plugins/aidd-ui/README.md | 5 +- plugins/aidd-ui/hooks/ui-hook.cjs | 52 ++++++++--- scripts/__tests__/aidd-ui-validate.test.js | 100 +++++++++++++++++---- 3 files changed, 128 insertions(+), 29 deletions(-) diff --git a/plugins/aidd-ui/README.md b/plugins/aidd-ui/README.md index 58c50d53c..78bcba4ad 100644 --- a/plugins/aidd-ui/README.md +++ b/plugins/aidd-ui/README.md @@ -68,8 +68,9 @@ It prints `VALID`, or one `✗ : ` line per problem and exits 1. ## Limits - Without `node` on `PATH`, the hook does nothing. -- A problem comes back in the same turn on Claude Code, Codex and Copilot CLI. On OpenCode and Kilo it does not; Cursor is unverified. -- Codex shows the problem as a blocked edit, though the file is written; a small model may report the write as failed. +- After a write under the design system or a code root, the problems go back to the model in the same turn on Claude Code, Codex and Copilot CLI. +- At the end of a turn that wrote such a file, the hook blocks once while problems remain. A forced continuation is never blocked again. The mark lives in the OS temp directory; without a session id, no end-of-turn check runs. +- On OpenCode and Kilo the problems do not reach the model; OpenCode V2 runs the hook but its answer does not reach the model either. 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. - Composite tokens are checked for a `$value`, not for its shape. diff --git a/plugins/aidd-ui/hooks/ui-hook.cjs b/plugins/aidd-ui/hooks/ui-hook.cjs index 9fc9341e8..58a77b5a3 100644 --- a/plugins/aidd-ui/hooks/ui-hook.cjs +++ b/plugins/aidd-ui/hooks/ui-hook.cjs @@ -1,9 +1,10 @@ -// PostToolUse and SessionStart: runs the design system checks. After a write under -// aidd_docs/ui/ or a code root, problems go back to the agent with exit 2: on stderr, which -// Claude Code and Codex read, and as additionalContext on stdout, the only channel Copilot -// reads. At session start they are printed as context. A file that cannot be read is one -// problem; anything else unexpected exits 0 in silence, so a broken check never blocks a session. +// 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 come back as context. At Stop, a marked turn gets +// its problems back once, as the reason to keep working. At session start they are printed as +// context. 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"); @@ -11,8 +12,10 @@ const { problemsOf, format } = require("./validate-ui.cjs"); const POST_TOOL_USE = "PostToolUse"; const SESSION_START = "SessionStart"; +const STOP = "Stop"; const EXIT_SILENT = 0; -const EXIT_BLOCKING = 2; +// 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; @@ -41,15 +44,43 @@ function writtenFiles(toolInput) { return [...patch.matchAll(PATCHED_FILE)].map((match) => match[1].trim()); } +/** 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}`); +} + +/** 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); +} + function afterWrite(project, payload) { if (!writtenFiles(payload.tool_input).some((file) => isWatched(project, file))) return EXIT_SILENT; + const mark = markFile(payload.session_id); + if (mark) fs.writeFileSync(mark, ""); const problems = problemsOf(project); if (!problems.length) return EXIT_SILENT; - // Exit 2 after a write reads as a refused edit to some models: say the write landed. const text = [`aidd-ui: the write was saved, fix ${problems.length} problems:`, ...format(problems)].join("\n"); - process.stderr.write(`${text}\n`); - process.stdout.write(JSON.stringify({ additionalContext: text })); - return EXIT_BLOCKING; + // 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; +} + +/** Blocks the turn's end once while a marked turn still leaves problems behind. */ +function atStop(project, payload) { + const mark = markFile(payload.session_id); + if (!mark || !fs.existsSync(mark)) return EXIT_SILENT; + fs.rmSync(mark, { force: true }); + if (payload.stop_hook_active === true) return EXIT_SILENT; + const problems = problemsOf(project); + if (!problems.length) return EXIT_SILENT; + const reason = [`aidd-ui: the design system has ${problems.length} problems, fix them before you finish:`, ...format(problems)].join("\n"); + process.stdout.write(JSON.stringify({ decision: "block", reason })); + return EXIT_SILENT; } function atSessionStart(project) { @@ -65,6 +96,7 @@ function run() { 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, payload); return EXIT_SILENT; } diff --git a/scripts/__tests__/aidd-ui-validate.test.js b/scripts/__tests__/aidd-ui-validate.test.js index a3c8c94df..c96194f55 100644 --- a/scripts/__tests__/aidd-ui-validate.test.js +++ b/scripts/__tests__/aidd-ui-validate.test.js @@ -24,14 +24,18 @@ const 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); }); @@ -53,19 +57,21 @@ function hook(input) { return { code: run.status, out: run.stdout, err: run.stderr }; } -const postToolUse = (filePath) => JSON.stringify({ hook_event_name: "PostToolUse", cwd: tmp, tool_name: "Write", tool_input: { file_path: filePath } }); +// 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, tool_name: "apply_patch", tool_input: { command: patch(...headers) } }); -const copilotPatch = (...headers) => JSON.stringify({ hook_event_name: "PostToolUse", cwd: tmp, tool_name: "Edit", tool_input: patch(...headers) }); +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: "" }; -// Claude Code and Codex read stderr at exit 2, Copilot only the additionalContext on stdout. -// The first line says the write landed: a Codex model read a bare exit 2 as a refused edit. -const handedBack = (problems) => { - const text = [`aidd-ui: the write was saved, fix ${problems.length} problems:`, ...problems].join("\n"); - return { code: 2, out: JSON.stringify({ additionalContext: text }), err: `${text}\n` }; -}; +const feedbackText = (problems) => [`aidd-ui: the write was saved, fix ${problems.length} problems:`, ...problems].join("\n"); +// Claude Code and Codex read additionalContext under hookSpecificOutput; Codex fails the hook on any other key. +const claudeFeedback = (problems) => ({ code: 0, out: JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: feedbackText(problems) } }), err: "" }); +// Copilot reads only a top-level additionalContext. +const copilotFeedback = (problems) => ({ code: 0, out: JSON.stringify({ additionalContext: feedbackText(problems) }), err: "" }); +const finish = (problems) => ({ code: 0, out: JSON.stringify({ decision: "block", reason: [`aidd-ui: the design system has ${problems.length} problems, fix them before you finish:`, ...problems].join("\n") }), err: "" }); describe("aidd-ui validator entries", () => { it("prints VALID for the valid project", () => { @@ -89,34 +95,34 @@ describe("aidd-ui validator entries", () => { 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))), handedBack(FAILURES)); + assert.deepEqual(hook(postToolUse(path.join(tmp, BRAND))), claudeFeedback(FAILURES)); }); 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"))), handedBack(FAILURES)); + assert.deepEqual(hook(postToolUse(path.join(tmp, "src/ui/Button.tsx"))), claudeFeedback(FAILURES)); }); 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"))), handedBack(FAILURES)); + assert.deepEqual(hook(postToolUse(path.join(tmp, "src/ui/Button.tsx"))), claudeFeedback(FAILURES)); }); 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)}`)), handedBack(FAILURES)); + assert.deepEqual(hook(codexPatch(`Update File: ${path.join(tmp, BRAND)}`)), claudeFeedback(FAILURES)); }); 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)}`)), handedBack(FAILURES)); + assert.deepEqual(hook(copilotPatch(`Add File: ${path.join(tmp, "README.md")}`, `Add File: ${path.join(tmp, BRAND)}`)), copilotFeedback(FAILURES)); }); 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" } }); - assert.deepEqual(hook(input), handedBack(FAILURES)); + 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(FAILURES)); }); it("stays silent when a patch touches only files elsewhere", () => { @@ -134,6 +140,66 @@ describe("aidd-ui validator entries", () => { }); }); + 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("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(FAILURES)); + }); + + 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 count then the problems, and exits 0", () => { breakOnePerFamily(); @@ -167,7 +233,7 @@ describe("aidd-ui validator entries", () => { 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 problems\n${UNREADABLE}\n`, err: "" }); - assert.deepEqual(hook(postToolUse(path.join(tmp, BRAND))), handedBack([UNREADABLE])); + assert.deepEqual(hook(postToolUse(path.join(tmp, BRAND))), claudeFeedback([UNREADABLE])); }); it("names an unreadable file under a code root", () => { From 37d93a75b430e4148c7de5c76c0429d115ac5a3b Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 31 Oct 2026 08:43:35 +0100 Subject: [PATCH 13/26] fix(aidd-ui): declare the Stop hook the turn-end check runs on The shipped hooks.json listed SessionStart and PostToolUse only, so no install ran the turn-end check. Tests named it; a guard on the declaration now fails without Stop. Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01XeK3mDvAh1xZvZGAKEVhJn --- README.md | 2 +- docs/CATALOG.md | 1 + plugins/aidd-ui/hooks/hooks.json | 11 +++++++++++ scripts/__tests__/aidd-ui-validate.test.js | 13 +++++++++++++ 4 files changed, 26 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fd66ff393..87e15899c 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ Feature flow, issue-to-PR automation, backlog alpha

🎨 aidd-ui

-1 skill · 2 hooks
+1 skill · 3 hooks
UI design system, checked on every write
diff --git a/docs/CATALOG.md b/docs/CATALOG.md index ff09f767e..bffe53165 100644 --- a/docs/CATALOG.md +++ b/docs/CATALOG.md @@ -143,6 +143,7 @@ Runs synchronous feature delivery, optional async issue automation, and the prod | --- | --- | | `SessionStart` | `hooks/ui-hook.cjs` | | `PostToolUse` | `hooks/ui-hook.cjs` | +| `Stop` | `hooks/ui-hook.cjs` | diff --git a/plugins/aidd-ui/hooks/hooks.json b/plugins/aidd-ui/hooks/hooks.json index f2653fed7..9df9f9050 100644 --- a/plugins/aidd-ui/hooks/hooks.json +++ b/plugins/aidd-ui/hooks/hooks.json @@ -22,6 +22,17 @@ } ] } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/ui-hook.cjs", + "timeout": 30 + } + ] + } ] } } diff --git a/scripts/__tests__/aidd-ui-validate.test.js b/scripts/__tests__/aidd-ui-validate.test.js index c96194f55..b5b545ac1 100644 --- a/scripts/__tests__/aidd-ui-validate.test.js +++ b/scripts/__tests__/aidd-ui-validate.test.js @@ -73,6 +73,19 @@ const claudeFeedback = (problems) => ({ code: 0, out: JSON.stringify({ hookSpeci const copilotFeedback = (problems) => ({ code: 0, out: JSON.stringify({ additionalContext: feedbackText(problems) }), err: "" }); const finish = (problems) => ({ code: 0, out: JSON.stringify({ decision: "block", reason: [`aidd-ui: the design system has ${problems.length} problems, fix them before you finish:`, ...problems].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: "" }); From 34137f51d0688d03b56d02af46a706852ca4b00f Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 31 Oct 2026 08:53:00 +0100 Subject: [PATCH 14/26] docs(aidd-ui): state the hosts that get the feedback and the turn-end continuation Co-Authored-By: Claude Haiku 5.5 Claude-Session: https://claude.ai/code/session_01XeK3mDvAh1xZvZGAKEVhJn --- plugins/aidd-ui/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/aidd-ui/README.md b/plugins/aidd-ui/README.md index 78bcba4ad..a26d267f6 100644 --- a/plugins/aidd-ui/README.md +++ b/plugins/aidd-ui/README.md @@ -68,9 +68,9 @@ It prints `VALID`, or one `✗ : ` line per problem and exits 1. ## Limits - Without `node` on `PATH`, the hook does nothing. -- After a write under the design system or a code root, the problems go back to the model in the same turn on Claude Code, Codex and Copilot CLI. -- At the end of a turn that wrote such a file, the hook blocks once while problems remain. A forced continuation is never blocked again. The mark lives in the OS temp directory; without a session id, no end-of-turn check runs. -- On OpenCode and Kilo the problems do not reach the model; OpenCode V2 runs the hook but its answer does not reach the model either. Cursor is unverified. +- After a write under the design system or a code root, the problems go back to the model in the same turn on Claude Code, Codex, Copilot CLI, OpenCode and Kilo. +- At the end of a turn that wrote such a file, the hook asks the model to continue once while problems remain, as the next prompt on OpenCode and Kilo. A forced continuation is never asked again. The 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. - Composite tokens are checked for a `$value`, not for its shape. From 71e422e5ed3617351671e71a98a0345a9a4ffcab Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 31 Oct 2026 14:44:01 +0100 Subject: [PATCH 15/26] fix(aidd-ui): create the turn mark exclusively, never through a planted link Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XeK3mDvAh1xZvZGAKEVhJn --- plugins/aidd-ui/hooks/ui-hook.cjs | 11 ++++++++++- scripts/__tests__/aidd-ui-validate.test.js | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/plugins/aidd-ui/hooks/ui-hook.cjs b/plugins/aidd-ui/hooks/ui-hook.cjs index 58a77b5a3..5b607e133 100644 --- a/plugins/aidd-ui/hooks/ui-hook.cjs +++ b/plugins/aidd-ui/hooks/ui-hook.cjs @@ -50,6 +50,15 @@ function markFile(sessionId) { 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. */ +function leaveMark(mark) { + try { + fs.writeFileSync(mark, "", { flag: "wx", mode: 0o600 }); + } catch (error) { + if (error.code !== "EEXIST") throw error; + } +} + /** 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); @@ -58,7 +67,7 @@ function isCopilot(payload) { function afterWrite(project, payload) { if (!writtenFiles(payload.tool_input).some((file) => isWatched(project, file))) return EXIT_SILENT; const mark = markFile(payload.session_id); - if (mark) fs.writeFileSync(mark, ""); + if (mark) leaveMark(mark); const problems = problemsOf(project); if (!problems.length) return EXIT_SILENT; const text = [`aidd-ui: the write was saved, fix ${problems.length} problems:`, ...format(problems)].join("\n"); diff --git a/scripts/__tests__/aidd-ui-validate.test.js b/scripts/__tests__/aidd-ui-validate.test.js index b5b545ac1..d6a385aaf 100644 --- a/scripts/__tests__/aidd-ui-validate.test.js +++ b/scripts/__tests__/aidd-ui-validate.test.js @@ -175,6 +175,20 @@ describe("aidd-ui validator entries", () => { 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(FAILURES)); + }); + + 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))); From 6a4642d4cfaee512bb9b5da1e5253bbf38a9f8f1 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 31 Oct 2026 14:44:43 +0100 Subject: [PATCH 16/26] docs(aidd-ui): state which hosts get the hook's answer in the roadmap Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XeK3mDvAh1xZvZGAKEVhJn --- aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-rework/roadmap.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 4a1410d59..da1f90a39 100644 --- 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 @@ -30,4 +30,4 @@ La vérification est un hook, déclenché à chaque écriture dans `aidd_docs/ui ## Limites connues -- Le retour du hook à l'agent est garanti sur Claude Code. Codex ne lance le hook qu'une fois approuvé ; Kilo et opencode perdent sa sortie ; Cursor et Copilot le lancent sur chaque outil. Ailleurs, la commande à la main est la garantie. +- 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. From 2d1c4320da130e33b2043304baf9b91b4d430fe6 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 31 Oct 2026 15:53:22 +0100 Subject: [PATCH 17/26] docs(aidd-ui): settle the format decisions the later pull requests build on Split design status from the version the code implements, version the design system at publication only, name tasks by their folder, version the format, adopt the DTCG Resolver for themes, generate tokens only under aidd_docs/ui, one engine for every generation, and a severity per problem with scoped hook output. Record the decisions later pull requests own. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XeK3mDvAh1xZvZGAKEVhJn --- .../2026_10_30_aidd-ui-rework/roadmap.md | 2 +- .../2026_10/2026_10_30_aidd-ui-rework/spec.md | 55 +++++++++++++------ 2 files changed, 38 insertions(+), 19 deletions(-) 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 index da1f90a39..813c7a5f3 100644 --- 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 @@ -14,7 +14,7 @@ Cinq pull requests empilées, chacune planifiée, codée et revue à son tour. L 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. Chaque skill porte ses propres scripts de génération : `tokens.css` pour `01-system`, la vitrine pour `02-screens`, la page de review pour `03-review`. +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. 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 index 0a3e1037d..8217b37c6 100644 --- 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 @@ -14,8 +14,10 @@ Tout le design system vit dans `aidd_docs/ui/`, un fichier par élément, rien aidd_docs/ui/ ├── system.md racine du design system ├── brand.md identité de marque -├── tokens.json tokens, format W3C DTCG 2025.10 -├── tokens.css généré depuis tokens.json, jamais écrit à la main +├── 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 @@ -34,22 +36,22 @@ Chaque fiche porte en tête : `id` (égal au nom du fichier, unique), `kind`, `s | Fiche | Contient en plus | | --- | --- | -| `system.md` | version du design system, plateformes, breakpoints, thèmes, langues et sens d'écriture, groupes de tokens primitifs, dossiers UI du code, chemins ignorés, librairies de composants utilisées ; règles transverses s'il y en a | +| `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, états (au moins défaut, vide, chargement, erreur, sans droits quand ils existent), breakpoints couverts, route ou fichier réel, 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 et par état, commentaire, écrans restants | +| review | écrans revus, verdict OK ou pas OK par écran, par état et par version revue, commentaire, écrans restants | ### Cycle de vie -- `proposé` : créé, pas encore validé. -- `validé` : rendu approuvé en review. -- `codé` : un lien vers le code existe et le validateur le confirme ; une molécule ou une composition sans fichier à elle est codée quand chaque composant qu'elle utilise l'est. -- `déprécié` : ne doit plus être utilisé, nomme son remplaçant ou dit pourquoi il n'en a pas. -- Le lien vers le code est obligatoire à `codé`, facultatif avant, sauf pour une molécule ou une composition codée par ce qu'elle utilise. -- Modifier un élément `codé` le repasse à `proposé` le temps de la review ; il redevient `codé` quand le code suit sa nouvelle version. +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é l'est, au moins à la même version. +- 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 @@ -63,9 +65,11 @@ Chaque fiche porte en tête : `id` (égal au nom du fichier, unique), `kind`, `s ### 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` : un changement cassant monte la majeure, un autre la mineure. +- 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 @@ -83,17 +87,18 @@ Chaque fiche porte en tête : `id` (égal au nom du fichier, unique), `kind`, `s ### 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 n'utilise que des sémantiques. -- `tokens.json` est la seule source des tokens ; ceux du code en sont générés. -- Le premier thème déclaré est la base ; un token qui varie selon le thème donne une valeur pour chacun des autres. 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 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 : OK ou pas OK par écran et par état, avec un commentaire. +- 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. @@ -115,6 +120,9 @@ Chaque fiche porte en tête : `id` (égal au nom du fichier, unique), `kind`, `s - 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, ou tous quand elle a écrit un fichier partagé (tokens, thèmes, `system.md`), et le nombre des autres ; 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é. @@ -144,7 +152,18 @@ Chaque fiche porte en tête : `id` (égal au nom du fichier, unique), `kind`, `s ## Open Questions -- TBD: quel projet pilote pour le cas vierge, sachant que Gouvernail et Kairos, pilotes du cas existant, ont déjà une identité ? +- 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 | 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 From 47d5918639c6f85e4cb4aa9386464508d628b611 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 31 Oct 2026 15:55:04 +0100 Subject: [PATCH 18/26] fix(aidd-ui): keep every check speaking when one fails A check that throws becomes one problem naming it instead of silencing the rest. A turn mark that cannot be written no longer costs the write its feedback, and Stop reads no project on a turn that wrote nothing watched. Kinds and folders are own keys only, and a byte order mark or CRLF is dropped once for every file, tokens.json included. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XeK3mDvAh1xZvZGAKEVhJn --- plugins/aidd-ui/hooks/lib/markdown.cjs | 4 +-- plugins/aidd-ui/hooks/lib/project.cjs | 7 ++++-- plugins/aidd-ui/hooks/lib/records.cjs | 10 +++++--- plugins/aidd-ui/hooks/ui-hook.cjs | 25 +++++++++++++------ plugins/aidd-ui/hooks/validate-ui.cjs | 18 ++++++++++--- scripts/__tests__/aidd-ui-records.test.js | 10 ++++++++ .../__tests__/aidd-ui-tokens-renders.test.js | 6 +++++ scripts/__tests__/aidd-ui-validate.test.js | 15 +++++++++++ 8 files changed, 74 insertions(+), 21 deletions(-) diff --git a/plugins/aidd-ui/hooks/lib/markdown.cjs b/plugins/aidd-ui/hooks/lib/markdown.cjs index 6557fdd50..c39e1715e 100644 --- a/plugins/aidd-ui/hooks/lib/markdown.cjs +++ b/plugins/aidd-ui/hooks/lib/markdown.cjs @@ -6,8 +6,6 @@ const SECTION_PREFIX = "## "; const TABLE_CELL_SEPARATOR = "|"; const LIST_SEPARATOR = ","; -const WINDOWS_LINE_ENDING = /\r\n/g; -const BYTE_ORDER_MARK = /^\uFEFF/; const ENTRY = /^([a-z][a-z0-9-]*):[ \t]*(.*)$/; const LIST = /^\[(.*)\]$/; const QUOTED = /^(["'])(.*)\1$/; @@ -82,7 +80,7 @@ function table(section) { /** A record's front matter, its problems, and its sections. */ function parseRecord(text) { - const lines = text.replace(BYTE_ORDER_MARK, "").replace(WINDOWS_LINE_ENDING, LINE_BREAK).split(LINE_BREAK); + const lines = text.split(LINE_BREAK); const frontMatter = parseFrontMatter(lines); return { hasFrontMatter: frontMatter.found, diff --git a/plugins/aidd-ui/hooks/lib/project.cjs b/plugins/aidd-ui/hooks/lib/project.cjs index 093620bb4..d4b213335 100644 --- a/plugins/aidd-ui/hooks/lib/project.cjs +++ b/plugins/aidd-ui/hooks/lib/project.cjs @@ -12,6 +12,8 @@ const PATH_SEPARATOR = "/"; const UNREADABLE = "unreadable"; 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); @@ -42,12 +44,13 @@ function walk(dir, skip = () => false, unlisted = () => {}) { }); } -/** A file's text, read once per run; null when it cannot be read, which `unreadable` reports. */ +/** 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"); + 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 } diff --git a/plugins/aidd-ui/hooks/lib/records.cjs b/plugins/aidd-ui/hooks/lib/records.cjs index 97d265bc3..a73af381e 100644 --- a/plugins/aidd-ui/hooks/lib/records.cjs +++ b/plugins/aidd-ui/hooks/lib/records.cjs @@ -51,7 +51,9 @@ const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/; const listWithOr = (values) => `${values.slice(0, -1).join(", ")} or ${values.at(-1)}`; const problem = (record, message) => ({ file: record.file, message }); const fm = (record) => record.frontMatter; -const isElement = (record) => fm(record).kind in LEVELS; +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); const isDeprecated = (record) => fm(record).status === "deprecated"; const folderLabel = (folder) => (folder ? `${folder}/` : "aidd_docs/ui/"); @@ -60,7 +62,7 @@ const folderLabel = (folder) => (folder ? `${folder}/` : "aidd_docs/ui/"); 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) => FOLDER_KINDS[record.folder]?.includes(fm(record).kind)); +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) { @@ -79,8 +81,8 @@ function kindProblems(project) { return readable(project).flatMap((record) => { const kind = fm(record).kind; if (kind === undefined) return []; - if (!(kind in KIND_KEYS)) return [problem(record, `unknown kind "${kind}"`)]; - if (FOLDER_KINDS[record.folder]?.includes(kind)) 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)}`)]; }); } diff --git a/plugins/aidd-ui/hooks/ui-hook.cjs b/plugins/aidd-ui/hooks/ui-hook.cjs index 5b607e133..57a56a513 100644 --- a/plugins/aidd-ui/hooks/ui-hook.cjs +++ b/plugins/aidd-ui/hooks/ui-hook.cjs @@ -50,12 +50,14 @@ function markFile(sessionId) { 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. */ +/** 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 (error) { - if (error.code !== "EEXIST") throw error; + } catch { + // already marked this turn, or no writable temp dir } } @@ -79,12 +81,17 @@ function afterWrite(project, payload) { return EXIT_SILENT; } -/** Blocks the turn's end once while a marked turn still leaves problems behind. */ -function atStop(project, payload) { +/** 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 EXIT_SILENT; + if (!mark || !fs.existsSync(mark)) return false; fs.rmSync(mark, { force: true }); - if (payload.stop_hook_active === true) return EXIT_SILENT; + return payload.stop_hook_active !== true; +} + +/** Blocks the turn's end once while a marked turn still leaves problems behind. */ +function atStop(project) { const problems = problemsOf(project); if (!problems.length) return EXIT_SILENT; const reason = [`aidd-ui: the design system has ${problems.length} problems, fix them before you finish:`, ...format(problems)].join("\n"); @@ -101,11 +108,13 @@ function atSessionStart(project) { function run() { const payload = readPayload(); if (payload === null) 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, payload); + if (payload.hook_event_name === STOP) return atStop(project); return EXIT_SILENT; } diff --git a/plugins/aidd-ui/hooks/validate-ui.cjs b/plugins/aidd-ui/hooks/validate-ui.cjs index 82b10fbb9..49a6bc3ce 100644 --- a/plugins/aidd-ui/hooks/validate-ui.cjs +++ b/plugins/aidd-ui/hooks/validate-ui.cjs @@ -2,21 +2,31 @@ // Usage: node validate-ui.cjs [project-root] // Checks a project's design system under aidd_docs/ui/: records, history, tokens, renders and // code links. Prints VALID, or one line per problem and exits 1. Prints nothing without one. -const { load, unreadable } = require("./lib/project.cjs"); +const { load, unreadable, UI_DIR } = 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 CHECKS = { records, history, tokens, renders, code }; const VERDICT_VALID = "VALID"; const EXIT_VALID = 0; const EXIT_INVALID = 1; const PROBLEM_MARK = "✗"; -/** Every problem of a loaded project, check by check, then each file no check could read. */ -const problemsOf = (project) => [...CHECKS.flatMap((check) => check.check(project)), ...unreadable(project)]; +/** 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. */ +const problemsOf = (project, checks = CHECKS) => [ + ...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), +]; /** The problems of the design system under `root`, or null when it has none. */ function validate(root) { diff --git a/scripts/__tests__/aidd-ui-records.test.js b/scripts/__tests__/aidd-ui-records.test.js index 058e45006..198c9d9cf 100644 --- a/scripts/__tests__/aidd-ui-records.test.js +++ b/scripts/__tests__/aidd-ui-records.test.js @@ -104,6 +104,16 @@ describe("aidd-ui records and history", () => { 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", "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", "ds-init"]))); assert.deepEqual(problems(), [`${UI}/screens/extra.md: kind "atom" does not belong in screens/`]); diff --git a/scripts/__tests__/aidd-ui-tokens-renders.test.js b/scripts/__tests__/aidd-ui-tokens-renders.test.js index 3729bb115..015b8b1e1 100644 --- a/scripts/__tests__/aidd-ui-tokens-renders.test.js +++ b/scripts/__tests__/aidd-ui-tokens-renders.test.js @@ -61,6 +61,12 @@ describe("aidd-ui tokens and renders", () => { 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`]); diff --git a/scripts/__tests__/aidd-ui-validate.test.js b/scripts/__tests__/aidd-ui-validate.test.js index d6a385aaf..f30f0a094 100644 --- a/scripts/__tests__/aidd-ui-validate.test.js +++ b/scripts/__tests__/aidd-ui-validate.test.js @@ -11,6 +11,8 @@ const { UI, writeProject, edit, editTokens } = require("./fixtures/aidd-ui/proje const HOOKS = path.resolve(__dirname, "../../plugins/aidd-ui/hooks"); const VALIDATE = path.join(HOOKS, "validate-ui.cjs"); const HOOK = path.join(HOOKS, "ui-hook.cjs"); +const { problemsOf, format } = require(VALIDATE); +const { load } = require(path.join(HOOKS, "lib/project.cjs")); const BRAND = `${UI}/brand.md`; const DIALOG = `${UI}/components/dialog.md`; @@ -103,6 +105,13 @@ describe("aidd-ui validator entries", () => { breakOnePerFamily(); assert.deepEqual(validate(tmp), { code: 1, out: FAILURES, 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", () => { @@ -181,6 +190,12 @@ describe("aidd-ui validator entries", () => { assert.deepEqual(hook(postToolUse(path.join(tmp, BRAND))), claudeFeedback(FAILURES)); }); + 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(FAILURES)); + }); + it("never writes through a link planted at the mark's path", () => { const target = path.join(tmp, "victim.txt"); fs.writeFileSync(target, "keep"); From 231fa1c54aad8246033cf7e7ad4d465209ed710a Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 31 Oct 2026 16:00:00 +0100 Subject: [PATCH 19/26] feat(aidd-ui): separate design status from the version the code implements `implemented-version` replaces the coded status, so code lagging a new design stays visible. system.md versions at publication only, from the tasks a row lists, so parallel features on distinct elements never conflict, and a token-only change can break. Tasks are task folders, review verdicts carry the version they judge, and system.md states its record format: another format is one problem, never an avalanche. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XeK3mDvAh1xZvZGAKEVhJn --- .../2026_10/2026_10_30_aidd-ui-rework/spec.md | 2 +- plugins/aidd-ui/hooks/lib/history.cjs | 71 +++++---- plugins/aidd-ui/hooks/lib/records.cjs | 69 ++++++--- plugins/aidd-ui/hooks/validate-ui.cjs | 27 ++-- scripts/__tests__/aidd-ui-records.test.js | 141 +++++++++++++----- scripts/__tests__/aidd-ui-validate.test.js | 6 + scripts/__tests__/fixtures/aidd-ui/project.js | 28 ++-- 7 files changed, 225 insertions(+), 119 deletions(-) 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 index 8217b37c6..734e5f079 100644 --- 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 @@ -50,7 +50,7 @@ Deux axes, jamais mêlés : où en est le design, et quelle version le code impl - `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é l'est, au moins à la même version. +- 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é. diff --git a/plugins/aidd-ui/hooks/lib/history.cjs b/plugins/aidd-ui/hooks/lib/history.cjs index 1f67917ee..a330f8b8a 100644 --- a/plugins/aidd-ui/hooks/lib/history.cjs +++ b/plugins/aidd-ui/hooks/lib/history.cjs @@ -1,15 +1,19 @@ -// H1 to H6: each versioned record's history, and the system version its tasks raise. -const { table } = require("./markdown.cjs"); +// 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, fm, INTEGER_VERSION, SEMVER } = require("./records.cjs"); const HISTORY_SECTION = "History"; const BREAKING_VALUES = ["yes", "no"]; const BREAKING = "yes"; const SYSTEM_KIND = "system"; +const TASKS_DIR = "aidd_docs/tasks/"; const problem = (record, message) => ({ file: record.file, message }); const isSystem = (record) => fm(record).kind === SYSTEM_KIND; 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) { @@ -76,49 +80,43 @@ function breakingProblems(project) { ); } -/** For each element task, whether any of its rows breaks, and which records it touches. */ -function elementTasks(project) { - const tasks = new Map(); +/** 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)) { - const task = tasks.get(row.Task) ?? { breaking: false, records: [] }; - task.breaking ||= row.Breaking === BREAKING; - if (!task.records.includes(record)) task.records.push(record); - tasks.set(row.Task, task); - } + for (const row of rowsOf(record)) if (row.Breaking === BREAKING) tasks.add(row.Task); } return tasks; } -/** The system row a task raised, with the row before it by version; null when the task has none. */ -function systemRow(rows, task) { - const row = rows.find((candidate) => candidate.Task === task); - if (!row) return null; - const ordered = byVersion(rows, true); - const index = ordered.findIndex((entry) => entry.row === row); - return { row, previous: index > 0 ? ordered[index - 1].row : undefined }; -} - -function bumpProblems(project) { +/** 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); - const rows = system ? rowsOf(system) : []; - return [...elementTasks(project)].flatMap(([task, { breaking, records }]) => { - const found = systemRow(rows, task); - if (!found) return records.map((record) => problem(record, `task "${task}" has no row in system.md`)); - if (!found.previous) return []; - const now = parts(found.row.Version, true); - const before = parts(found.previous.Version, true); - if (!now || !before) return []; - if (breaking && now[0] <= before[0]) { - return [problem(system, `task "${task}" breaks an element, yet system.md ${found.row.Version} raises no major version`)]; - } - if (!breaking && compare(now.slice(0, 2), before.slice(0, 2)) <= 0) { - return [problem(system, `task "${task}" changes an element, yet system.md ${found.row.Version} raises neither major nor minor`)]; - } + 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") @@ -131,8 +129,9 @@ const RULES = [ duplicateProblems, // H2 currentProblems, // H3 breakingProblems, // H4 - bumpProblems, // H5 + publicationProblems, // H5 deprecationProblems, // H6 + taskProblems, // H7 ]; /** Every broken history rule, as `{file, message}`, in rule order. */ diff --git a/plugins/aidd-ui/hooks/lib/records.cjs b/plugins/aidd-ui/hooks/lib/records.cjs index a73af381e..844259d6e 100644 --- a/plugins/aidd-ui/hooks/lib/records.cjs +++ b/plugins/aidd-ui/hooks/lib/records.cjs @@ -3,8 +3,10 @@ const fs = require("node:fs"); const path = require("node:path"); const { list, table } = require("./markdown.cjs"); -const STATUSES = ["proposed", "validated", "coded", "deprecated"]; -const DESIGN_STATUSES = ["proposed", "validated"]; // R16: system, brand and reviews are never coded +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"]; @@ -22,7 +24,7 @@ const SYSTEM_ID = "system"; const COMMON_KEYS = ["id", "kind", "status", "version"]; const COMPONENT_KEYS = ["uses", "tokens", "variants", "states", "interactive", "role"]; const KIND_KEYS = { - system: ["platforms", "breakpoints", "themes", "locales", "directions", "primitives", "code-roots", "ignore", "libraries"], + system: ["format", "platforms", "breakpoints", "themes", "locales", "directions", "primitives", "code-roots", "ignore", "libraries"], brand: [], atom: COMPONENT_KEYS, molecule: COMPONENT_KEYS, @@ -41,7 +43,8 @@ const FOLDER_KINDS = { reviews: ["review"], }; const COMPONENT_KINDS = FOLDER_KINDS.components; -const ASSEMBLED_KINDS = ["molecule", "composition"]; // coded through what they use when no file is their own +const ASSEMBLED_KINDS = ["molecule", "composition"]; // implemented through what they use when no file is their own +const IMPLEMENTED_KINDS = [...COMPONENT_KINDS, "screen"]; const DESIGN_KINDS = ["system", "brand", "review"]; const LEVELS = { asset: 0, atom: 1, molecule: 2, composition: 3, screen: 4 }; // an element uses its own level or lower @@ -155,23 +158,36 @@ function stateProblems(project) { }); } -/** Whether an assembled element is coded through the components it uses: at least one, all - * coded. Assets are files, never code, so they count for nothing. */ -function codedThroughUses(record, index) { +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 && fm(target).status === "coded"); + 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 codedProblems(project) { +function implementedProblems(project) { const index = byId(project); return placed(project) - .filter((record) => isElement(record) && fm(record).status === "coded" && !("code" in fm(record))) - .filter((record) => !codedThroughUses(record, index)) - .map((record) => - problem(record, ASSEMBLED_KINDS.includes(fm(record).kind) ? 'status coded needs "code", or every element it uses coded' : 'status coded needs "code"') - ); + .filter(isImplemented) + .flatMap((record) => { + const message = implementedProblem(record, index); + return message ? [problem(record, message)] : []; + }); } function deprecatedProblems(project) { @@ -233,11 +249,14 @@ function reviewProblems(project) { .filter((record) => fm(record).kind === "review") .flatMap((record) => { const rows = table(record.sections[VERDICTS_SECTION]); - const missing = list(fm(record).screens).flatMap((id) => - list(index.get(id) && fm(index.get(id)).states) - .filter((state) => !rows.some((row) => row.Screen === id && row.State === state)) - .map((state) => `no verdict row for ${id} / ${state}`) - ); + // 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)}`); @@ -271,6 +290,14 @@ function usageProblems(project) { .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 = [ frontMatterProblems, // R1 kindProblems, // R2 @@ -279,7 +306,7 @@ const RULES = [ versionProblems, // R5 usesProblems, // R6 stateProblems, // R7 - codedProblems, // R8 + implementedProblems, // R8 deprecatedProblems, // R9 deprecatedUseProblems, // R10 breakpointProblems, // R11 @@ -294,4 +321,4 @@ const RULES = [ /** Every broken record rule, as `{file, message}`, in rule order. */ const check = (project) => RULES.flatMap((rule) => rule(project)); -module.exports = { check, placed, systemOf, isElement, fm, COMPONENT_KINDS, INTEGER_VERSION, SEMVER }; +module.exports = { check, formatProblem, placed, systemOf, isElement, fm, COMPONENT_KINDS, INTEGER_VERSION, SEMVER }; diff --git a/plugins/aidd-ui/hooks/validate-ui.cjs b/plugins/aidd-ui/hooks/validate-ui.cjs index 49a6bc3ce..ca6625d69 100644 --- a/plugins/aidd-ui/hooks/validate-ui.cjs +++ b/plugins/aidd-ui/hooks/validate-ui.cjs @@ -16,17 +16,22 @@ const EXIT_INVALID = 1; const PROBLEM_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. */ -const problemsOf = (project, checks = CHECKS) => [ - ...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), -]; + * 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), + ]; +} /** The problems of the design system under `root`, or null when it has none. */ function validate(root) { diff --git a/scripts/__tests__/aidd-ui-records.test.js b/scripts/__tests__/aidd-ui-records.test.js index 198c9d9cf..db5cc66e9 100644 --- a/scripts/__tests__/aidd-ui-records.test.js +++ b/scripts/__tests__/aidd-ui-records.test.js @@ -110,12 +110,12 @@ describe("aidd-ui records and history", () => { }); 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", "ds-init"]))); + 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", "ds-init"]))); + 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/`]); }); }); @@ -139,7 +139,7 @@ describe("aidd-ui records and history", () => { it("names an unknown status", () => { change(JOURNEY, "status: validated", "status: done"); - assert.deepEqual(problems(), [`${JOURNEY}: status "done" must be proposed, validated, coded or deprecated`]); + assert.deepEqual(problems(), [`${JOURNEY}: status "done" must be proposed, validated or deprecated`]); }); it("names an interactive value that is neither yes nor no", () => { @@ -161,7 +161,7 @@ describe("aidd-ui records and history", () => { it("names an id two records hold", () => { const screen = ["id: brand", "kind: screen", "status: proposed", "version: 1", "uses: []", "states: [default]", "breakpoints: [mobile]", "route: /brand"]; - put(tmp, `${UI}/screens/brand.md`, record(screen, history(["1", "created", "no", "ds-init"]))); + 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}`]); }); }); @@ -231,21 +231,42 @@ describe("aidd-ui records and history", () => { }); }); - describe("R8 coded", () => { - it("names a coded record without code", () => { + describe("R8 implemented version", () => { + it("names an implemented record without code", () => { change(HOME, "code: src/ui/HomePage.tsx#HomePage\n", ""); - assert.deepEqual(problems(), [`${HOME}: status coded needs "code"`]); + assert.deepEqual(problems(), [`${HOME}: implemented-version needs "code"`]); }); - it("accepts a coded molecule without code when everything it uses is coded", () => { - change(SEARCH, "status: validated", "status: coded"); + 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 a coded molecule without code that uses a component not coded", () => { - change(SEARCH, "status: validated", "status: coded"); - change(BUTTON, "status: coded", "status: validated"); - assert.deepEqual(problems(), [`${SEARCH}: status coded needs "code", or every element it uses coded`]); + 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`]); }); }); @@ -283,7 +304,7 @@ describe("aidd-ui records and history", () => { `${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", "ds-init"], ["2", "deprecated for icon-search", "yes", "retire-old-button"]) + 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]"); @@ -307,12 +328,17 @@ describe("aidd-ui records and history", () => { describe("R13 reviews", () => { it("names a screen state with no verdict row", () => { - change(REVIEW, "| home | loading | ko | spinner too small |\n", ""); - assert.deepEqual(problems(), [`${REVIEW}: no verdict row for home / loading`]); + 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 | ok | |", "| home | default | maybe | |"); + 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`]); }); }); @@ -333,9 +359,9 @@ describe("aidd-ui records and history", () => { describe("R16 system, brand and review statuses", () => { const cases = [ - [SYSTEM, "status: validated", "coded"], + [SYSTEM, "status: validated", "deprecated"], [BRAND, "status: validated", "deprecated"], - [REVIEW, "status: proposed", "coded"], + [REVIEW, "status: proposed", "deprecated"], ]; for (const [file, from, status] of cases) { it(`names ${path.basename(file)} with status ${status}`, () => { @@ -361,7 +387,7 @@ describe("aidd-ui records and history", () => { 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 | ds-init |\n| 2 | deprecated for button |"); + 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`]); }); }); @@ -374,7 +400,7 @@ describe("aidd-ui records and history", () => { it("names a gap in the history", () => { change(BUTTON, "version: 1", "version: 3"); - change(BUTTON, "| 1 | created | no | ds-init |", "| 1 | created | no | ds-init |\n| 3 | larger | no | ds-init |"); + 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`]); }); }); @@ -386,40 +412,79 @@ describe("aidd-ui records and history", () => { }); }); - describe("H5 system version follows its tasks", () => { - it("names a breaking task without a major bump", () => { + 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}: task "retire-old-button" breaks an element, yet system.md 1.1.0 raises no major version`]); + assert.deepEqual(problems(), [`${SYSTEM}: 1.1.0 is breaking, yet raises no major version`]); }); - it("names a non-breaking task whose system row raises nothing", () => { - change(BUTTON, "version: 1", "version: 2"); - change(BUTTON, "| 1 | created | no | ds-init |", "| 1 | created | no | ds-init |\n| 2 | larger padding | no | tweak |"); + it("names a publication that raises neither major nor minor", () => { change(SYSTEM, "version: 2.0.0", "version: 2.0.1"); - change(SYSTEM, "| 2.0.0 | old-button retired | yes | retire-old-button |", "| 2.0.0 | old-button retired | yes | retire-old-button |\n| 2.0.1 | button padding | no | tweak |"); - assert.deepEqual(problems(), [`${SYSTEM}: task "tweak" changes an element, yet system.md 2.0.1 raises neither major nor minor`]); + 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 | ds-init |\n| 2.0.0 | old-button retired | yes | retire-old-button |", - "| 2.0.0 | old-button retired | yes | retire-old-button |\n| 1.0.0 | first system | no | ds-init |" - ); + 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("names a task system.md has no row for", () => { - change(JOURNEY, "| no | ds-init |", "| no | new-flow |"); - assert.deepEqual(problems(), [`${JOURNEY}: task "new-flow" has no row in system.md`]); + 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 | retire-old-button |", "| no | retire-old-button |"); + 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-validate.test.js b/scripts/__tests__/aidd-ui-validate.test.js index f30f0a094..ab9c86340 100644 --- a/scripts/__tests__/aidd-ui-validate.test.js +++ b/scripts/__tests__/aidd-ui-validate.test.js @@ -106,6 +106,12 @@ describe("aidd-ui validator entries", () => { 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"); } }; diff --git a/scripts/__tests__/fixtures/aidd-ui/project.js b/scripts/__tests__/fixtures/aidd-ui/project.js index f00e935f4..9ddba3e27 100644 --- a/scripts/__tests__/fixtures/aidd-ui/project.js +++ b/scripts/__tests__/fixtures/aidd-ui/project.js @@ -9,7 +9,7 @@ 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", "ds-init"]; +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`; @@ -23,6 +23,7 @@ const SYSTEM = record( "kind: system", "status: validated", "version: 2.0.0", + "format: 1", "platforms: [web]", "breakpoints: [mobile, desktop]", "themes: [light, dark]", @@ -35,7 +36,7 @@ const SYSTEM = record( ], "# Design system", "## Breakpoints\n\n| Name | From |\n| --- | --- |\n| mobile | 0 |\n| desktop | 64rem |", - history(["1.0.0", "first system", "no", "ds-init"], ["2.0.0", "old-button retired", "yes", "retire-old-button"]) + 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( @@ -87,8 +88,9 @@ const BUTTON = record( [ "id: button", "kind: atom", - "status: coded", + "status: validated", "version: 1", + "implemented-version: 1", "uses: []", "tokens: [color.action.default, color.text.default, space.md, border.thin, motion.fast, font.body, font.weight.bold]", "variants: [primary, secondary]", @@ -126,8 +128,9 @@ const DIALOG = record( [ "id: dialog", "kind: composition", - "status: coded", + "status: validated", "version: 1", + "implemented-version: 1", "uses: [button]", "tokens: [space.md]", "variants: [default]", @@ -158,15 +161,16 @@ const OLD_BUTTON = record( ], "# Old button", usage("Nowhere: replaced by button."), - history(FIRST_ROW, ["2", "deprecated for button", "yes", "retire-old-button"]) + history(FIRST_ROW, ["2", "deprecated for button", "yes", "aidd_docs/tasks/retire-old-button"]) ); const HOME = record( [ "id: home", "kind: screen", - "status: coded", + "status: validated", "version: 1", + "implemented-version: 1", "uses: [search-field, dialog]", "states: [default, empty, loading, error]", "breakpoints: [mobile, desktop]", @@ -190,12 +194,12 @@ const REVIEW = record( [ "## Verdicts", "", - "| Screen | State | Verdict | Comment |", - "| --- | --- | --- | --- |", - "| home | default | ok | |", - "| home | empty | ok | |", - "| home | loading | ko | spinner too small |", - "| home | error | pending | |", + "| 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) ); From f1a962d2d3e7d9928110e89481a8509d680446ec Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 31 Oct 2026 16:05:19 +0100 Subject: [PATCH 20/26] feat(aidd-ui): read themes through the DTCG Resolver and every token a render uses tokens.json holds the base theme; tokens.resolver.json names a file per other theme, which redefines base tokens only, with their type. Screens cite tokens like components, a render uses only the tokens its record declares, and a raw CSS value's var() must name a token, so an impact report read from the records misses none. system.md names the code's token file, kept out of coverage and colour checks, and tests may state colours. tokens.json is parsed once. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XeK3mDvAh1xZvZGAKEVhJn --- plugins/aidd-ui/hooks/lib/code.cjs | 33 +++- plugins/aidd-ui/hooks/lib/records.cjs | 5 +- plugins/aidd-ui/hooks/lib/renders.cjs | 26 ++- plugins/aidd-ui/hooks/lib/tokens.cjs | 167 ++++++++++++------ scripts/__tests__/aidd-ui-code.test.js | 19 +- scripts/__tests__/aidd-ui-records.test.js | 2 +- .../__tests__/aidd-ui-tokens-renders.test.js | 98 +++++++--- scripts/__tests__/fixtures/aidd-ui/project.js | 56 +++--- 8 files changed, 287 insertions(+), 119 deletions(-) diff --git a/plugins/aidd-ui/hooks/lib/code.cjs b/plugins/aidd-ui/hooks/lib/code.cjs index affd80b8a..0655ad6bb 100644 --- a/plugins/aidd-ui/hooks/lib/code.cjs +++ b/plugins/aidd-ui/hooks/lib/code.cjs @@ -1,5 +1,6 @@ -// C1 to C7: each code link resolves, every file under a code root is linked or ignored, and UI -// code holds no colour literal. Other literals and non-web code are stated limits. +// 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"); @@ -10,7 +11,10 @@ const { walk, text, relative, isUnder } = 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_FILE = /(?:^|\/)(?:__tests__|__mocks__)\/|\.(?:test|spec)\.[^/]+$/; const STYLESHEET_EXTENSIONS = [".css", ".scss"]; const SCRIPT_EXTENSIONS = [".js", ".jsx", ".ts", ".tsx", ".vue", ".svelte"]; const SCANNED_EXTENSIONS = [...STYLESHEET_EXTENSIONS, ...SCRIPT_EXTENSIONS, ".html"]; @@ -52,11 +56,19 @@ const hasWord = (text, word) => new RegExp(`(? walk(path.join(project.root, root)).map((file) => relative(project.root, file))); - trees.set(project, { files: [...new Set(files)], ignore: prefixes(project, IGNORE_KEY) }); + const tokenFile = codeTokens(project); + trees.set(project, { files: [...new Set(files)], ignore: [...prefixes(project, IGNORE_KEY), ...(tokenFile ? [tokenFile] : [])] }); } return trees.get(project); } @@ -114,11 +126,17 @@ function coverageProblems(project) { const literalProblems = (pattern, extensions, applies = () => true) => (project) => applies(project) - ? scanned(project, extensions).flatMap((file) => - findLiterals(searchable(project, file), pattern).map(({ message }) => ({ file, message })) - ) + ? scanned(project, extensions) + .filter((file) => !TEST_FILE.test(file)) + .flatMap((file) => findLiterals(searchable(project, file), pattern).map(({ message }) => ({ file, message }))) : []; +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 }) => { @@ -146,6 +164,7 @@ const RULES = [ sharedLinkProblems, // C5 literalProblems(PATTERNS.physical, STYLESHEET_EXTENSIONS, declaresRightToLeft), // C6 rootProblems, // C7 + codeTokensProblems, // C8 ]; /** Every broken code rule, as `{file, message}`, in rule order. */ diff --git a/plugins/aidd-ui/hooks/lib/records.cjs b/plugins/aidd-ui/hooks/lib/records.cjs index 844259d6e..b6fd2bdb3 100644 --- a/plugins/aidd-ui/hooks/lib/records.cjs +++ b/plugins/aidd-ui/hooks/lib/records.cjs @@ -29,7 +29,7 @@ const KIND_KEYS = { atom: COMPONENT_KEYS, molecule: COMPONENT_KEYS, composition: COMPONENT_KEYS, - screen: ["uses", "states", "breakpoints", "route"], + screen: ["uses", "tokens", "states", "breakpoints", "route"], journey: ["persona"], asset: ["type", "file", "alt"], review: ["scope", "screens"], @@ -45,6 +45,7 @@ const FOLDER_KINDS = { const COMPONENT_KINDS = FOLDER_KINDS.components; const ASSEMBLED_KINDS = ["molecule", "composition"]; // implemented through what they use when no file is their own const IMPLEMENTED_KINDS = [...COMPONENT_KINDS, "screen"]; +const TOKENED_KINDS = IMPLEMENTED_KINDS; // components and screens cite the tokens their render uses const DESIGN_KINDS = ["system", "brand", "review"]; const LEVELS = { asset: 0, atom: 1, molecule: 2, composition: 3, screen: 4 }; // an element uses its own level or lower @@ -321,4 +322,4 @@ const RULES = [ /** Every broken record rule, as `{file, message}`, in rule order. */ const check = (project) => RULES.flatMap((rule) => rule(project)); -module.exports = { check, formatProblem, placed, systemOf, isElement, fm, COMPONENT_KINDS, INTEGER_VERSION, SEMVER }; +module.exports = { check, formatProblem, placed, systemOf, isElement, fm, COMPONENT_KINDS, TOKENED_KINDS, INTEGER_VERSION, SEMVER }; diff --git a/plugins/aidd-ui/hooks/lib/renders.cjs b/plugins/aidd-ui/hooks/lib/renders.cjs index 6bbb774c1..37da5b46b 100644 --- a/plugins/aidd-ui/hooks/lib/renders.cjs +++ b/plugins/aidd-ui/hooks/lib/renders.cjs @@ -1,9 +1,9 @@ -// V1 to V6: each component and screen has its HTML render, showing every variant and state -// through tokens alone. +// 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, systemOf, COMPONENT_KINDS } = require("./records.cjs"); -const { cssNames } = require("./tokens.cjs"); +const { cssNames, cssNameOf } = require("./tokens.cjs"); const { text, RENDER_EXTENSION } = require("./project.cjs"); const RENDERED_KINDS = [...COMPONENT_KINDS, "screen"]; @@ -81,6 +81,25 @@ function referenceProblems(project) { }); } +/** 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(({ message }) => ({ file, message }))) : []; @@ -91,6 +110,7 @@ const RULES = [ literalProblems(PATTERNS.colour), // V4 literalProblems(PATTERNS.physical, declaresRightToLeft), // V5 literalProblems(PATTERNS.unit), // V6 + declaredProblems, // V7 ]; /** Every broken render rule, as `{file, message}`, in rule order. */ diff --git a/plugins/aidd-ui/hooks/lib/tokens.cjs b/plugins/aidd-ui/hooks/lib/tokens.cjs index f8b326251..4db6c3b8d 100644 --- a/plugins/aidd-ui/hooks/lib/tokens.cjs +++ b/plugins/aidd-ui/hooks/lib/tokens.cjs @@ -1,9 +1,10 @@ -// T1 to T8: tokens.json against the DTCG 2025.10 format and color modules, plus this plugin's -// themes and raw CSS extensions, and components citing semantic tokens only. Composite values are checked for presence only. +// 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, COMPONENT_KINDS } = require("./records.cjs"); +const { placed, systemOf, TOKENED_KINDS } = require("./records.cjs"); const { text } = require("./project.cjs"); const RESERVED_KEYS = ["$value", "$type", "$description", "$extensions", "$deprecated", "$extends", "$schema"]; @@ -12,8 +13,11 @@ const PROPERTY_PREFIX = "$"; const PATH_SEPARATOR = "."; const CSS_PREFIX = "--"; const CSS_SEPARATOR = "-"; -const THEMES_EXTENSION = "dev.ai-driven.themes"; 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"; @@ -35,6 +39,7 @@ const MAX_ALIAS_DEPTH = 32; // alias cycles are a stated limit; this only stops 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); @@ -65,21 +70,30 @@ function walk(node, path, groupType, found) { const emptyTree = () => ({ tokens: new Map(), groups: new Set(), badNames: [] }); -/** A tree no rule can read, with its T1 problem; null when unreadable, which the loader reports. */ -const unusable = (problem) => ({ ...emptyTree(), unusable: true, problem }); - -/** The tree: its tokens, groups and bad names, or why it cannot be read. */ -function read(project) { - if (!fs.existsSync(nodePath.join(project.root, project.tokensFile))) return unusable(MISSING); - const source = text(project, project.tokensFile); - if (source === null) return unusable(null); - let tree; +/** 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 { - tree = JSON.parse(source); + const value = JSON.parse(source); + return isObject(value) ? { value } : { problem: NOT_JSON }; } catch { - return unusable(NOT_JSON); + return { problem: NOT_JSON }; } - return isObject(tree) ? walk(tree, [], tree.$type, emptyTree()) : unusable(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 ? walk(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. */ @@ -140,9 +154,9 @@ const SHAPES = { /** 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 tokens.json, naming the token path first. */ -const tokenProblem = (project) => (path, message) => ({ file: project.tokensFile, message: `${path}: ${message}` }); -const themesOf = (token) => (isObject(token.node.$extensions?.[THEMES_EXTENSION]) ? token.node.$extensions[THEMES_EXTENSION] : {}); +/** 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) => `${CSS_PREFIX}${path.join(CSS_SEPARATOR)}`; function jsonProblems(tree, project) { return tree.problem ? [{ file: project.tokensFile, message: tree.problem }] : []; @@ -190,36 +204,78 @@ function aliasProblems(tree, project) { }); } -/** The first theme system.md declares is the base, held by `$value`; a themed token gives - * every other one. */ +/** 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 system = systemOf(project); - const [base, ...others] = list(system?.frontMatter.themes); - return [...tree.tokens].flatMap(([name, token]) => { - const themes = themesOf(token); - if (Object.keys(themes).length === 0) return []; - const missing = system ? others.filter((theme) => !(theme in themes)).map((theme) => at(name, `no value for theme "${theme}"`)) : []; - const values = Object.entries(themes).flatMap(([theme, value]) => { - if (system && theme === base) return [at(name, `theme "${theme}" is the base, its value is $value`)]; - if (system && !others.includes(theme)) return [at(name, `theme "${theme}" is not in system.md`)]; - const type = typeOf(tree, token); - const message = shapeProblem(type, value) ?? aliasProblem(tree, value, type); - return message ? [at(name, `theme "${theme}": ${message}`)] : []; - }); - return [...missing, ...values]; - }); + 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`))); } -function componentTokenProblems(tree, project) { +/** 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 placed(project) - .filter((record) => COMPONENT_KINDS.includes(record.frontMatter.kind)) - .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` })) - ); + 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. */ @@ -227,13 +283,11 @@ const isPrimitive = (name, groups) => groups.some((group) => name === group || n function primitiveProblems(tree, project) { const groups = list(systemOf(project)?.frontMatter.primitives); - return placed(project) - .filter((record) => COMPONENT_KINDS.includes(record.frontMatter.kind)) - .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` })) - ); + 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 = [ @@ -243,8 +297,9 @@ const RULES = [ shapeProblems, // T4 aliasProblems, // T5 themeProblems, // T6 - componentTokenProblems, // T7 + citedTokenProblems, // T7 primitiveProblems, // T8 + rawCssProblems, // T9 ]; /** Every broken token rule, as `{file, message}`, in rule order. */ @@ -257,8 +312,10 @@ function check(project) { * cannot be read, so renders do not repeat T1. */ function cssNames(project) { const tree = read(project); - if (tree.unusable) return null; - return new Set([...tree.tokens.values()].map((token) => `${CSS_PREFIX}${token.path.join(CSS_SEPARATOR)}`)); + return tree.unusable ? null : new Set([...tree.tokens.values()].map((token) => cssName(token.path))); } -module.exports = { check, cssNames }; +/** A token path's CSS custom property. */ +const cssNameOf = (name) => cssName(name.split(PATH_SEPARATOR)); + +module.exports = { check, cssNames, cssNameOf }; diff --git a/scripts/__tests__/aidd-ui-code.test.js b/scripts/__tests__/aidd-ui-code.test.js index cd4c6b633..2d1648acb 100644 --- a/scripts/__tests__/aidd-ui-code.test.js +++ b/scripts/__tests__/aidd-ui-code.test.js @@ -57,7 +57,7 @@ describe("aidd-ui code link", () => { 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/tokens.css]", "ignore: [./src/ui/tokens.css]"); + edit(tmp, SYSTEM, "ignore: [src/ui/vendor]", "ignore: [./src/ui/vendor]"); assert.deepEqual(problems(), []); }); @@ -119,9 +119,19 @@ describe("aidd-ui code link", () => { }); 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 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", () => { @@ -151,6 +161,13 @@ describe("aidd-ui code link", () => { }); }); + 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]"); diff --git a/scripts/__tests__/aidd-ui-records.test.js b/scripts/__tests__/aidd-ui-records.test.js index db5cc66e9..b3727a875 100644 --- a/scripts/__tests__/aidd-ui-records.test.js +++ b/scripts/__tests__/aidd-ui-records.test.js @@ -160,7 +160,7 @@ describe("aidd-ui records and history", () => { }); it("names an id two records hold", () => { - const screen = ["id: brand", "kind: screen", "status: proposed", "version: 1", "uses: []", "states: [default]", "breakpoints: [mobile]", "route: /brand"]; + 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}`]); }); diff --git a/scripts/__tests__/aidd-ui-tokens-renders.test.js b/scripts/__tests__/aidd-ui-tokens-renders.test.js index 015b8b1e1..64f53f284 100644 --- a/scripts/__tests__/aidd-ui-tokens-renders.test.js +++ b/scripts/__tests__/aidd-ui-tokens-renders.test.js @@ -3,7 +3,7 @@ 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"); +const { UI, writeProject, edit, editTokens, 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. @@ -18,7 +18,10 @@ 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 INSERTED_LINE = 30; // the line before in button.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; @@ -140,47 +143,87 @@ describe("aidd-ui tokens and renders", () => { }); describe("T6 themes", () => { - const themes = (tree) => tree.color.brand.primary.$extensions["dev.ai-driven.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("names a theme system.md lacks", () => { - editTokens(tmp, (tree) => { - themes(tree).sepia = themes(tree).dark; - delete themes(tree).dark; + 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}`]); }); - assert.deepEqual(problems(), [ - `${TOKENS}: color.brand.primary: no value for theme "dark"`, - `${TOKENS}: color.brand.primary: theme "sepia" is not in system.md`, - ]); + } + + 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 lacking a declared theme", () => { - editTokens(tmp, (tree) => delete themes(tree).dark); - editTokens(tmp, (tree) => (themes(tree).light = themes(tree).dark ?? { colorSpace: "srgb", components: [1, 1, 1] })); - assert.deepEqual(problems(), [ - `${TOKENS}: color.brand.primary: no value for theme "dark"`, - `${TOKENS}: color.brand.primary: theme "light" is the base, its value is $value`, - ]); + 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 theme value aliasing a token of another type", () => { - editTokens(tmp, (tree) => (themes(tree).dark = "{space.md}")); - assert.deepEqual(problems(), [`${TOKENS}: color.brand.primary: theme "dark": alias {space.md} is a dimension, not a color`]); + 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", () => { - editTokens(tmp, (tree) => (themes(tree).dark.components = [0.4, 0.6])); - assert.deepEqual(problems(), [`${TOKENS}: color.brand.primary: theme "dark": components must be three numbers or "none"`]); + 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 component tokens", () => { + 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(), []); @@ -261,6 +304,13 @@ describe("aidd-ui tokens and renders", () => { }); }); + 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

'); diff --git a/scripts/__tests__/fixtures/aidd-ui/project.js b/scripts/__tests__/fixtures/aidd-ui/project.js index 9ddba3e27..3b10b730d 100644 --- a/scripts/__tests__/fixtures/aidd-ui/project.js +++ b/scripts/__tests__/fixtures/aidd-ui/project.js @@ -17,6 +17,8 @@ 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", @@ -31,7 +33,8 @@ const SYSTEM = record( "directions: [ltr, rtl]", "primitives: [color.brand]", "code-roots: [src/ui]", - "ignore: [src/ui/tokens.css]", + "code-tokens: src/ui/tokens.css", + "ignore: [src/ui/vendor]", "libraries: [@radix-ui/react-dialog]", ], "# Design system", @@ -59,9 +62,6 @@ const TOKENS = { brand: { primary: { $value: { colorSpace: "srgb", components: [0.2, 0.4, 0.6], hex: "#336699" }, - $extensions: { - "dev.ai-driven.themes": { dark: { colorSpace: "srgb", components: [0.4, 0.6, 0.8], hex: "#6699cc" } }, - }, }, }, text: { default: { $value: "{color.brand.primary}" } }, @@ -76,6 +76,16 @@ const TOKENS = { }, }; +// 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", @@ -92,7 +102,7 @@ const BUTTON = record( "version: 1", "implemented-version: 1", "uses: []", - "tokens: [color.action.default, color.text.default, space.md, border.thin, motion.fast, font.body, font.weight.bold]", + `tokens: [${BUTTON_TOKENS.join(", ")}]`, "variants: [primary, secondary]", "states: [default, hover, focus, disabled]", "interactive: yes", @@ -172,6 +182,7 @@ const HOME = record( "version: 1", "implemented-version: 1", "uses: [search-field, dialog]", + "tokens: [space.md, color.text.default]", "states: [default, empty, loading, error]", "breakpoints: [mobile, desktop]", "route: /", @@ -204,37 +215,27 @@ const REVIEW = record( history(FIRST_ROW) ); -const STYLE = [ - ".ui {", - " padding-block: var(--space-md);", - " padding-inline: var(--space-md);", - " margin-inline-start: 0;", - " color: var(--color-text-default);", - " background: var(--color-brand-primary);", - " border: var(--border-thin) solid var(--color-brand-primary);", - " font-family: var(--font-body);", - " font-weight: var(--font-weight-bold);", - " transition: background var(--motion-fast);", - " text-align: start;", - "}", -].join("\n"); - -const render = (title, body) => - `\n\n\n\n${title}\n\n\n\n${body.join("\n")}\n\n\n`; +/** 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", [ + "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", statesOf("default", ["default", "hover", "focus", "disabled"])), - "components/dialog.html": render("dialog", statesOf("default", ["default", "hover", "focus", "disabled"])), - "components/old-button.html": render("old-button", statesOf("default", ["default"])), + "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}
`) ), }; @@ -243,12 +244,15 @@ 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, From 836b71fc02083040225dea4466a00a2460b7378b Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 31 Oct 2026 16:12:48 +0100 Subject: [PATCH 21/26] feat(aidd-ui): give each problem its rule and severity, and scope what the hook prints Every problem carries its rule id, and is an error unless its rule calls it a warning: a code file no record links, and a render width of 2px or less. Only errors fail the validator or block a turn's end. After a write, the hook prints the problems of the files the write reached, its render and the records citing it, or all of them after a shared file, with the count of the other errors; every hook output stops at 20 lines and names the command that shows the rest. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XeK3mDvAh1xZvZGAKEVhJn --- .../2026_10/2026_10_30_aidd-ui-rework/spec.md | 2 +- plugins/aidd-ui/hooks/lib/code.cjs | 39 ++++--- plugins/aidd-ui/hooks/lib/graph.cjs | 41 +++++++ plugins/aidd-ui/hooks/lib/history.cjs | 25 +++-- plugins/aidd-ui/hooks/lib/project.cjs | 9 +- plugins/aidd-ui/hooks/lib/records.cjs | 45 ++++---- plugins/aidd-ui/hooks/lib/renders.cjs | 34 +++--- plugins/aidd-ui/hooks/lib/tokens.cjs | 33 +++--- plugins/aidd-ui/hooks/ui-hook.cjs | 57 +++++++--- plugins/aidd-ui/hooks/validate-ui.cjs | 17 ++- scripts/__tests__/aidd-ui-validate.test.js | 105 ++++++++++++++---- 11 files changed, 274 insertions(+), 133 deletions(-) create mode 100644 plugins/aidd-ui/hooks/lib/graph.cjs 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 index 734e5f079..e87bbdf17 100644 --- 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 @@ -120,7 +120,7 @@ Deux axes, jamais mêlés : où en est le design, et quelle version le code impl - 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, ou tous quand elle a écrit un fichier partagé (tokens, thèmes, `system.md`), et le nombre des autres ; la sortie du hook est plafonnée, la commande à la main montre tout. +- 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 ? diff --git a/plugins/aidd-ui/hooks/lib/code.cjs b/plugins/aidd-ui/hooks/lib/code.cjs index 0655ad6bb..6dd5469a1 100644 --- a/plugins/aidd-ui/hooks/lib/code.cjs +++ b/plugins/aidd-ui/hooks/lib/code.cjs @@ -6,7 +6,7 @@ const path = require("node:path"); const { list } = require("./markdown.cjs"); const { placed, systemOf, isElement, fm } = require("./records.cjs"); const { PATTERNS, findLiterals, declaresRightToLeft } = require("./renders.cjs"); -const { walk, text, relative, isUnder } = require("./project.cjs"); +const { walk, text, relative, isUnder, checkOf, WARNING } = require("./project.cjs"); const EXPORT_SEPARATOR = "#"; const PACKAGE_PREFIX = "package:"; @@ -117,18 +117,21 @@ function covers(target, file) { 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) => ({ file, message: "no record links it and no ignore covers it" })); + .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_FILE.test(file)) - .flatMap((file) => findLiterals(searchable(project, file), pattern).map(({ message }) => ({ file, message }))) + .flatMap((file) => findLiterals(searchable(project, file), pattern).map((found) => ({ file, ...found }))) : []; function codeTokensProblems(project) { @@ -156,18 +159,18 @@ function rootProblems(project) { .map((prefix) => ({ file: system.file, message: `code root "${prefix}" must name a folder inside the project` })); } -const RULES = [ - linkProblems, // C1 - libraryProblems, // C2 - coverageProblems, // C3 - literalProblems(PATTERNS.colour, SCANNED_EXTENSIONS), // C4 - sharedLinkProblems, // C5 - literalProblems(PATTERNS.physical, STYLESHEET_EXTENSIONS, declaresRightToLeft), // C6 - rootProblems, // C7 - codeTokensProblems, // C8 -]; - -/** Every broken code rule, as `{file, message}`, in rule order. */ -const check = (project) => RULES.flatMap((rule) => rule(project)); - -module.exports = { check, codeRoots }; +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..7e04668d3 --- /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("./records.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 index a330f8b8a..f14a3a7f5 100644 --- a/plugins/aidd-ui/hooks/lib/history.cjs +++ b/plugins/aidd-ui/hooks/lib/history.cjs @@ -2,6 +2,7 @@ // the task folder each change names. const { table, list } = require("./markdown.cjs"); const { placed, systemOf, isElement, fm, INTEGER_VERSION, SEMVER } = require("./records.cjs"); +const { checkOf } = require("./project.cjs"); const HISTORY_SECTION = "History"; const BREAKING_VALUES = ["yes", "no"]; @@ -124,17 +125,17 @@ function deprecationProblems(project) { .map((record) => problem(record, "deprecated, yet its last history row is not breaking")); } -const RULES = [ - presenceProblems, // H1 - duplicateProblems, // H2 - currentProblems, // H3 - breakingProblems, // H4 - publicationProblems, // H5 - deprecationProblems, // H6 - taskProblems, // H7 -]; - -/** Every broken history rule, as `{file, message}`, in rule order. */ -const check = (project) => RULES.flatMap((rule) => rule(project)); +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/project.cjs b/plugins/aidd-ui/hooks/lib/project.cjs index d4b213335..93c390d85 100644 --- a/plugins/aidd-ui/hooks/lib/project.cjs +++ b/plugins/aidd-ui/hooks/lib/project.cjs @@ -10,6 +10,8 @@ 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 = /^/; @@ -59,6 +61,11 @@ function text(project, file) { 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 })); @@ -89,4 +96,4 @@ function load(root) { return project; } -module.exports = { load, walk, text, unreadable, relative, isUnder, UI_DIR, RENDER_EXTENSION }; +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 index b6fd2bdb3..4e6e7925b 100644 --- a/plugins/aidd-ui/hooks/lib/records.cjs +++ b/plugins/aidd-ui/hooks/lib/records.cjs @@ -2,6 +2,7 @@ const fs = require("node:fs"); const path = require("node:path"); const { list, table } = require("./markdown.cjs"); +const { checkOf } = require("./project.cjs"); const STATUSES = ["proposed", "validated", "deprecated"]; const DESIGN_STATUSES = ["proposed", "validated"]; // R16: system, brand and reviews are never deprecated @@ -299,27 +300,27 @@ function formatProblem(project) { return format === undefined || format === FORMAT ? null : problem(system, `format "${format}" is not ${FORMAT}, the one this validator reads`); } -const RULES = [ - frontMatterProblems, // R1 - kindProblems, // R2 - requiredKeyProblems, // R3 - identityProblems, // R4 - versionProblems, // R5 - usesProblems, // R6 - stateProblems, // R7 - implementedProblems, // R8 - deprecatedProblems, // R9 - deprecatedUseProblems, // R10 - breakpointProblems, // R11 - journeyProblems, // R12 - reviewProblems, // R13 - brandProblems, // R14 - assetProblems, // R15 - designStatusProblems, // R16 - usageProblems, // R17 -]; - -/** Every broken record rule, as `{file, message}`, in rule order. */ -const check = (project) => RULES.flatMap((rule) => rule(project)); +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, placed, systemOf, isElement, fm, COMPONENT_KINDS, TOKENED_KINDS, INTEGER_VERSION, SEMVER }; diff --git a/plugins/aidd-ui/hooks/lib/renders.cjs b/plugins/aidd-ui/hooks/lib/renders.cjs index 37da5b46b..fd8340bdd 100644 --- a/plugins/aidd-ui/hooks/lib/renders.cjs +++ b/plugins/aidd-ui/hooks/lib/renders.cjs @@ -4,7 +4,7 @@ const path = require("node:path"); const { list } = require("./markdown.cjs"); const { placed, systemOf, COMPONENT_KINDS } = require("./records.cjs"); const { cssNames, cssNameOf } = require("./tokens.cjs"); -const { text, RENDER_EXTENSION } = require("./project.cjs"); +const { text, checkOf, RENDER_EXTENSION, ERROR, WARNING } = require("./project.cjs"); const RENDERED_KINDS = [...COMPONENT_KINDS, "screen"]; const RIGHT_TO_LEFT = "rtl"; @@ -14,6 +14,7 @@ const CUSTOM_PROPERTY = /var\(\s*(--[\w-]+)/g; const DECLARED_PROPERTY = /(--[\w-]+)\s*:/g; // a property the render sets itself, not a token 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 /** Literals a render or UI code must not hold, each with the message it earns. */ const PATTERNS = { @@ -24,6 +25,7 @@ const PATTERNS = { 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: { @@ -32,11 +34,11 @@ const PATTERNS = { }, }; -/** Each match of `pattern` in `text`, as `{line, message}`, lines numbered from 1. */ +/** 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((match) => ({ line: index + 1, message: `line ${index + 1}: ${pattern.message(match[0])}` })); + return [...searched.matchAll(pattern.regex)].map(([found]) => ({ severity: pattern.severity?.(found) ?? ERROR, message: `line ${index + 1}: ${pattern.message(found)}` })); }); } @@ -101,19 +103,19 @@ function declaredProblems(project) { } const literalProblems = (pattern, applies = () => true) => (project) => - applies(project) ? project.renders.flatMap((file) => findLiterals(readRender(project, file), pattern).map(({ message }) => ({ file, message }))) : []; + applies(project) ? project.renders.flatMap((file) => findLiterals(readRender(project, file), pattern).map((found) => ({ file, ...found }))) : []; -const RULES = [ - presenceProblems, // V1 - coverageProblems, // V2 - referenceProblems, // V3 - literalProblems(PATTERNS.colour), // V4 - literalProblems(PATTERNS.physical, declaresRightToLeft), // V5 - literalProblems(PATTERNS.unit), // V6 - declaredProblems, // V7 -]; +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, as `{file, message}`, in rule order. */ -const check = (project) => RULES.flatMap((rule) => rule(project)); +/** Every broken render rule, in rule order. */ +const check = checkOf(RULES); -module.exports = { check, PATTERNS, findLiterals, declaresRightToLeft }; +module.exports = { check, renderOf, PATTERNS, findLiterals, declaresRightToLeft }; diff --git a/plugins/aidd-ui/hooks/lib/tokens.cjs b/plugins/aidd-ui/hooks/lib/tokens.cjs index 4db6c3b8d..a78ab0b98 100644 --- a/plugins/aidd-ui/hooks/lib/tokens.cjs +++ b/plugins/aidd-ui/hooks/lib/tokens.cjs @@ -5,7 +5,7 @@ const fs = require("node:fs"); const nodePath = require("node:path"); const { list } = require("./markdown.cjs"); const { placed, systemOf, TOKENED_KINDS } = require("./records.cjs"); -const { text } = require("./project.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 @@ -290,23 +290,20 @@ function primitiveProblems(tree, project) { ); } -const RULES = [ - jsonProblems, // T1 - nameProblems, // T2 - typeProblems, // T3 - shapeProblems, // T4 - aliasProblems, // T5 - themeProblems, // T6 - citedTokenProblems, // T7 - primitiveProblems, // T8 - rawCssProblems, // T9 -]; - -/** Every broken token rule, as `{file, message}`, in rule order. */ -function check(project) { - const tree = read(project); - return RULES.flatMap((rule) => rule(tree, project)); -} +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. */ diff --git a/plugins/aidd-ui/hooks/ui-hook.cjs b/plugins/aidd-ui/hooks/ui-hook.cjs index 57a56a513..ec73a3eb8 100644 --- a/plugins/aidd-ui/hooks/ui-hook.cjs +++ b/plugins/aidd-ui/hooks/ui-hook.cjs @@ -1,19 +1,23 @@ // 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 come back as context. At Stop, a marked turn gets -// its problems back once, as the reason to keep working. At session start they are printed as -// context. A file that cannot be read is one problem; anything else unexpected exits 0 in -// silence, so a broken check never blocks a session. +// 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 { problemsOf, format } = require("./validate-ui.cjs"); +const { reached } = require("./lib/graph.cjs"); +const { problemsOf, format, isError } = require("./validate-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 VALIDATOR = path.join(__dirname, "validate-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. @@ -29,11 +33,8 @@ function readPayload() { } } -/** Whether a written file belongs to the design system or to its code. */ -function isWatched(project, filePath) { - const file = relative(project.root, path.resolve(project.root, filePath)); - return [UI_DIR, ...codeRoots(project)].some((prefix) => isUnder(file, prefix)); -} +/** 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) { @@ -66,13 +67,31 @@ 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 ${VALIDATOR} ${project.root}`] : [])]; +} + function afterWrite(project, payload) { - if (!writtenFiles(payload.tool_input).some((file) => isWatched(project, file))) return EXIT_SILENT; + 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); - if (!problems.length) return EXIT_SILENT; - const text = [`aidd-ui: the write was saved, fix ${problems.length} problems:`, ...format(problems)].join("\n"); + 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 } @@ -90,18 +109,20 @@ function consumeMark(payload) { return payload.stop_hook_active !== true; } -/** Blocks the turn's end once while a marked turn still leaves problems behind. */ +/** Blocks the turn's end once while a marked turn still leaves errors behind. */ function atStop(project) { - const problems = problemsOf(project); - if (!problems.length) return EXIT_SILENT; - const reason = [`aidd-ui: the design system has ${problems.length} problems, fix them before you finish:`, ...format(problems)].join("\n"); + 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) process.stdout.write(`aidd-ui: ${problems.length} problems\n${format(problems).join("\n")}\n`); + 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; } diff --git a/plugins/aidd-ui/hooks/validate-ui.cjs b/plugins/aidd-ui/hooks/validate-ui.cjs index ca6625d69..67eb6a34e 100644 --- a/plugins/aidd-ui/hooks/validate-ui.cjs +++ b/plugins/aidd-ui/hooks/validate-ui.cjs @@ -2,7 +2,7 @@ // Usage: node validate-ui.cjs [project-root] // Checks a project's design system under aidd_docs/ui/: records, history, tokens, renders and // code links. Prints VALID, or one line per problem and exits 1. Prints nothing without one. -const { load, unreadable, UI_DIR } = require("./lib/project.cjs"); +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"); @@ -13,7 +13,8 @@ const CHECKS = { records, history, tokens, renders, code }; const VERDICT_VALID = "VALID"; const EXIT_VALID = 0; const EXIT_INVALID = 1; -const PROBLEM_MARK = "✗"; +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 @@ -39,16 +40,20 @@ function validate(root) { return project === null ? null : problemsOf(project); } -/** One `✗ : ` line per problem, the file relative to the project root. */ -const format = (problems) => problems.map((problem) => `${PROBLEM_MARK} ${problem.file}: ${problem.message}`); +/** 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 main(args) { const problems = validate(args[0] ?? process.cwd()); if (problems === null) return EXIT_VALID; console.log(problems.length ? format(problems).join("\n") : VERDICT_VALID); - return problems.length ? EXIT_INVALID : EXIT_VALID; + return problems.some(isError) ? EXIT_INVALID : EXIT_VALID; } if (require.main === module) process.exitCode = main(process.argv.slice(2)); -module.exports = { validate, problemsOf, format }; +module.exports = { validate, problemsOf, format, isError }; diff --git a/scripts/__tests__/aidd-ui-validate.test.js b/scripts/__tests__/aidd-ui-validate.test.js index ab9c86340..35a3146e3 100644 --- a/scripts/__tests__/aidd-ui-validate.test.js +++ b/scripts/__tests__/aidd-ui-validate.test.js @@ -20,10 +20,11 @@ const TOKENS = `${UI}/tokens.json`; /** One mutation from each family: records, tokens, code. */ const FAILURES = [ - `✗ ${BRAND}: lacks "## Users"`, - `✗ ${TOKENS}: space.md: unit "em" must be px or rem`, - `✗ ${DIALOG}: package "left-pad" is not in system.md libraries`, + `✗ ${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; @@ -68,12 +69,14 @@ const copilotPatch = (...headers) => JSON.stringify({ hook_event_name: "PostTool 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: "" }; -const feedbackText = (problems) => [`aidd-ui: the write was saved, fix ${problems.length} problems:`, ...problems].join("\n"); +/** 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) => ({ code: 0, out: JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: feedbackText(problems) } }), err: "" }); +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) => ({ code: 0, out: JSON.stringify({ additionalContext: feedbackText(problems) }), err: "" }); -const finish = (problems) => ({ code: 0, out: JSON.stringify({ decision: "block", reason: [`aidd-ui: the design system has ${problems.length} problems, fix them before you finish:`, ...problems].join("\n") }), err: "" }); +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. @@ -93,6 +96,39 @@ describe("aidd-ui validator entries", () => { 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("E1 no design system", () => { it("prints nothing without aidd_docs/ui/", () => { fs.rmSync(path.join(tmp, UI), { recursive: true }); @@ -123,34 +159,34 @@ describe("aidd-ui validator entries", () => { 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(FAILURES)); + 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(FAILURES)); + 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(FAILURES)); + 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(FAILURES)); + 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(FAILURES)); + 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(FAILURES)); + assert.deepEqual(hook(input), copilotFeedback([BRAND_FAILURE], 2)); }); it("stays silent when a patch touches only files elsewhere", () => { @@ -158,6 +194,33 @@ describe("aidd-ui validator entries", () => { 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 ${VALIDATE} ${tmp}`])); + }); + it("stays silent when a write lands elsewhere", () => { breakOnePerFamily(); assert.deepEqual(hook(postToolUse(path.join(tmp, "README.md"))), silent); @@ -193,13 +256,13 @@ describe("aidd-ui validator entries", () => { 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(FAILURES)); + 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(FAILURES)); + 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", () => { @@ -238,7 +301,7 @@ describe("aidd-ui validator entries", () => { 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(FAILURES)); + assert.deepEqual(hook(input), claudeFeedback([BRAND_FAILURE], 2)); }); it("stays silent without a session id, since it cannot tell a turn apart", () => { @@ -249,9 +312,9 @@ describe("aidd-ui validator entries", () => { }); describe("E4 SessionStart", () => { - it("prints the count then the problems, and exits 0", () => { + it("prints the counts then the errors, and exits 0", () => { breakOnePerFamily(); - assert.deepEqual(hook(sessionStart()), { code: 0, out: `aidd-ui: 3 problems\n${FAILURES.join("\n")}\n`, err: "" }); + 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/", () => { @@ -280,15 +343,15 @@ describe("aidd-ui validator entries", () => { 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 problems\n${UNREADABLE}\n`, err: "" }); - assert.deepEqual(hook(postToolUse(path.join(tmp, BRAND))), claudeFeedback([UNREADABLE])); + 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", "✗ src/ui/Folder.css: unreadable"], + out: ["⚠ src/ui/Folder.css: no record links it and no ignore covers it (C3)", "✗ src/ui/Folder.css: unreadable"], err: "", }); }); From 192368c902ef1a9d62b8081db23346f5c75833c7 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 31 Oct 2026 16:15:23 +0100 Subject: [PATCH 22/26] fix(aidd-ui): name alias cycles and prove the guards no test held An alias cycle is now a problem on each of its tokens instead of a stated limit, and a walk into a cycle from outside it ends. A test now fails without each guard that had none: an export matched as a whole word, a code root or ignore with a trailing slash, and a length inside var() left to the token. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XeK3mDvAh1xZvZGAKEVhJn --- plugins/aidd-ui/hooks/lib/tokens.cjs | 31 +++++++++++++------ scripts/__tests__/aidd-ui-code.test.js | 11 +++++++ .../__tests__/aidd-ui-tokens-renders.test.js | 24 ++++++++++++++ 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/plugins/aidd-ui/hooks/lib/tokens.cjs b/plugins/aidd-ui/hooks/lib/tokens.cjs index a78ab0b98..0fd339a72 100644 --- a/plugins/aidd-ui/hooks/lib/tokens.cjs +++ b/plugins/aidd-ui/hooks/lib/tokens.cjs @@ -34,7 +34,6 @@ 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 MAX_ALIAS_DEPTH = 32; // alias cycles are a stated limit; this only stops the walk const FORBIDDEN_IN_NAME = /[{}.]/; const ALIAS = /^\{([^{}]+)\}$/; @@ -100,12 +99,24 @@ function read(project) { 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. */ -function typeOf(tree, token, depth = 0) { +/** 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 && depth < MAX_ALIAS_DEPTH) return typeOf(tree, target, depth + 1); - return token.groupType; + 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) { @@ -172,7 +183,7 @@ function typeProblems(tree, 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)) return []; // T5 names the broken alias + 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`)]; @@ -187,11 +198,13 @@ function shapeProblems(tree, project) { }); } -/** What is wrong with an alias, or null: its target must be a token, of `type` when given. */ -function aliasProblem(tree, value, type) { +/** 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}`; } @@ -199,7 +212,7 @@ function aliasProblem(tree, value, 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); + const message = aliasProblem(tree, token.node.$value, token.node.$type ?? token.groupType, name); return message ? [at(name, message)] : []; }); } diff --git a/scripts/__tests__/aidd-ui-code.test.js b/scripts/__tests__/aidd-ui-code.test.js index 2d1648acb..4d7e2ea9a 100644 --- a/scripts/__tests__/aidd-ui-code.test.js +++ b/scripts/__tests__/aidd-ui-code.test.js @@ -66,6 +66,17 @@ describe("aidd-ui code link", () => { 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`]); diff --git a/scripts/__tests__/aidd-ui-tokens-renders.test.js b/scripts/__tests__/aidd-ui-tokens-renders.test.js index 64f53f284..e2337981e 100644 --- a/scripts/__tests__/aidd-ui-tokens-renders.test.js +++ b/scripts/__tests__/aidd-ui-tokens-renders.test.js @@ -136,6 +136,23 @@ describe("aidd-ui tokens and renders", () => { 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`]); @@ -296,6 +313,13 @@ describe("aidd-ui tokens and renders", () => { } }); + 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(""); From dfe6e09c9521e259a1382cb0423832e8fc0d8126 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 31 Oct 2026 16:18:13 +0100 Subject: [PATCH 23/26] refactor(aidd-ui): give every check the record model instead of another check The record model moves to model.cjs and the literal patterns to literals.cjs, so a check depends on the model, not on a sibling check. Dead guards go: the payload shape test, a trim no header needs, the folder test readdir already makes, the validate wrapper nothing called. Files sort by code unit, the same on every machine; the code tree caches on the project like the token tree; the script scan reads runs of plain text at once, from 1.37 s to 0.9 s on a 5,000-file project. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XeK3mDvAh1xZvZGAKEVhJn --- plugins/aidd-ui/hooks/lib/code.cjs | 21 +++++-------- plugins/aidd-ui/hooks/lib/graph.cjs | 2 +- plugins/aidd-ui/hooks/lib/history.cjs | 5 +-- plugins/aidd-ui/hooks/lib/literals.cjs | 41 ++++++++++++++++++++++++ plugins/aidd-ui/hooks/lib/markdown.cjs | 6 ++-- plugins/aidd-ui/hooks/lib/model.cjs | 43 ++++++++++++++++++++++++++ plugins/aidd-ui/hooks/lib/project.cjs | 3 +- plugins/aidd-ui/hooks/lib/records.cjs | 43 ++------------------------ plugins/aidd-ui/hooks/lib/renders.cjs | 43 +++----------------------- plugins/aidd-ui/hooks/lib/tokens.cjs | 12 +++---- plugins/aidd-ui/hooks/ui-hook.cjs | 9 +++--- plugins/aidd-ui/hooks/validate-ui.cjs | 13 +++----- 12 files changed, 118 insertions(+), 123 deletions(-) create mode 100644 plugins/aidd-ui/hooks/lib/literals.cjs create mode 100644 plugins/aidd-ui/hooks/lib/model.cjs diff --git a/plugins/aidd-ui/hooks/lib/code.cjs b/plugins/aidd-ui/hooks/lib/code.cjs index 6dd5469a1..33fd0bd51 100644 --- a/plugins/aidd-ui/hooks/lib/code.cjs +++ b/plugins/aidd-ui/hooks/lib/code.cjs @@ -4,8 +4,8 @@ const fs = require("node:fs"); const path = require("node:path"); const { list } = require("./markdown.cjs"); -const { placed, systemOf, isElement, fm } = require("./records.cjs"); -const { PATTERNS, findLiterals, declaresRightToLeft } = require("./renders.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 = "#"; @@ -18,23 +18,20 @@ const TEST_FILE = /(?:^|\/)(?:__tests__|__mocks__)\/|\.(?: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 BLANK = " "; -const CURRENT_FOLDER = "."; -const PARENT_FOLDER = ".."; 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]/g; +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, BLANK)); +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 !== CURRENT_FOLDER && !prefix.startsWith(PARENT_FOLDER) && !path.posix.isAbsolute(prefix); +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. */ @@ -54,8 +51,6 @@ const linked = (project) => const hasWord = (text, word) => new RegExp(`(? walk(path.join(project.root, root)).map((file) => relative(project.root, file))); const tokenFile = codeTokens(project); - trees.set(project, { files: [...new Set(files)], ignore: [...prefixes(project, IGNORE_KEY), ...(tokenFile ? [tokenFile] : [])] }); + project.codeTree = { files: [...new Set(files)], ignore: [...prefixes(project, IGNORE_KEY), ...(tokenFile ? [tokenFile] : [])] }; } - return trees.get(project); + return project.codeTree; } /** Every file under a code root that no `ignore` prefix covers, relative to the project root. */ diff --git a/plugins/aidd-ui/hooks/lib/graph.cjs b/plugins/aidd-ui/hooks/lib/graph.cjs index 7e04668d3..154a620e2 100644 --- a/plugins/aidd-ui/hooks/lib/graph.cjs +++ b/plugins/aidd-ui/hooks/lib/graph.cjs @@ -2,7 +2,7 @@ // 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("./records.cjs"); +const { placed, fm } = require("./model.cjs"); const { renderOf } = require("./renders.cjs"); const { codeOwner } = require("./code.cjs"); const { isUnder, UI_DIR } = require("./project.cjs"); diff --git a/plugins/aidd-ui/hooks/lib/history.cjs b/plugins/aidd-ui/hooks/lib/history.cjs index f14a3a7f5..99e6791df 100644 --- a/plugins/aidd-ui/hooks/lib/history.cjs +++ b/plugins/aidd-ui/hooks/lib/history.cjs @@ -1,17 +1,14 @@ // 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, fm, INTEGER_VERSION, SEMVER } = require("./records.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 SYSTEM_KIND = "system"; const TASKS_DIR = "aidd_docs/tasks/"; -const problem = (record, message) => ({ file: record.file, message }); -const isSystem = (record) => fm(record).kind === SYSTEM_KIND; 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)); 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 index c39e1715e..2b0f4a43c 100644 --- a/plugins/aidd-ui/hooks/lib/markdown.cjs +++ b/plugins/aidd-ui/hooks/lib/markdown.cjs @@ -1,10 +1,8 @@ // Reads a UI record: flat front matter, `## ` sections, and a section's first table. const LINE_BREAK = "\n"; -const FENCE = "---"; const SECTION_PREFIX = "## "; const TABLE_CELL_SEPARATOR = "|"; -const LIST_SEPARATOR = ","; const ENTRY = /^([a-z][a-z0-9-]*):[ \t]*(.*)$/; const LIST = /^\[(.*)\]$/; @@ -21,12 +19,12 @@ function parseValue(raw) { const list = raw.trim().match(LIST); if (!list) return unquote(raw.trim()); return list[1] - .split(LIST_SEPARATOR) + .split(",") .map((item) => unquote(item.trim())) .filter(Boolean); } -const isFence = (line) => line.trimEnd() === FENCE; +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. */ 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 index 93c390d85..bd985f56e 100644 --- a/plugins/aidd-ui/hooks/lib/project.cjs +++ b/plugins/aidd-ui/hooks/lib/project.cjs @@ -29,7 +29,6 @@ function isUnder(file, prefix) { /** 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 = () => {}) { - if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return []; let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); @@ -38,7 +37,7 @@ function walk(dir, skip = () => false, unlisted = () => {}) { return []; } return entries - .sort((a, b) => a.name.localeCompare(b.name)) + .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]; diff --git a/plugins/aidd-ui/hooks/lib/records.cjs b/plugins/aidd-ui/hooks/lib/records.cjs index 4e6e7925b..1de1228de 100644 --- a/plugins/aidd-ui/hooks/lib/records.cjs +++ b/plugins/aidd-ui/hooks/lib/records.cjs @@ -3,6 +3,7 @@ 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 @@ -20,7 +21,6 @@ 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 SYSTEM_ID = "system"; const COMMON_KEYS = ["id", "kind", "status", "version"]; const COMPONENT_KEYS = ["uses", "tokens", "variants", "states", "interactive", "role"]; @@ -35,49 +35,12 @@ const KIND_KEYS = { asset: ["type", "file", "alt"], review: ["scope", "screens"], }; -const FOLDER_KINDS = { - "": ["system", "brand"], - components: ["atom", "molecule", "composition"], - screens: ["screen"], - journeys: ["journey"], - assets: ["asset"], - reviews: ["review"], -}; -const COMPONENT_KINDS = FOLDER_KINDS.components; const ASSEMBLED_KINDS = ["molecule", "composition"]; // implemented through what they use when no file is their own -const IMPLEMENTED_KINDS = [...COMPONENT_KINDS, "screen"]; -const TOKENED_KINDS = IMPLEMENTED_KINDS; // components and screens cite the tokens their render uses const DESIGN_KINDS = ["system", "brand", "review"]; -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 listWithOr = (values) => `${values.slice(0, -1).join(", ")} or ${values.at(-1)}`; -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); const isDeprecated = (record) => fm(record).status === "deprecated"; const folderLabel = (folder) => (folder ? `${folder}/` : "aidd_docs/ui/"); -/** 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); - function frontMatterProblems(project) { return project.records.flatMap((record) => record.problems.map((parse) => problem(record, `line ${parse.line}: ${parse.message}`))); } @@ -125,7 +88,7 @@ function versionProblems(project) { return placed(project).flatMap((record) => { const version = fm(record).version; if (version === undefined) return []; - if (fm(record).kind === SYSTEM_ID) return SEMVER.test(version) ? [] : [problem(record, `version "${version}" must be semver, such as 2.0.0`)]; + 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`)]; }); } @@ -323,4 +286,4 @@ const RULES = { /** Every broken record rule, in rule order. */ const check = checkOf(RULES); -module.exports = { check, formatProblem, placed, systemOf, isElement, fm, COMPONENT_KINDS, TOKENED_KINDS, INTEGER_VERSION, SEMVER }; +module.exports = { check, formatProblem }; diff --git a/plugins/aidd-ui/hooks/lib/renders.cjs b/plugins/aidd-ui/hooks/lib/renders.cjs index fd8340bdd..608940980 100644 --- a/plugins/aidd-ui/hooks/lib/renders.cjs +++ b/plugins/aidd-ui/hooks/lib/renders.cjs @@ -2,51 +2,18 @@ // through the tokens its record declares alone. const path = require("node:path"); const { list } = require("./markdown.cjs"); -const { placed, systemOf, COMPONENT_KINDS } = require("./records.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, ERROR, WARNING } = require("./project.cjs"); +const { text, checkOf, RENDER_EXTENSION } = require("./project.cjs"); -const RENDERED_KINDS = [...COMPONENT_KINDS, "screen"]; -const RIGHT_TO_LEFT = "rtl"; 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 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 - -/** 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); const renderOf = (record) => `${path.dirname(record.file)}/${record.frontMatter.id}${RENDER_EXTENSION}`; -const rendered = (project) => placed(project).filter((record) => RENDERED_KINDS.includes(record.frontMatter.kind) && record.frontMatter.id === record.name); +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) ?? ""; @@ -118,4 +85,4 @@ const RULES = { /** Every broken render rule, in rule order. */ const check = checkOf(RULES); -module.exports = { check, renderOf, PATTERNS, findLiterals, declaresRightToLeft }; +module.exports = { check, renderOf }; diff --git a/plugins/aidd-ui/hooks/lib/tokens.cjs b/plugins/aidd-ui/hooks/lib/tokens.cjs index 0fd339a72..982d2ac1b 100644 --- a/plugins/aidd-ui/hooks/lib/tokens.cjs +++ b/plugins/aidd-ui/hooks/lib/tokens.cjs @@ -4,15 +4,13 @@ const fs = require("node:fs"); const nodePath = require("node:path"); const { list } = require("./markdown.cjs"); -const { placed, systemOf, TOKENED_KINDS } = require("./records.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 CSS_PREFIX = "--"; -const CSS_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"; @@ -46,7 +44,7 @@ const childKeys = (node) => Object.keys(node).filter((key) => !key.startsWith(PR 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 walk(node, path, groupType, found) { +function collect(node, path, groupType, found) { for (const key of Object.keys(node)) { const child = node[key]; const childPath = [...path, key]; @@ -61,7 +59,7 @@ function walk(node, path, groupType, found) { if ("$value" in child || childKeys(child).length === 0) found.tokens.set(name, { path: childPath, node: child, groupType }); else { found.groups.add(name); - walk(child, childPath, child.$type ?? groupType, found); + collect(child, childPath, child.$type ?? groupType, found); } } return found; @@ -86,7 +84,7 @@ function readJson(project, file) { /** 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 ? walk(json.value, [], json.value.$type, emptyTree()) : { ...emptyTree(), unusable: true, problem: json.problem }; + return json.value ? collect(json.value, [], json.value.$type, emptyTree()) : { ...emptyTree(), unusable: true, problem: json.problem }; } /** tokens.json's tree, read once per run. */ @@ -167,7 +165,7 @@ const shapeProblem = (type, value) => (aliasOf(value) !== undefined || !SHAPES[t /** 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) => `${CSS_PREFIX}${path.join(CSS_SEPARATOR)}`; +const cssName = (path) => `--${path.join("-")}`; function jsonProblems(tree, project) { return tree.problem ? [{ file: project.tokensFile, message: tree.problem }] : []; diff --git a/plugins/aidd-ui/hooks/ui-hook.cjs b/plugins/aidd-ui/hooks/ui-hook.cjs index ec73a3eb8..032f3d716 100644 --- a/plugins/aidd-ui/hooks/ui-hook.cjs +++ b/plugins/aidd-ui/hooks/ui-hook.cjs @@ -23,11 +23,10 @@ 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 object on stdin, or null when there is none or it is not one. */ +/** The JSON on stdin, or null when it is none. */ function readPayload() { try { - const parsed = JSON.parse(fs.readFileSync(0, "utf8")); - return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null; + return JSON.parse(fs.readFileSync(0, "utf8")); } catch { return null; } @@ -42,7 +41,7 @@ function writtenFiles(toolInput) { 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].trim()); + 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. */ @@ -128,7 +127,7 @@ function atSessionStart(project) { function run() { const payload = readPayload(); - if (payload === null) return EXIT_SILENT; + 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()); diff --git a/plugins/aidd-ui/hooks/validate-ui.cjs b/plugins/aidd-ui/hooks/validate-ui.cjs index 67eb6a34e..d8490c0a2 100644 --- a/plugins/aidd-ui/hooks/validate-ui.cjs +++ b/plugins/aidd-ui/hooks/validate-ui.cjs @@ -34,12 +34,6 @@ function problemsOf(project, checks = CHECKS) { ]; } -/** The problems of the design system under `root`, or null when it has none. */ -function validate(root) { - const project = load(root); - return project === null ? null : problemsOf(project); -} - /** Whether a problem blocks: anything a rule does not call a warning. */ const isError = (problem) => problem.severity !== WARNING; @@ -48,12 +42,13 @@ const isError = (problem) => problem.severity !== WARNING; const format = (problems) => problems.map((problem) => `${isError(problem) ? ERROR_MARK : WARNING_MARK} ${problem.file}: ${problem.message}${problem.rule ? ` (${problem.rule})` : ""}`); function main(args) { - const problems = validate(args[0] ?? process.cwd()); - if (problems === null) return EXIT_VALID; + const project = load(args[0] ?? process.cwd()); + 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; } if (require.main === module) process.exitCode = main(process.argv.slice(2)); -module.exports = { validate, problemsOf, format, isError }; +module.exports = { problemsOf, format, isError }; From 427b4117418eeb95abec6c3192ded33999039b85 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 31 Oct 2026 16:21:07 +0100 Subject: [PATCH 24/26] feat(aidd-ui): run every design system job through one engine command validate-ui.cjs becomes aidd-ui.cjs, which takes the job as a command, validate for now, so the generators later pull requests add share its reader instead of copying it. The README states the new format: implemented-version, publication rows, task folders, verdict versions, the Resolver profile, code-tokens, severities and what each hook moment prints. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XeK3mDvAh1xZvZGAKEVhJn --- plugins/aidd-ui/README.md | 77 ++++++++++++------- .../hooks/{validate-ui.cjs => aidd-ui.cjs} | 20 +++-- plugins/aidd-ui/hooks/ui-hook.cjs | 6 +- scripts/__tests__/aidd-ui-validate.test.js | 15 +++- 4 files changed, 78 insertions(+), 40 deletions(-) rename plugins/aidd-ui/hooks/{validate-ui.cjs => aidd-ui.cjs} (74%) diff --git a/plugins/aidd-ui/README.md b/plugins/aidd-ui/README.md index a26d267f6..ee7c2f3b6 100644 --- a/plugins/aidd-ui/README.md +++ b/plugins/aidd-ui/README.md @@ -21,63 +21,84 @@ One file per element, under `aidd_docs/ui/`. ```txt aidd_docs/ui/ -├── system.md the design system -├── brand.md the brand -├── tokens.json tokens, DTCG 2025.10 -├── 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 +├── 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. +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`, `coded`, `deprecated`), `version` | `## History` | -| `system.md` | `version` in semver, `platforms`, `breakpoints`, `themes`, `locales`, `directions`, `primitives` (token groups components never cite), `code-roots`, `ignore`, `libraries` | optional `## Rules`: Id, Rule | +| 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`, `code` | `## Usage`, and `## Keyboard` when interactive | -| screen | `uses`, `states`, `breakpoints`, `route`, `code` | | +| 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, Verdict (`ok`, `ko`, `pending`), Comment | +| 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 -- `code` is `path#Export`, or `package:#Export` for a library component; required once `coded`, except for a molecule or composition whose used components are all coded. - 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 theme value is a token extension: `"$extensions": {"dev.ai-driven.themes": {"dark": }}`. The first theme in `system.md` is the base, held by `$value`; a themed token gives every other one. -- A value DTCG cannot hold, such as `color-mix()` or `100%`, is raw CSS: `"$extensions": {"dev.ai-driven.css": ""}`, checked for presence only. +- 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 -A hook checks the design system after every write under `aidd_docs/ui/` or a code root, and at session start. By hand: - ```sh -node /hooks/validate-ui.cjs [project-root] +node /hooks/aidd-ui.cjs validate [project-root] ``` -It prints `VALID`, or one `✗ : ` line per problem and exits 1. +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 under the design system or a code root, the problems go back to the model in the same turn on Claude Code, Codex, Copilot CLI, OpenCode and Kilo. -- At the end of a turn that wrote such a file, the hook asks the model to continue once while problems remain, as the next prompt on OpenCode and Kilo. A forced continuation is never asked again. The mark lives in the OS temp directory; without a session id, no end-of-turn check runs. +- 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. -- Alias cycles are not detected. - `code-roots` and `ignore` are path prefixes, not globs. -- In code, only colour literals are found, in web code only; other literals are left to the UI audit. -- In script files, `.vue` and `.svelte` included, colours are found in string literals only. +- 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. diff --git a/plugins/aidd-ui/hooks/validate-ui.cjs b/plugins/aidd-ui/hooks/aidd-ui.cjs similarity index 74% rename from plugins/aidd-ui/hooks/validate-ui.cjs rename to plugins/aidd-ui/hooks/aidd-ui.cjs index d8490c0a2..a553d528e 100644 --- a/plugins/aidd-ui/hooks/validate-ui.cjs +++ b/plugins/aidd-ui/hooks/aidd-ui.cjs @@ -1,7 +1,8 @@ #!/usr/bin/env node -// Usage: node validate-ui.cjs [project-root] -// Checks a project's design system under aidd_docs/ui/: records, history, tokens, renders and -// code links. Prints VALID, or one line per problem and exits 1. Prints nothing without one. +// 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"); @@ -13,6 +14,7 @@ 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 = "⚠"; @@ -41,14 +43,22 @@ const isError = (problem) => problem.severity !== WARNING; * the project root. */ const format = (problems) => problems.map((problem) => `${isError(problem) ? ERROR_MARK : WARNING_MARK} ${problem.file}: ${problem.message}${problem.rule ? ` (${problem.rule})` : ""}`); -function main(args) { - const project = load(args[0] ?? process.cwd()); +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/ui-hook.cjs b/plugins/aidd-ui/hooks/ui-hook.cjs index 032f3d716..ed67e1993 100644 --- a/plugins/aidd-ui/hooks/ui-hook.cjs +++ b/plugins/aidd-ui/hooks/ui-hook.cjs @@ -10,14 +10,14 @@ 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("./validate-ui.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 VALIDATOR = path.join(__dirname, "validate-ui.cjs"); +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. @@ -71,7 +71,7 @@ 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 ${VALIDATOR} ${project.root}`] : [])]; + return [...format(problems.slice(0, LIMIT)), ...(rest > 0 ? [`… and ${rest} more: node ${ENGINE} validate ${project.root}`] : [])]; } function afterWrite(project, payload) { diff --git a/scripts/__tests__/aidd-ui-validate.test.js b/scripts/__tests__/aidd-ui-validate.test.js index 35a3146e3..57bf0e33c 100644 --- a/scripts/__tests__/aidd-ui-validate.test.js +++ b/scripts/__tests__/aidd-ui-validate.test.js @@ -9,9 +9,9 @@ const { UI, writeProject, edit, editTokens } = require("./fixtures/aidd-ui/proje // 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 VALIDATE = path.join(HOOKS, "validate-ui.cjs"); +const ENGINE = path.join(HOOKS, "aidd-ui.cjs"); const HOOK = path.join(HOOKS, "ui-hook.cjs"); -const { problemsOf, format } = require(VALIDATE); +const { problemsOf, format } = require(ENGINE); const { load } = require(path.join(HOOKS, "lib/project.cjs")); const BRAND = `${UI}/brand.md`; @@ -51,7 +51,7 @@ function breakOnePerFamily() { const lines = (text) => text.split("\n").filter(Boolean); function validate(root) { - const run = spawnSync(process.execPath, [VALIDATE, root], { encoding: "utf8" }); + const run = spawnSync(process.execPath, [ENGINE, "validate", root], { encoding: "utf8" }); return { code: run.status, out: lines(run.stdout), err: run.stderr }; } @@ -129,6 +129,13 @@ describe("aidd-ui validator entries", () => { }); }); + 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 }); @@ -218,7 +225,7 @@ describe("aidd-ui validator entries", () => { 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 ${VALIDATE} ${tmp}`])); + 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", () => { From 8c3f0c91763c3e8b33a826529987024d49568508 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 31 Oct 2026 16:24:33 +0100 Subject: [PATCH 25/26] docs(aidd-ui): place the change flow and state the limits still unwritten The change flow belongs to the design system skill, regenerating renders through the engine and handing review back without naming a skill. The README states what a code root walks, how loosely a variant and an export are found, and the unquoted plugin root. The foundation plan points to the spec for the decisions taken after it ran. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XeK3mDvAh1xZvZGAKEVhJn --- aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-foundation/plan.md | 2 ++ aidd_docs/tasks/2026_10/2026_10_30_aidd-ui-rework/spec.md | 1 + plugins/aidd-ui/README.md | 4 +++- 3 files changed, 6 insertions(+), 1 deletion(-) 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 index 4510b608c..929fd751a 100644 --- 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 @@ -7,6 +7,8 @@ status: implemented 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 | 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 index e87bbdf17..8ab3e15fe 100644 --- 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 @@ -159,6 +159,7 @@ Deux axes, jamais mêlés : où en est le design, et quelle version le code impl | 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. | diff --git a/plugins/aidd-ui/README.md b/plugins/aidd-ui/README.md index ee7c2f3b6..c2ce730eb 100644 --- a/plugins/aidd-ui/README.md +++ b/plugins/aidd-ui/README.md @@ -96,7 +96,9 @@ Each output stops at 20 problem lines and names the command that prints the rest - 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. +- `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. From 3597a2178d9381480e5aa22974496a2aebe4edbf Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Sat, 31 Oct 2026 16:39:55 +0100 Subject: [PATCH 26/26] fix(aidd-ui): read a test file by folder or by name with two anchored patterns One pattern mixing an unanchored folder branch with an anchored name branch read as a precedence mistake. A mock folder now has the test that proves it is skipped. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XeK3mDvAh1xZvZGAKEVhJn --- plugins/aidd-ui/hooks/lib/code.cjs | 5 +++-- scripts/__tests__/aidd-ui-code.test.js | 5 +++++ scripts/__tests__/aidd-ui-tokens-renders.test.js | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/plugins/aidd-ui/hooks/lib/code.cjs b/plugins/aidd-ui/hooks/lib/code.cjs index 33fd0bd51..1e2f33275 100644 --- a/plugins/aidd-ui/hooks/lib/code.cjs +++ b/plugins/aidd-ui/hooks/lib/code.cjs @@ -14,7 +14,8 @@ 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_FILE = /(?:^|\/)(?:__tests__|__mocks__)\/|\.(?:test|spec)\.[^/]+$/; +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"]; @@ -125,7 +126,7 @@ function coverageProblems(project) { const literalProblems = (pattern, extensions, applies = () => true) => (project) => applies(project) ? scanned(project, extensions) - .filter((file) => !TEST_FILE.test(file)) + .filter((file) => !TEST_FOLDER.test(file) && !TEST_NAME.test(file)) .flatMap((file) => findLiterals(searchable(project, file), pattern).map((found) => ({ file, ...found }))) : []; diff --git a/scripts/__tests__/aidd-ui-code.test.js b/scripts/__tests__/aidd-ui-code.test.js index 4d7e2ea9a..68a151565 100644 --- a/scripts/__tests__/aidd-ui-code.test.js +++ b/scripts/__tests__/aidd-ui-code.test.js @@ -139,6 +139,11 @@ describe("aidd-ui code link", () => { 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(), []); diff --git a/scripts/__tests__/aidd-ui-tokens-renders.test.js b/scripts/__tests__/aidd-ui-tokens-renders.test.js index e2337981e..7c3f732ae 100644 --- a/scripts/__tests__/aidd-ui-tokens-renders.test.js +++ b/scripts/__tests__/aidd-ui-tokens-renders.test.js @@ -3,7 +3,7 @@ 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, put } = require("./fixtures/aidd-ui/project.js"); +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.