@atelier-ui/create-workspace 0.2.41 → 0.2.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +33 -4
  2. package/package.json +1 -1
  3. package/src/generators/preset/files/contracts/README.md +29 -0
  4. package/src/generators/preset/files/contracts/button.contract.ts.template +20 -0
  5. package/src/generators/preset/files/contracts/types.ts.template +55 -0
  6. package/src/generators/preset/files/figma/snapshot.json +164 -0
  7. package/src/generators/preset/files/storybook/angular/atl-button.stories.ts.template +38 -0
  8. package/src/generators/preset/files/storybook/angular/main.ts.template +39 -0
  9. package/src/generators/preset/files/storybook/angular/preview.ts.template +30 -0
  10. package/src/generators/preset/files/storybook/angular/tsconfig.json +16 -0
  11. package/src/generators/preset/files/storybook/angular/vitest.config.ts.template +40 -0
  12. package/src/generators/preset/files/storybook/angular/vitest.setup.ts.template +14 -0
  13. package/src/generators/preset/files/storybook/react/atl-button.stories.tsx +31 -0
  14. package/src/generators/preset/files/storybook/react/main.ts.template +37 -0
  15. package/src/generators/preset/files/storybook/react/preview.tsx +29 -0
  16. package/src/generators/preset/files/storybook/react/vitest.config.ts.template +32 -0
  17. package/src/generators/preset/files/storybook/react/vitest.setup.ts.template +8 -0
  18. package/src/generators/preset/files/storybook/vue/atl-button.stories.ts.template +34 -0
  19. package/src/generators/preset/files/storybook/vue/main.ts.template +38 -0
  20. package/src/generators/preset/files/storybook/vue/preview.ts.template +29 -0
  21. package/src/generators/preset/files/storybook/vue/vitest.config.ts.template +32 -0
  22. package/src/generators/preset/files/storybook/vue/vitest.setup.ts.template +9 -0
  23. package/src/generators/preset/files/styles/tokens.css +59 -43
  24. package/src/generators/preset/files/tools/scripts/check-contracts.mjs +1644 -0
  25. package/src/generators/preset/files/tools/scripts/figma-snapshot-contracts.mjs +370 -0
  26. package/src/generators/preset/files/tools/scripts/lib/docgen.mjs +573 -0
  27. package/src/generators/preset/files/tools/scripts/lib/ts-eval.js +126 -0
  28. package/src/generators/preset/files/tools/scripts/preflight.mjs +220 -30
  29. package/src/generators/preset/files/tools/stylelint-rules/index.js +21 -0
  30. package/src/generators/preset/files/tools/stylelint-rules/no-primitive-token.js +362 -0
  31. package/src/generators/preset/files/tools/stylelint-rules/no-raw-color-literal.js +154 -0
  32. package/src/generators/preset/files/tools/stylelint-rules/no-token-bypass.js +497 -0
  33. package/src/generators/preset/files/tools/stylelint-rules/no-undeclared-token.js +122 -0
  34. package/src/generators/preset/files/tools/stylelint-rules/utils.js +71 -0
  35. package/src/generators/preset/preset.d.ts +1 -0
  36. package/src/generators/preset/preset.js +955 -5
  37. package/src/generators/preset/preset.js.map +1 -1
  38. package/src/generators/preset/schema.d.ts +1 -0
  39. package/src/generators/preset/schema.json +5 -0
@@ -0,0 +1,497 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * atelier/no-token-bypass
5
+ *
6
+ * Catches a literal whose value a token already holds — the one kind of
7
+ * unbound value that is objectively wrong. This rule deliberately does NOT
8
+ * demand that every value be a token: of the ~190 literals in the component
9
+ * stylesheets, most are component dimensions (an avatar size, a toggle
10
+ * track, a step circle) with exactly one user, and tokenising them would
11
+ * produce a hundred single-use tokens — the rule of three violated in token
12
+ * form. What IS wrong is a literal that duplicates a token in the FAMILY its
13
+ * own property should draw from — two real, invisible defects were found
14
+ * this way (AtlTab and AtlCodeBlock's header both hardcoding `2.5rem` where
15
+ * `--ui-control-height-md` already said 2.5rem, ADR-0047).
16
+ *
17
+ * Family per property (z-index → --ui-z-*, opacity → --ui-opacity-*,
18
+ * font-size/line-height/font-weight/letter-spacing → their own --ui-* family,
19
+ * border-radius → --ui-radius-*, box-shadow → --ui-shadow-*, transition/
20
+ * animation-duration → --ui-duration-*, padding/margin/gap → --ui-spacing-*,
21
+ * min-height/height/width/min-width → --ui-control-height-*), so the rule
22
+ * never suggests a spacing token for a width. Border widths get their own
23
+ * check: any literal length on a `border*` property is a bypass, because
24
+ * `--ui-border-width`/`--ui-border-width-thick` are the only two values that
25
+ * family holds and neither aliases a "normal" CSS default the way `0` does.
26
+ *
27
+ * `TOKEN_BYPASS_EXEMPT` (kept in tools/scripts/lib/allowlists.js — see
28
+ * no-primitive-token.js's header for why these exemption maps were not
29
+ * relocated out of that file) carries the same two kinds as every other
30
+ * allowlist there (ADR-0034): `kind: 'design'` — the shared value is a
31
+ * coincidence, binding it would be wrong — stays silent; `kind: 'gap'` — it
32
+ * should bind and hasn't yet — warns on every run via a per-report
33
+ * `severity: 'warning'` override.
34
+ *
35
+ * Staleness is checked the same way no-primitive-token.js checks it: once
36
+ * per `componentRoot` per process, against a direct filesystem scan of THAT
37
+ * root's own `**\/*.css` tree, not any other project's — see that file's
38
+ * header for the full reasoning and the one known gap (a root-asymmetric
39
+ * exemption would be falsely reported stale by the root(s) that don't
40
+ * reference it; none of today's four entries are asymmetric, verified
41
+ * 2026-09-12).
42
+ *
43
+ * One thing this port improves rather than copies: the retired script
44
+ * hand-rolled a `(^|[;{])\s*([a-z-]+)\s*:\s*([^;{}]+)` regex over
45
+ * comment-stripped CSS text to recover `prop: value` pairs. A stylelint rule
46
+ * gets that for free, correctly, from `root.walkDecls` — no hand-rolled
47
+ * parsing needed for the per-file violation check. The pre-scan used only
48
+ * for staleness still uses the same regex, because at that point there is no
49
+ * PostCSS AST — it exists purely to answer "is this literal referenced
50
+ * anywhere", not to attribute a violation to a location.
51
+ *
52
+ * Scope is deliberately component CSS only — every framework's own
53
+ * `src/lib` component stylesheets — the retired script never touched the
54
+ * docs app, and neither does this rule.
55
+ *
56
+ * THREE secondary options, all repo-relative POSIX paths, none guessed —
57
+ * the config declares topology the way `no-undeclared-token`'s `tokenFiles`
58
+ * already does:
59
+ * - `tokenFile` (required in practice): the single canonical token source
60
+ * to read DECLARED VALUES from (`tokenValue` below) — this repo passes
61
+ * the same `libs/create-workspace/.../tokens.css` `no-undeclared-token`
62
+ * uses. Omitted, `tokenValue` is `{}` and the family-match half of this
63
+ * rule silently finds nothing to match (the border-width half is
64
+ * unaffected — it never reads token values).
65
+ * - `componentRoot` (required for staleness): the one directory this
66
+ * invocation's `libs/<fw>/src/lib` (or a scaffold's own tree) lives at.
67
+ * Replaces a `frameworkOf()` regex that used to pattern-match the input
68
+ * file's path against `libs/(angular|react|vue)/src/lib/` to guess which
69
+ * of three hardcoded trees to scan — the config already knows which
70
+ * tree each Nx target lints (one target per project), so asking it to
71
+ * say so is not a bigger ask than `tokenFiles` already is. Omitted, the
72
+ * staleness scan (which needs a directory to walk) does not run; the
73
+ * per-declaration bypass/border check is unaffected — it never depended
74
+ * on knowing the framework, only on `dir` (a plain basename).
75
+ * - `allowlistsFile` (optional): repo-relative path to a CommonJS module
76
+ * exporting `TOKEN_BYPASS_EXEMPT`. This repo passes
77
+ * `tools/scripts/lib/allowlists.js`, unchanged. **Absent — the
78
+ * documented default for a scaffold, which starts with ZERO
79
+ * exemptions — `TOKEN_BYPASS_EXEMPT` is `{}`.** An empty exemption
80
+ * object also means the staleness scan is skipped outright, not just
81
+ * "runs and finds nothing": scanning a tree to police an empty map is
82
+ * work with no finding it could ever produce, so the scan (and the
83
+ * per-root cache it would populate) is short-circuited before it
84
+ * touches the filesystem. Loaded lazily, per file, from inside the rule
85
+ * closure (cached by resolved absolute path) rather than at module
86
+ * `require()` time — the old code's top-level `require('../scripts/lib/
87
+ * allowlists')` ran the instant `index.js` loaded this file into the
88
+ * `plugins` array, which happens whenever ANY rule in the plugin is
89
+ * used, whether or not `no-token-bypass` itself is turned on for that
90
+ * project. A scaffold has no such file at all, so that top-level
91
+ * `require()` would throw `MODULE_NOT_FOUND` merely from loading the
92
+ * plugin — before any config decision about whether to enable this rule
93
+ * even applies.
94
+ *
95
+ * **The empty default applies only when `allowlistsFile` is absent.**
96
+ * When it IS supplied, the loaded module's exports are validated:
97
+ * `TOKEN_BYPASS_EXEMPT` must be a plain object. A module that fails to
98
+ * load resolution still throws `MODULE_NOT_FOUND` as before (unchanged,
99
+ * already loud); a module that DOES load but doesn't actually export
100
+ * that name — a typo, a rename on one side only — used to be silently
101
+ * treated as the same "zero exemptions" default, which is exactly
102
+ * ADR-0124's failure class: the staleness scan skips outright (nothing
103
+ * to report), unconditionally, regardless of whether any exempted
104
+ * literal elsewhere in the tree also loses its exemption and starts
105
+ * reporting on its own — the rule never signals that its configured
106
+ * allowlist never actually loaded, only that SOME check silently didn't
107
+ * run (2026-09-12 stylelint review, claim 2, reproduced with a fixture
108
+ * that has no exempted literal in scope at all: a genuinely stale entry
109
+ * went from a blocking `[STALE-EXEMPT]` to total silence, exit 0, the
110
+ * moment the export name was misspelled). Now a mis-shaped supplied
111
+ * module reports `[INVALID-ALLOWLISTS]` at `error` severity on every
112
+ * file this override touches, via `stylelint.utils.report()` rather
113
+ * than a thrown exception — see `no-primitive-token.js`'s header for
114
+ * the full reasoning (verified there that a thrown error aborts the
115
+ * entire stylelint run rather than reporting per file); this rule makes
116
+ * the same call for the same reason.
117
+ *
118
+ * Formerly tools/scripts/check-token-bypass.js (check:token-bypass).
119
+ */
120
+
121
+ const fs = require('fs');
122
+ const path = require('path');
123
+ const stylelint = require('stylelint');
124
+ const {
125
+ REPO_ROOT,
126
+ toRepoRelative,
127
+ isNonEmptyString,
128
+ normalizeRepoRelative,
129
+ } = require('./utils');
130
+
131
+ const ruleName = 'atelier/no-token-bypass';
132
+
133
+ const messages = stylelint.utils.ruleMessages(ruleName, {
134
+ bypass: (prop, value, holders) =>
135
+ `[BYPASS] sets ${prop}: ${value}, which is exactly what ${holders
136
+ .map((h) => `var(${h})`)
137
+ .join(
138
+ ' / ',
139
+ )} holds. Bind it, or exempt it in TOKEN_BYPASS_EXEMPT with a reason.`,
140
+ border: (prop, value) =>
141
+ `[BORDER] sets ${prop}: ${value} with a literal width. Use var(--ui-border-width) ` +
142
+ 'or var(--ui-border-width-thick); if the value is a graphic device rather than a border ' +
143
+ 'weight, exempt it in TOKEN_BYPASS_EXEMPT with a reason.',
144
+ stale: (key) =>
145
+ `[STALE-EXEMPT] TOKEN_BYPASS_EXEMPT lists '${key}', but no stylesheet under the configured componentRoot has that literal any more. Remove the entry.`,
146
+ invalidAllowlists: (allowlistsFile, reason) =>
147
+ `[INVALID-ALLOWLISTS] '${allowlistsFile}' ${reason}. A supplied allowlistsFile must actually export it — a missing or misspelled export is a broken configuration, not the documented empty default (which only applies when allowlistsFile is omitted entirely).`,
148
+ });
149
+
150
+ const meta = {
151
+ url: 'tools/stylelint-rules/no-token-bypass.js',
152
+ };
153
+
154
+ /** Which token family a property may draw from. Copied verbatim from the retired script. */
155
+ const FAMILY = {
156
+ 'z-index': /^--ui-z-/,
157
+ opacity: /^--ui-opacity-/,
158
+ 'letter-spacing': /^--ui-letter-spacing-/,
159
+ 'font-weight': /^--ui-font-weight-/,
160
+ 'font-size': /^--ui-font-size-/,
161
+ 'line-height': /^--ui-line-height-/,
162
+ 'border-radius': /^--ui-radius-/,
163
+ 'box-shadow': /^--ui-shadow-/,
164
+ 'transition-duration': /^--ui-duration-/,
165
+ 'animation-duration': /^--ui-duration-/,
166
+ padding: /^--ui-spacing-/,
167
+ 'padding-top': /^--ui-spacing-/,
168
+ 'padding-right': /^--ui-spacing-/,
169
+ 'padding-bottom': /^--ui-spacing-/,
170
+ 'padding-left': /^--ui-spacing-/,
171
+ margin: /^--ui-spacing-/,
172
+ 'margin-top': /^--ui-spacing-/,
173
+ 'margin-right': /^--ui-spacing-/,
174
+ 'margin-bottom': /^--ui-spacing-/,
175
+ 'margin-left': /^--ui-spacing-/,
176
+ gap: /^--ui-spacing-/,
177
+ 'row-gap': /^--ui-spacing-/,
178
+ 'column-gap': /^--ui-spacing-/,
179
+ 'min-height': /^--ui-control-height-/,
180
+ height: /^--ui-control-height-/,
181
+ width: /^--ui-control-height-/,
182
+ 'min-width': /^--ui-control-height-/,
183
+ };
184
+
185
+ /** Border widths get their own rule: any literal length is a bypass. */
186
+ const BORDER_PROP = /^border(-top|-right|-bottom|-left)?(-width)?$/;
187
+
188
+ /** Values that mean "nothing", not "a measurement". */
189
+ const STRUCTURAL = new Set([
190
+ '0',
191
+ 'none',
192
+ 'auto',
193
+ 'inherit',
194
+ 'initial',
195
+ 'unset',
196
+ 'currentColor',
197
+ 'transparent',
198
+ ]);
199
+
200
+ /** Token → value, read once per resolved `tokenFile` path from the light
201
+ * (`:root`) block of the canonical token source and cached by absolute
202
+ * path — the same single file for every framework, unlike
203
+ * no-undeclared-token's `tokenFiles`, because token-bypass never scopes to
204
+ * docs. Already a declared nx.json `stylelint` input since stage 1. */
205
+ function readTokenValues(absPath) {
206
+ const tokensCss = fs
207
+ .readFileSync(absPath, 'utf-8')
208
+ .replace(/\/\*[\s\S]*?\*\//g, '');
209
+ const darkAt = tokensCss.indexOf('@media (prefers-color-scheme: dark)');
210
+ const lightBlock = darkAt === -1 ? tokensCss : tokensCss.slice(0, darkAt);
211
+ const tokenValue = {};
212
+ for (const m of lightBlock.matchAll(/(--ui-[a-z0-9-]+)\s*:\s*([^;]+);/g)) {
213
+ const value = m[2].trim();
214
+ if (value.startsWith('var(')) continue; // an alias, not a value of its own
215
+ tokenValue[m[1]] = value;
216
+ }
217
+ return tokenValue;
218
+ }
219
+
220
+ const tokenValueCache = new Map(); // absolute tokenFile path -> tokenValue map
221
+
222
+ /** `{}` when `tokenFile` is absent — the family-match check then never
223
+ * matches anything (nothing to compare against); the border-width check is
224
+ * unaffected, since it never reads token values. */
225
+ function getTokenValues(tokenFile) {
226
+ if (!tokenFile) return {};
227
+ const absPath = path.resolve(REPO_ROOT, tokenFile);
228
+ if (!tokenValueCache.has(absPath)) {
229
+ tokenValueCache.set(absPath, readTokenValues(absPath));
230
+ }
231
+ return tokenValueCache.get(absPath);
232
+ }
233
+
234
+ /** Human-readable description of what `require()` actually returned, for the
235
+ * `[INVALID-ALLOWLISTS]` message. */
236
+ function describeExport(value) {
237
+ if (value === undefined) return 'is undefined (no such export)';
238
+ if (Array.isArray(value)) return 'is an array';
239
+ if (value === null) return 'is null';
240
+ return `is a ${typeof value}`;
241
+ }
242
+
243
+ const allowlistsCache = new Map(); // absolute allowlistsFile path -> result below
244
+
245
+ /**
246
+ * `{ module: null, invalidReason: null }` when `allowlistsFile` is absent —
247
+ * the documented default for a scaffold, which starts with ZERO exemptions
248
+ * (see header).
249
+ *
250
+ * When `allowlistsFile` IS supplied, the loaded module's shape is validated:
251
+ * `TOKEN_BYPASS_EXEMPT` must be a plain object (not an array, not `null`,
252
+ * not missing). Missing or wrongly-shaped yields `{ module: null,
253
+ * invalidReason: <string> }` instead of silently falling back to the same
254
+ * empty default the absent-option case uses — see header for why (claim 2).
255
+ * A module that itself fails to `require()` (a typo'd PATH, not a typo'd
256
+ * EXPORT) still throws `MODULE_NOT_FOUND` here, unchanged and already loud.
257
+ */
258
+ function getAllowlists(allowlistsFile) {
259
+ if (!allowlistsFile) return { module: null, invalidReason: null };
260
+ const absPath = path.resolve(REPO_ROOT, allowlistsFile);
261
+ if (!allowlistsCache.has(absPath)) {
262
+ const required = require(absPath);
263
+ const value = required.TOKEN_BYPASS_EXEMPT;
264
+ const isValidShape =
265
+ value !== null && typeof value === 'object' && !Array.isArray(value);
266
+ allowlistsCache.set(
267
+ absPath,
268
+ isValidShape
269
+ ? { module: required, invalidReason: null }
270
+ : {
271
+ module: null,
272
+ invalidReason: `'TOKEN_BYPASS_EXEMPT' ${describeExport(value)}, expected an object`,
273
+ },
274
+ );
275
+ }
276
+ return allowlistsCache.get(absPath);
277
+ }
278
+
279
+ /** Which declared tokens in `family` hold exactly `value`. */
280
+ function tokensHolding(value, family, tokenValue) {
281
+ return Object.keys(tokenValue).filter(
282
+ (name) => family.test(name) && tokenValue[name] === value,
283
+ );
284
+ }
285
+
286
+ /**
287
+ * Every `TOKEN_BYPASS_EXEMPT` key (`<dir>:<prop>:<value>`) actually
288
+ * referenced anywhere under `componentRoot`'s own `**\/*.css` — a direct
289
+ * filesystem scan over raw text (no PostCSS AST available here), independent
290
+ * of which files stylelint hands this rule during this run.
291
+ */
292
+ function scanComponentRootForSeenKeys(absBase, tokenValue) {
293
+ const seen = new Set();
294
+ if (!fs.existsSync(absBase)) return seen;
295
+ for (const dir of fs.readdirSync(absBase)) {
296
+ const dirPath = path.join(absBase, dir);
297
+ if (!fs.statSync(dirPath).isDirectory()) continue;
298
+ for (const file of fs
299
+ .readdirSync(dirPath)
300
+ .filter((f) => f.endsWith('.css'))) {
301
+ const css = fs
302
+ .readFileSync(path.join(dirPath, file), 'utf-8')
303
+ .replace(/\/\*[\s\S]*?\*\//g, '');
304
+ for (const m of css.matchAll(/(^|[;{])\s*([a-z-]+)\s*:\s*([^;{}]+)/g)) {
305
+ const prop = m[2];
306
+ const value = m[3].trim().replace(/\s+/g, ' ');
307
+ if (value.includes('var(--ui-')) continue;
308
+ if (STRUCTURAL.has(value)) continue;
309
+
310
+ if (BORDER_PROP.test(prop)) {
311
+ const width = value.match(/^([\d.]+)(px|rem|em)\b/);
312
+ if (width) seen.add(`${dir}:${prop}:${width[0]}`);
313
+ continue;
314
+ }
315
+ const family = FAMILY[prop];
316
+ if (!family) continue;
317
+ if (tokensHolding(value, family, tokenValue).length > 0) {
318
+ seen.add(`${dir}:${prop}:${value}`);
319
+ }
320
+ }
321
+ }
322
+ }
323
+ return seen;
324
+ }
325
+
326
+ // Keyed on (componentRoot, tokenFile, allowlistsFile), not on componentRoot
327
+ // alone — the scan result depends on `tokenValue` (which comes from
328
+ // `tokenFile`), and whether the once-per-root stale-report loop below even
329
+ // RUNS depends on `TOKEN_BYPASS_EXEMPT` (which comes from `allowlistsFile`).
330
+ // Two overrides sharing a `componentRoot` but differing in either used to
331
+ // serve the first one's scan (and its `staleReportedForRoot` flag) to the
332
+ // second, silently suppressing the second's own staleness check — see
333
+ // `no-primitive-token.js` for the full reasoning and its own reproduction
334
+ // of the same shape of bug (2026-09-12 stylelint review, claim 3).
335
+ function cacheKey(componentRoot, tokenFile, allowlistsFile) {
336
+ // JSON-encode the tuple rather than joining with a separator character:
337
+ // that's unambiguous no matter what any of the three strings contain,
338
+ // unlike a literal join (space, NUL, or any other single character) which
339
+ // two different (componentRoot, tokenFile, allowlistsFile) triples could
340
+ // in principle both produce.
341
+ return JSON.stringify([componentRoot, tokenFile || '', allowlistsFile || '']);
342
+ }
343
+ const seenByRoot = new Map();
344
+ const staleReportedForRoot = new Set();
345
+
346
+ /** @type {import('stylelint').Rule} */
347
+ const rule = (primary, secondaryOptions) => {
348
+ return (root, result) => {
349
+ const validOptions = stylelint.utils.validateOptions(
350
+ result,
351
+ ruleName,
352
+ { actual: primary, possible: [true] },
353
+ {
354
+ actual: secondaryOptions,
355
+ possible: {
356
+ tokenFile: [isNonEmptyString],
357
+ componentRoot: [isNonEmptyString],
358
+ allowlistsFile: [isNonEmptyString],
359
+ },
360
+ },
361
+ );
362
+ if (!validOptions) return;
363
+
364
+ // Normalized BEFORE the `inScope` compare below and before it's used in
365
+ // any cache key — see `no-primitive-token.js` / `normalizeRepoRelative`'s
366
+ // header for why a raw `componentRoot: './libs/react/src/lib'`, a
367
+ // trailing slash, or an absolute path would otherwise disagree with
368
+ // `toRepoRelative(inputFile)` and silently disable every staleness check
369
+ // for this override (claim 4).
370
+ const rawComponentRoot = secondaryOptions && secondaryOptions.componentRoot;
371
+ const componentRoot = rawComponentRoot
372
+ ? normalizeRepoRelative(rawComponentRoot)
373
+ : undefined;
374
+ const tokenFile = secondaryOptions && secondaryOptions.tokenFile;
375
+ const tokenValue = getTokenValues(tokenFile);
376
+ const allowlistsFile = secondaryOptions && secondaryOptions.allowlistsFile;
377
+ const { module: allowlists, invalidReason } = getAllowlists(allowlistsFile);
378
+ if (invalidReason) {
379
+ stylelint.utils.report({
380
+ message: messages.invalidAllowlists(allowlistsFile, invalidReason),
381
+ node: root,
382
+ result,
383
+ ruleName,
384
+ });
385
+ }
386
+ const TOKEN_BYPASS_EXEMPT =
387
+ (allowlists && allowlists.TOKEN_BYPASS_EXEMPT) || {};
388
+
389
+ const inputFile = root.source && root.source.input.file;
390
+ const relFile = inputFile ? toRepoRelative(inputFile) : undefined;
391
+ const dir = relFile ? path.basename(path.dirname(relFile)) : undefined;
392
+ const inScope =
393
+ Boolean(componentRoot) &&
394
+ Boolean(relFile) &&
395
+ (relFile === componentRoot || relFile.startsWith(`${componentRoot}/`));
396
+ // Scanning a tree to police an empty map is work with no finding it
397
+ // could ever produce — skip the scan (and the staleness report loop)
398
+ // outright rather than run it and find nothing, every file, forever.
399
+ const hasExemptions = Object.keys(TOKEN_BYPASS_EXEMPT).length > 0;
400
+ const rootKey = cacheKey(componentRoot, tokenFile, allowlistsFile);
401
+
402
+ if (inScope && hasExemptions && !seenByRoot.has(rootKey)) {
403
+ seenByRoot.set(
404
+ rootKey,
405
+ scanComponentRootForSeenKeys(
406
+ path.resolve(REPO_ROOT, componentRoot),
407
+ tokenValue,
408
+ ),
409
+ );
410
+ }
411
+ const seenKeys =
412
+ inScope && hasExemptions ? seenByRoot.get(rootKey) : new Set();
413
+
414
+ // Allowlist hygiene, reported once per (componentRoot, tokenFile,
415
+ // allowlistsFile) per run — see no-primitive-token.js for why this is
416
+ // the anchor and its limitation.
417
+ if (inScope && hasExemptions && !staleReportedForRoot.has(rootKey)) {
418
+ staleReportedForRoot.add(rootKey);
419
+ for (const key of Object.keys(TOKEN_BYPASS_EXEMPT)) {
420
+ if (!seenKeys.has(key)) {
421
+ stylelint.utils.report({
422
+ message: messages.stale(key),
423
+ node: root,
424
+ result,
425
+ ruleName,
426
+ });
427
+ }
428
+ }
429
+ }
430
+
431
+ root.walkDecls((decl) => {
432
+ const prop = decl.prop.toLowerCase();
433
+ const value = decl.value.trim().replace(/\s+/g, ' ');
434
+ if (value.includes('var(--ui-')) return;
435
+ if (STRUCTURAL.has(value)) return;
436
+
437
+ if (BORDER_PROP.test(prop)) {
438
+ const width = value.match(/^([\d.]+)(px|rem|em)\b/);
439
+ if (!width) return;
440
+
441
+ const key = `${dir}:${prop}:${width[0]}`;
442
+ const exempt = TOKEN_BYPASS_EXEMPT[key];
443
+ if (exempt) {
444
+ if (exempt.kind === 'gap') {
445
+ stylelint.utils.report({
446
+ message: `[GAP] ${prop}: ${width[0]} still unbound. ${exempt.why}`,
447
+ node: decl,
448
+ result,
449
+ ruleName,
450
+ severity: 'warning',
451
+ });
452
+ }
453
+ return;
454
+ }
455
+ stylelint.utils.report({
456
+ message: messages.border(prop, value),
457
+ node: decl,
458
+ result,
459
+ ruleName,
460
+ });
461
+ return;
462
+ }
463
+
464
+ const family = FAMILY[prop];
465
+ if (!family) return;
466
+ const holders = tokensHolding(value, family, tokenValue);
467
+ if (holders.length === 0) return;
468
+
469
+ const key = `${dir}:${prop}:${value}`;
470
+ const exempt = TOKEN_BYPASS_EXEMPT[key];
471
+ if (exempt) {
472
+ if (exempt.kind === 'gap') {
473
+ stylelint.utils.report({
474
+ message: `[GAP] ${prop}: ${value} should bind to var(${holders[0]}). ${exempt.why}`,
475
+ node: decl,
476
+ result,
477
+ ruleName,
478
+ severity: 'warning',
479
+ });
480
+ }
481
+ return;
482
+ }
483
+ stylelint.utils.report({
484
+ message: messages.bypass(prop, value, holders),
485
+ node: decl,
486
+ result,
487
+ ruleName,
488
+ });
489
+ });
490
+ };
491
+ };
492
+
493
+ rule.ruleName = ruleName;
494
+ rule.messages = messages;
495
+ rule.meta = meta;
496
+
497
+ module.exports = stylelint.createPlugin(ruleName, rule);
@@ -0,0 +1,122 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * atelier/no-undeclared-token
5
+ *
6
+ * Every `--ui-*` custom property a stylesheet READS via `var(--ui-x, ...)`
7
+ * must be declared somewhere in the token source(s) named by the `tokenFiles`
8
+ * secondary option (repo-root-relative paths, e.g.
9
+ * `['libs/create-workspace/src/generators/preset/files/styles/tokens.css']`).
10
+ * A `var()` fallback is what makes an undeclared reference silent: the
11
+ * component still renders, plausibly, at a value the design system does not
12
+ * control. Two real incidents motivated this (not hypothetical):
13
+ *
14
+ * - all three code-block stylesheets read `var(--ui-font-mono, …)` while
15
+ * nothing declared it, so every code block silently rendered in the
16
+ * Menlo fallback until ADR-0035 declared the token.
17
+ * - AtlTooltip read `var(--ui-z-tooltip, 200)`, which no token source had
18
+ * ever declared, so the tooltip's stacking level was the literal 200
19
+ * while every other floating layer used --ui-z-dropdown (ADR-0075).
20
+ *
21
+ * `tokenFiles` is a required secondary option, not a hardcoded path, because
22
+ * the declared-token SET differs by scope: component CSS is checked only
23
+ * against the shared `tokens.css` (one file, three frameworks' own copies
24
+ * kept byte-identical to it by `check:tokens`), while the docs site's CSS is
25
+ * checked against that file UNION `docs/src/styles/docs-theme.css` (which
26
+ * both declares docs-only tokens and re-declares/overrides some `--ui-*`
27
+ * ones). A stylelint config wires the two scopes to two different
28
+ * `tokenFiles` lists via per-glob overrides, rather than this rule guessing
29
+ * which file(s) apply to which CSS.
30
+ *
31
+ * `--ui-` itself (as opposed to `--docs-*` or any other custom-property
32
+ * prefix) is NOT an option: unlike `tokenFiles`, there is no known reason
33
+ * this repo would ever want to police a different prefix, so it stays a
34
+ * constant the way `atelier/host-attr-guard`'s `HOST_GUARD_KEY` does.
35
+ *
36
+ * Reports once per `var(--ui-x)` occurrence, attributed to the declaration
37
+ * that reads it — the formerly-`check-css-tokens.js` script instead
38
+ * aggregated one message per undeclared token name across the whole repo
39
+ * ("read by N component stylesheet(s)"); a linter reporting per file, at
40
+ * the exact line, is the more useful shape for the same fact and is what a
41
+ * `root.walkDecls` visitor naturally produces.
42
+ *
43
+ * Formerly Pass C of `tools/scripts/check-css-tokens.js`.
44
+ */
45
+
46
+ const fs = require('fs');
47
+ const path = require('path');
48
+ const stylelint = require('stylelint');
49
+ const { REPO_ROOT, isNonEmptyString } = require('./utils');
50
+
51
+ const ruleName = 'atelier/no-undeclared-token';
52
+
53
+ const messages = stylelint.utils.ruleMessages(ruleName, {
54
+ rejected: (name) =>
55
+ `[UNDECLARED] '${name}' is read here via var() but is declared in no ` +
56
+ 'configured token source. The component silently renders at its ' +
57
+ "var() fallback (if any), which the design system doesn't control. " +
58
+ 'Declare the token, or reference one that exists.',
59
+ });
60
+
61
+ const meta = {
62
+ url: 'tools/stylelint-rules/no-undeclared-token.js',
63
+ };
64
+
65
+ // Matches a `var(--ui-...)` READ inside a declaration's value.
66
+ const TOKEN_READ = /var\(\s*(--ui-[a-z0-9-]+)/g;
67
+ // Matches a `--ui-...` DECLARATION (`--ui-x: <value>;`) inside a token
68
+ // source file. A token may be re-declared across selectors (light / dark /
69
+ // [data-theme] blocks); only its existence matters here.
70
+ const TOKEN_DECLARATION = /(--ui-[a-zA-Z0-9-]+)\s*:/g;
71
+
72
+ /** The set of `--ui-*` names declared in `tokenFiles` (repo-root-relative paths). */
73
+ function readDeclaredTokens(tokenFiles) {
74
+ const declared = new Set();
75
+ for (const relFile of tokenFiles) {
76
+ const absFile = path.resolve(REPO_ROOT, relFile);
77
+ const src = fs.readFileSync(absFile, 'utf-8');
78
+ for (const match of src.matchAll(TOKEN_DECLARATION)) {
79
+ declared.add(match[1]);
80
+ }
81
+ }
82
+ return declared;
83
+ }
84
+
85
+ /** @type {import('stylelint').Rule} */
86
+ const rule = (primary, secondaryOptions) => {
87
+ return (root, result) => {
88
+ const validOptions = stylelint.utils.validateOptions(
89
+ result,
90
+ ruleName,
91
+ { actual: primary, possible: [true] },
92
+ {
93
+ actual: secondaryOptions,
94
+ possible: { tokenFiles: [isNonEmptyString] },
95
+ },
96
+ );
97
+ if (!validOptions) return;
98
+
99
+ const tokenFiles = secondaryOptions.tokenFiles;
100
+ const declaredTokens = readDeclaredTokens(tokenFiles);
101
+
102
+ root.walkDecls((decl) => {
103
+ for (const match of decl.value.matchAll(TOKEN_READ)) {
104
+ const name = match[1];
105
+ if (declaredTokens.has(name)) continue;
106
+
107
+ stylelint.utils.report({
108
+ message: messages.rejected(name),
109
+ node: decl,
110
+ result,
111
+ ruleName,
112
+ });
113
+ }
114
+ });
115
+ };
116
+ };
117
+
118
+ rule.ruleName = ruleName;
119
+ rule.messages = messages;
120
+ rule.meta = meta;
121
+
122
+ module.exports = stylelint.createPlugin(ruleName, rule);
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Small, dependency-free helpers shared by the local rules in this
5
+ * directory. Deliberately does not import anything from `stylelint/lib/*`
6
+ * beyond the public API (`stylelint.utils.*`, `stylelint.createPlugin`) —
7
+ * those subpaths happen to be resolvable (the package's `exports` map
8
+ * allows `./lib/utils/*`), but they are stylelint's own internals, not part
9
+ * of its documented `PublicApi`, so a rule in this repo doesn't take a
10
+ * dependency on them staying stable across versions.
11
+ */
12
+
13
+ const path = require('path');
14
+
15
+ /**
16
+ * Absolute path to the repository root, computed from this file's own
17
+ * location rather than from `process.cwd()` — `tools/stylelint-rules/` sits
18
+ * at the same depth as `tools/scripts/` (two levels under the root), so this
19
+ * resolves correctly regardless of which directory stylelint is invoked
20
+ * from (an Nx target may run with `cwd` set to a project root, not the
21
+ * workspace root).
22
+ */
23
+ const REPO_ROOT = path.resolve(__dirname, '../..');
24
+
25
+ /**
26
+ * `absPath` expressed relative to `REPO_ROOT`, POSIX-separated so an option
27
+ * written as `'docs/src/styles/global.css'` matches on every platform.
28
+ *
29
+ * @param {string} absPath
30
+ */
31
+ function toRepoRelative(absPath) {
32
+ return path.relative(REPO_ROOT, absPath).split(path.sep).join('/');
33
+ }
34
+
35
+ /** Is `value` a non-empty string? Used to validate rule options without
36
+ * depending on stylelint's internal `validateTypes` module (see header). */
37
+ function isNonEmptyString(value) {
38
+ return typeof value === 'string' && value.length > 0;
39
+ }
40
+
41
+ /**
42
+ * A `componentRoot`-shaped option, normalized exactly the way a linted
43
+ * file's own path already is by `toRepoRelative` above: resolved to an
44
+ * absolute path, then re-expressed relative to `REPO_ROOT` with POSIX
45
+ * separators. Without this, a rule comparing an option value directly
46
+ * against `toRepoRelative(inputFile)` (a plain `===`/`startsWith` string
47
+ * compare) silently disagrees on a leading `./`, a trailing slash, or an
48
+ * absolute path — all three name the same directory as the plain
49
+ * `libs/<fw>/src/lib` form every config in this repo happens to use today,
50
+ * but nothing enforced that shape until this normalization existed
51
+ * (2026-09-12 stylelint review, claim 4: reproduced — `componentRoot:
52
+ * 'scratch-claim4/'` (trailing slash), `'./scratch-claim4'` (leading dot),
53
+ * and the equivalent absolute path each turned a genuinely stale
54
+ * `PRIMITIVE_EXEMPTIONS` entry from a blocking `[STALE]` into total silence,
55
+ * exit 0 — the staleness scan never ran because `inScope` never matched).
56
+ * Both `no-primitive-token.js` and `no-token-bypass.js` normalize
57
+ * `componentRoot` through this before using it either in the `inScope`
58
+ * check or as (part of) a cache key.
59
+ *
60
+ * @param {string} relOrAbsPath
61
+ */
62
+ function normalizeRepoRelative(relOrAbsPath) {
63
+ return toRepoRelative(path.resolve(REPO_ROOT, relOrAbsPath));
64
+ }
65
+
66
+ module.exports = {
67
+ REPO_ROOT,
68
+ toRepoRelative,
69
+ isNonEmptyString,
70
+ normalizeRepoRelative,
71
+ };