diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 1e2fffd..e411fba 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -78,6 +78,15 @@ jobs: - name: Unit tests run: pnpm test + # The token pipeline had no test at all, which is how an alias + # between two tokens of the same collection could be unresolvable since the + # beginning — the starter's own `semantics.json` only ever references + # `primitives`, so the case was never exercised. These specs run + # `tokens.build.mjs` on throwaway token sets, so they cover the real + # execution path without touching the generated files. + - name: Token pipeline tests + run: pnpm tokens:test + # `postinstall` has just regenerated everything `docs:config` produces, so # this step looks redundant. It is not — it enforces two things nothing # else does: diff --git a/CHANGELOG.md b/CHANGELOG.md index c1f156e..541ee87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -188,6 +188,49 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr ### Fixed +- **Un alias entre deux jetons d'une même collection cassait `tokens:build`** (FSHSP-203). + Le build s'arrêtait sur `Reference Errors: Some token references (N) could not be found`, + donc plus de SCSS généré, donc ni application ni Storybook. Chaque collection est bâtie + seule et ses jetons sont posés sous sa clé (`semantics`, pour la collection du même nom) : + une référence intra-collection devait donc s'écrire `{semantics.global.text.default}`. + Or un export Figma / Token Flow Manager ne met jamais le nom de la collection dans le + chemin d'une variable — il produit la forme nue `{global.text.default}`, que Style + Dictionary ne peut pas résoudre. Les références sont maintenant préfixées au chargement, + et la forme nue devient la forme normale. + - **Une racine déjà explicite n'est jamais touchée** : `{primitives.grey.500}` reste une + référence inter-collections. Et le préfixage ne s'applique que si la cible existe + réellement dans la collection — sans quoi un `{effects.default}` de `styles.json`, qui + vise une AUTRE collection et passe par `refToVar` et non par Style Dictionary, serait + préfixé de travers. Corollaire assumé : un groupe qui porte le nom d'une collection + n'est pas atteignable par une référence nue, la collection gagne. + - **L'indirection est conservée** : les blocs clair et sombre émettent tous deux + `var(--global-text-default)`, dont la cible change par mode. Un alias intra-collection + est donc juste par mode sans rien de plus. + - **Une référence cassée nomme maintenant son fichier et son jeton**, avant même que + Style Dictionary ne s'en mêle : `src/design-tokens/semantics.json → form.modeLight.content` + plutôt qu'un chemin résolu qui ne correspond à aucune ligne du fichier. + - **`scripts/tokens.build.mjs` passe en 🔒 verrouillé** chez le consommateur, comme + `src/styles/ui-kit/` : c'est du moteur, pas du contenu de projet. Rejouer + `ng add @4sh/ui-kit-schematics` le remplace donc, et c'est ce qui fait arriver ce + correctif — et les suivants — dans un projet déjà installé. `tokens.config.json` et les + JSON de jetons restent, eux, éditables et jamais écrasés. + - **Le pipeline de tokens a enfin des tests** (`pnpm tokens:test`, ajouté à la CI). Il n'en + avait aucun, ce qui explique qu'un trou pareil ait tenu depuis le début : le + `semantics.json` du starter ne référence que `primitives` (1295 fois) et ne contient pas + un seul alias intra-collection. Le script accepte pour cela un `--config ` qui + déplace sa racine, de sorte qu'une suite le lance sur un jeu de jetons jetable. + +- **`ui-tooltip` : `autoHide=false` ne gardait pas l'infobulle ouverte.** L'option posait bien + `pointer-events: auto` sur le panneau, mais `mouseleave` sur le déclencheur démontait + l'overlay immédiatement, `hideDelay` valant 0 par défaut. Le pointeur n'avait donc jamais le + temps de franchir l'écart de la flèche : le panneau disparaissait avant d'être atteint, et son + `mouseenter` ne tirait jamais. Un plancher est maintenant appliqué à `hideDelay` quand le + panneau est interactif, et un `hideDelay` plus grand continue de primer. + - Le focus qui entre dans le panneau ne le ferme plus : le `focusout` du déclencheur ignore + une cible située à l'intérieur, ce qui rend le contenu réellement cliquable à la souris. + - Les écouteurs du panneau et celui d'`Échap` étaient reposés à **chaque** affichage alors + qu'ils n'étaient libérés qu'à la destruction : ils sont désormais attachés une seule fois. + - `Échap` masque maintenant sans attendre `hideDelay`. - **Le scroll lock de fond est de nouveau un seul compteur pour tout le kit** (FSHSP-210). `lockBodyScroll` / `unlockBodyScroll` étaient recopiés à l'identique dans les cinq points d'entrée qui masquent le viewport (`ui-modal`, `ui-drawer`, `ui-bottom-sheet`, `ui-sidebar` diff --git a/package.json b/package.json index 6e7fa30..fe19896 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "postinstall": "pnpm tokens:build && pnpm ui-kit:build && pnpm docs:config", "ui-kit:build": "ng build ui-kit && pnpm ui-kit:styles", "tokens:build": "node scripts/tokens.build.mjs", + "tokens:test": "vitest run --root scripts", "build-info": "node scripts/build-info.mjs", "docs:config": "node scripts/docs.config.mjs && node scripts/component-vars.build.mjs", "docs:search": "node scripts/docs.search.mjs", diff --git a/projects/ui-kit-schematics/src/ng-add/files/main.scss b/projects/ui-kit-schematics/src/ng-add/files/main.scss index 1148ce4..da62624 100644 --- a/projects/ui-kit-schematics/src/ng-add/files/main.scss +++ b/projects/ui-kit-schematics/src/ng-add/files/main.scss @@ -48,6 +48,7 @@ @use "base/base"; @use "base/typography"; @use "base/motion"; +@use "base/ripple"; // 🔒 Classes utilitaires globales (.text-center, .no-margin…). @use "ui-kit/utils/helpers"; diff --git a/projects/ui-kit-schematics/src/ng-add/index.ts b/projects/ui-kit-schematics/src/ng-add/index.ts index e2e3ba3..8331e49 100644 --- a/projects/ui-kit-schematics/src/ng-add/index.ts +++ b/projects/ui-kit-schematics/src/ng-add/index.ts @@ -477,15 +477,16 @@ function copyTokensPipeline(): Rule { readFileSync(join(pipelineDir, 'tokens.config.json'), 'utf8'), ); } - if (!tree.exists('scripts/tokens.build.mjs')) { - tree.create( - 'scripts/tokens.build.mjs', - readFileSync(join(pipelineDir, 'tokens.build.mjs'), 'utf8'), - ); + + const buildScript = readFileSync(join(pipelineDir, 'tokens.build.mjs'), 'utf8'); + if (tree.exists('scripts/tokens.build.mjs')) { + tree.overwrite('scripts/tokens.build.mjs', buildScript); + } else { + tree.create('scripts/tokens.build.mjs', buildScript); } context.logger.info( - '✔ Chaîne de génération des tokens copiée (src/design-tokens/, tokens.config.json, scripts/tokens.build.mjs).', + '✔ Chaîne de génération des tokens copiée (src/design-tokens/, tokens.config.json, scripts/tokens.build.mjs 🔒).', ); return tree; }; diff --git a/scripts/tokens.build.mjs b/scripts/tokens.build.mjs index 281dfa3..43b4dd9 100644 --- a/scripts/tokens.build.mjs +++ b/scripts/tokens.build.mjs @@ -16,8 +16,16 @@ import { createPropertyFormatter, usesReferences, getReferences } from 'style-di // --- Config & paths --------------------------------------------------------- -const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); -const CONFIG = JSON.parse(readFileSync(join(ROOT, 'tokens.config.json'), 'utf8')); +const configArg = process.argv.indexOf('--config'); +const CONFIG_PATH = + configArg !== -1 && process.argv[configArg + 1] + ? isAbsolute(process.argv[configArg + 1]) + ? process.argv[configArg + 1] + : join(process.cwd(), process.argv[configArg + 1]) + : join(dirname(fileURLToPath(import.meta.url)), '..', 'tokens.config.json'); + +const ROOT = dirname(CONFIG_PATH); +const CONFIG = JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); const SRC = isAbsolute(CONFIG.sourceRoot) ? CONFIG.sourceRoot : join(ROOT, CONFIG.sourceRoot); const HEADER = `/* ${CONFIG.header ?? 'Generated — do not edit.'} */\n\n`; @@ -103,24 +111,77 @@ for (const c of CONFIG.collections) for (const k of refKeys(c)) byRefKey[k] = c; // --- Token file walking ------------------------------------------------------- -/** Flatten a DTCG tree into [{ path, token }] leaves ($value nodes). */ -function leaves(node, path = [], out = []) { +/** Flatten a DTCG tree into [{ file, path, token }] leaves ($value nodes). + * `file` travels with the leaf so a bad reference can name the file to open. */ +function leaves(node, file, path = [], out = []) { if (!node || typeof node !== 'object') return out; if ('$value' in node) { - out.push({ path, token: node }); + out.push({ file, path, token: node }); return out; } - for (const k of Object.keys(node)) if (!k.startsWith('$')) leaves(node[k], [...path, k], out); + for (const k of Object.keys(node)) + if (!k.startsWith('$')) leaves(node[k], file, [...path, k], out); return out; } const leavesByCol = {}; for (const c of CONFIG.collections) { leavesByCol[c.id] = (c.files ?? []).flatMap((f) => - leaves(JSON.parse(readFileSync(join(SRC, f), 'utf8'))), + leaves(JSON.parse(readFileSync(join(SRC, f), 'utf8')), f), ); } +// --- Intra-collection references ---------------------------------------------- +// A reference carries the path of a VARIABLE, and the collection name is not part +// of it: that is what Figma / Token Flow Manager export. Style Dictionary resolves +// against the whole dictionary, where each collection sits under its ref key, so +// an alias between two tokens of the SAME collection comes out bare +// (`{global.text.default}`) and cannot resolve — while the prefixed form +// (`{semantics.global.text.default}`) resolves, and keeps its `var(…)` indirection, +// so it stays correct per mode. We add the prefix here rather than asking +// designers to write one their tool does not produce (FSHSP-203). + +/** Every `{…}` reference of a value, whatever its shape (string, array, composite). */ +const REF_RE = /\{([^{}]+)\}/g; + +function mapRefs(value, fn) { + if (typeof value === 'string') return value.replace(REF_RE, (_, ref) => `{${fn(ref)}}`); + if (Array.isArray(value)) return value.map((v) => mapRefs(v, fn)); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, mapRefs(v, fn)])); + } + return value; +} + +/** Paths a collection carries, mode segments removed — i.e. what a reference aims at. */ +function collectionPaths(col) { + const strip = modeSegments(col); + const paths = new Set(); + for (const l of leavesByCol[col.id]) paths.add(l.path.filter((p) => !strip.has(p)).join('.')); + return paths; +} + +for (const col of CONFIG.collections) { + const primary = refKeys(col)[0]; + const own = collectionPaths(col); + for (const leaf of leavesByCol[col.id]) { + let touched = false; + const value = mapRefs(leaf.token.$value, (ref) => { + // Explicit root: a cross-collection reference, left exactly as written. + if (byRefKey[ref.split('.')[0]]) return ref; + // Otherwise, and ONLY when the target really lives here. Without that + // condition a composite collection's `{effects.default}` — which points at + // another collection and is resolved by `refToVar`, not by Style Dictionary — + // would get the wrong prefix, and a genuinely broken reference would lose the + // name its author wrote, which is the one worth showing them. + if (!own.has(ref)) return ref; + touched = true; + return `${primary}.${ref}`; + }); + if (touched) leaf.token = { ...leaf.token, $value: value }; + } +} + /** Detect all mode names from the JSON structure for a given axis. */ function detectModeNames(col, axis) { const modes = new Set(); @@ -472,6 +533,53 @@ const compositeCols = new Set( ), ); +// --- Reference check --------------------------------------------------------- +// Style Dictionary reports a broken reference by its resolved path +// (`{semantics.form.high.content.default} tries to reference …`) — which names +// neither the file to open nor the path as written in it. We check first, so the +// message points at the line a designer can actually fix. +// +// Composite collections are skipped: they never reach Style Dictionary (see +// `refToVar`), and their references legitimately aim at another collection +// without naming it. + +/** Value at a dotted path in a Style Dictionary tree, or undefined. */ +function lookup(tree, path) { + let node = tree; + for (const seg of path.split('.')) { + if (!node || typeof node !== 'object') return undefined; + node = node[seg]; + } + return node; +} + +function checkReferences() { + const broken = []; + for (const col of CONFIG.collections) { + if (compositeCols.has(col.id)) continue; + const { tokens } = buildTokens(col); + for (const leaf of leavesByCol[col.id]) { + mapRefs(leaf.token.$value, (ref) => { + if (lookup(tokens, ref) === undefined) { + broken.push({ file: leaf.file, path: leaf.path.join('.'), ref }); + } + return ref; + }); + } + } + if (!broken.length) return; + const lines = broken.map((b) => ` ${b.file} → ${b.path}\n {${b.ref}} introuvable`); + throw new Error( + `Références de jetons non résolues (${broken.length}) :\n${lines.join('\n')}\n\n` + + `Un alias vers un jeton de la MÊME collection est préfixé automatiquement : ` + + `si la cible existe, c'est son orthographe ou son chemin qui est en cause. ` + + `Vers une AUTRE collection, la référence doit nommer sa collection ` + + `(ex. {primitives.grey.500}).`, + ); +} + +checkReferences(); + // --- Build ----------------------------------------------------------------------- const written = []; diff --git a/scripts/tokens.build.spec.mjs b/scripts/tokens.build.spec.mjs new file mode 100644 index 0000000..83a809c --- /dev/null +++ b/scripts/tokens.build.spec.mjs @@ -0,0 +1,236 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; + +const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'tokens.build.mjs'); + +/** + * Le pipeline est piloté par `tokens.config.json` et écrit à côté : chaque cas + * s'exécute dans son propre dossier jetable, via `--config`, donc rien ne touche + * aux fichiers générés du dépôt. + */ +const dirs = []; +afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +/** Deux collections : une préfixée (`primitives`), une au préfixe vide (`semantics`, + * avec un axe de modes) — la configuration du starter, en miniature. */ +function config(extraCollections = []) { + return { + sourceRoot: 'design-tokens', + header: 'test', + collections: [ + { + id: 'primitives', + prefix: 'primitives', + preserveCase: false, + files: ['primitives.json'], + modeAxes: [], + }, + ...extraCollections, + { + id: 'semantics', + prefix: '', + preserveCase: false, + files: ['semantics.json'], + modeAxes: [ + { + name: 'theme', + source: 'nested', + strategy: 'selectors', + default: 'modeLight', + map: { modeLight: ':root', modeDark: "[data-theme='dark']" }, + }, + ], + }, + ], + outputs: [ + { + id: 'css', + format: 'css-vars', + destination: 'out', + collections: ['primitives', ...extraCollections.map((c) => c.id), 'semantics'], + }, + ], + }; +} + +const PRIMITIVES = { + grey: { + 900: { $value: '#111111', $type: 'color' }, + 50: { $value: '#fafafa', $type: 'color' }, + }, +}; + +/** `global.text.default` existe dans les deux modes : c'est la cible des alias. */ +function semantics(extra = {}) { + return { + global: { + modeLight: { text: { default: { $value: '{primitives.grey.900}', $type: 'color' } } }, + modeDark: { text: { default: { $value: '{primitives.grey.50}', $type: 'color' } } }, + }, + ...extra, + }; +} + +function run({ tokens, extraCollections = [] }) { + const dir = mkdtempSync(join(tmpdir(), 'tokens-build-')); + dirs.push(dir); + mkdirSync(join(dir, 'design-tokens'), { recursive: true }); + writeFileSync(join(dir, 'tokens.config.json'), JSON.stringify(config(extraCollections), null, 2)); + for (const [name, content] of Object.entries(tokens)) { + writeFileSync(join(dir, 'design-tokens', name), JSON.stringify(content, null, 2)); + } + try { + execFileSync(process.execPath, [SCRIPT, '--config', join(dir, 'tokens.config.json')], { + encoding: 'utf8', + stdio: 'pipe', + }); + } catch (error) { + return { ok: false, message: `${error.stdout ?? ''}${error.stderr ?? ''}`, dir }; + } + return { + ok: true, + dir, + semantics: readFileSync(join(dir, 'out', '_tokens-semantics.scss'), 'utf8'), + }; +} + +describe('tokens.build — résolution des références', () => { + it('résout un alias intra-collection écrit SANS le nom de sa collection', () => { + // La forme qu'exporte Figma / Token Flow Manager : le nom de la collection + // ne fait pas partie du chemin d'une variable (FSHSP-203). + const result = run({ + tokens: { + 'primitives.json': PRIMITIVES, + 'semantics.json': semantics({ + form: { + modeLight: { content: { $value: '{global.text.default}', $type: 'color' } }, + modeDark: { content: { $value: '{global.text.default}', $type: 'color' } }, + }, + }), + }, + }); + + expect(result.message ?? '').toBe(''); + expect(result.ok).toBe(true); + // L'indirection est conservée, pas aplatie : c'est elle qui rend l'alias + // juste par mode, la cible ne valant pas la même chose en clair et en sombre. + expect(result.semantics).toContain('--form-content: var(--global-text-default)'); + }); + + it('donne le même résultat quand la référence porte déjà sa collection', () => { + const bare = run({ + tokens: { + 'primitives.json': PRIMITIVES, + 'semantics.json': semantics({ + form: { + modeLight: { content: { $value: '{global.text.default}', $type: 'color' } }, + modeDark: { content: { $value: '{global.text.default}', $type: 'color' } }, + }, + }), + }, + }); + const prefixed = run({ + tokens: { + 'primitives.json': PRIMITIVES, + 'semantics.json': semantics({ + form: { + modeLight: { content: { $value: '{semantics.global.text.default}', $type: 'color' } }, + modeDark: { content: { $value: '{semantics.global.text.default}', $type: 'color' } }, + }, + }), + }, + }); + + expect(prefixed.ok).toBe(true); + expect(prefixed.semantics).toBe(bare.semantics); + }); + + it('laisse intacte une référence vers une autre collection', () => { + const result = run({ + tokens: { 'primitives.json': PRIMITIVES, 'semantics.json': semantics() }, + }); + + expect(result.ok).toBe(true); + expect(result.semantics).toContain('--global-text-default: var(--primitives-grey-900)'); + expect(result.semantics).toContain('--global-text-default: var(--primitives-grey-50)'); + }); + + it("suit l'alias jusqu'à la bonne valeur dans CHAQUE mode", () => { + const result = run({ + tokens: { + 'primitives.json': PRIMITIVES, + 'semantics.json': semantics({ + form: { + modeLight: { content: { $value: '{global.text.default}', $type: 'color' } }, + modeDark: { content: { $value: '{global.text.default}', $type: 'color' } }, + }, + }), + }, + }); + + const light = result.semantics.slice( + result.semantics.indexOf(':root'), + result.semantics.indexOf('[data-theme='), + ); + const dark = result.semantics.slice(result.semantics.indexOf('[data-theme=')); + expect(light).toContain('--global-text-default: var(--primitives-grey-900)'); + expect(dark).toContain('--global-text-default: var(--primitives-grey-50)'); + // Le même `var(…)` dans les deux blocs : c'est la cible qui change, pas l'alias. + expect(light).toContain('--form-content: var(--global-text-default)'); + expect(dark).toContain('--form-content: var(--global-text-default)'); + }); + + it('donne la priorité à la collection quand un groupe porte le même nom', () => { + // `metrics` est à la fois une collection ET un groupe de `semantics`. La + // référence nue `{metrics.sm}` vise la COLLECTION : le préfixage automatique + // ne s'applique jamais à une racine qui est déjà une clé de collection. + const metrics = { + id: 'metrics', + prefix: 'metrics', + preserveCase: false, + files: ['metrics.json'], + modeAxes: [], + }; + const result = run({ + extraCollections: [metrics], + tokens: { + 'primitives.json': PRIMITIVES, + 'metrics.json': { sm: { $value: '8px', $type: 'dimension' } }, + 'semantics.json': semantics({ + metrics: { modeLight: { local: { $value: '4px', $type: 'dimension' } } }, + gap: { + modeLight: { field: { $value: '{metrics.sm}', $type: 'dimension' } }, + }, + }), + }, + }); + + expect(result.message ?? '').toBe(''); + expect(result.ok).toBe(true); + expect(result.semantics).toContain('--gap-field: var(--metrics-sm)'); + }); + + it('échoue sur une référence réellement cassée, en nommant le fichier et le jeton', () => { + const result = run({ + tokens: { + 'primitives.json': PRIMITIVES, + 'semantics.json': semantics({ + form: { + modeLight: { content: { $value: '{global.text.nope}', $type: 'color' } }, + }, + }), + }, + }); + + expect(result.ok).toBe(false); + expect(result.message).toContain('semantics.json'); + expect(result.message).toContain('form.modeLight.content'); + expect(result.message).toContain('{global.text.nope}'); + }); +}); diff --git a/scripts/vitest.config.mjs b/scripts/vitest.config.mjs new file mode 100644 index 0000000..065b4d4 --- /dev/null +++ b/scripts/vitest.config.mjs @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; + +// Les scripts d'outillage sont du Node pur (`.mjs`, aucun builder Angular) : +// Vitest tourne dessus directement. Les specs lancent le script en +// sous-processus sur un jeu de jetons jetable plutôt que d'importer son module — +// il a des effets de bord au chargement (il lit la config et écrit sur disque), +// et c'est de toute façon le vrai chemin d'exécution qu'on veut couvrir. +export default defineConfig({ + test: { + environment: 'node', + include: ['**/*.spec.mjs'], + passWithNoTests: false, + }, +});