Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <chemin>` 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`
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions projects/ui-kit-schematics/src/ng-add/files/main.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
13 changes: 7 additions & 6 deletions projects/ui-kit-schematics/src/ng-add/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
122 changes: 115 additions & 7 deletions scripts/tokens.build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`;

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 = [];
Expand Down
Loading
Loading