tokens-to-css 1.0.0 → 1.1.0

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.
package/README.md CHANGED
@@ -57,6 +57,25 @@ stylesheet — the relationship survived instead of being flattened to `#191627`
57
57
  Change the primitive and everything pointing at it moves, which is the reason to
58
58
  keep tokens in a hierarchy at all.
59
59
 
60
+ Composite tokens work too. Shadow, border, transition and gradient become one
61
+ custom property each, and typography becomes one per property it describes:
62
+
63
+ ```css
64
+ --elevation-low: 0px 2px 6px 0px var(--color-shadow);
65
+ --motion-emphasized: 200ms cubic-bezier(0.2, 0, 0, 1) 0ms;
66
+ --type-body-font-size: 0.875rem;
67
+ --type-body-letter-spacing: 0.16px;
68
+ ```
69
+
70
+ An aliased sub-value stays an alias there too, so a shadow still moves when its
71
+ colour does. And where the token genuinely does not say something — a transition
72
+ never says *which* property it animates — the missing part is yours to supply
73
+ rather than ours to invent:
74
+
75
+ ```css
76
+ .panel { transition: opacity var(--motion-emphasized); }
77
+ ```
78
+
60
79
  A URL works wherever a path does:
61
80
 
62
81
  ```js
@@ -136,7 +155,7 @@ addresses refused — including when the URL names one literally.
136
155
  | [Getting started](docs/getting-started.md) | Five steps from install to a stylesheet, including breaking it on purpose |
137
156
  | [What it accepts](docs/formats.md) | The three shapes, the order they are checked, and everything refused |
138
157
  | [The naming rule](docs/naming.md) | How `color.brand` becomes `--color-brand`, and why that is a promise |
139
- | [Failure codes](docs/failures.md) | The eight codes and what each one means |
158
+ | [Failure codes](docs/failures.md) | The nine codes and what each one means |
140
159
 
141
160
  ## What it will not do
142
161
 
@@ -149,10 +168,14 @@ deferred, not forgotten.
149
168
  - **Evaluate expressions.** `{spacing.md} * 2` is refused rather than computed.
150
169
  There is no evaluator in this package. `calc()` and `clamp()` are valid CSS
151
170
  and pass through untouched.
152
- - **Convert composite tokens.** Typography, shadow, border, gradient and
153
- transition are refused: a typography token is five CSS properties, and
154
- accepting it would change what "one token, one custom property" means.
155
- Deferred to a version after this one.
171
+ - **Guess at a token it cannot write.** A composite missing a sub-property the
172
+ spec marks required, a border whose style is SVG geometry, a font weight that
173
+ is not a weight each is **skipped**: left out of the stylesheet, listed in
174
+ `skipped` on the result, and named in a comment above `:root`, while the rest
175
+ of the file converts.
176
+ - **Emit the `font` shorthand.** Used alone it drops `letter-spacing` in
177
+ silence, so a typography token becomes five properties rather than five plus a
178
+ trap.
156
179
  - **Pick a winner on a collision.** Two token paths that produce the same
157
180
  custom-property name fail the conversion rather than one quietly overwriting
158
181
  the other.
package/dist/index.d.ts CHANGED
@@ -1,52 +1,3 @@
1
- //#region src/options.d.ts
2
- /**
3
- * What a caller passes in and gets back.
4
- *
5
- * These live apart from the public surface so the orchestrator can read them
6
- * without importing the entry point that calls it — a cycle between the two
7
- * would work in ESM and still be the wrong shape (AD-1).
8
- */
9
- /** How a remote Token Source is fetched. Ignored when the source is a local path. */
10
- interface HttpOptions {
11
- /** Allow `http:` URLs. Off by default — only `https:` is fetched. */
12
- readonly allowInsecure?: boolean;
13
- /** Total budget for the request, in milliseconds. */
14
- readonly timeoutMs?: number;
15
- /** Largest response body accepted, in bytes. */
16
- readonly maxBytes?: number;
17
- /** How many redirects to follow before giving up. */
18
- readonly maxRedirects?: number;
19
- }
20
- /** Everything a caller can adjust about a conversion. */
21
- interface GenerateCssOptions {
22
- /** Directory the stylesheet is written to. Defaults to `assets/css`. */
23
- readonly outDir?: string;
24
- /** Filename inside that directory. Defaults to `tokens.css`. */
25
- readonly fileName?: string;
26
- /** Base for resolving relative paths. Defaults to the current working directory. */
27
- readonly baseDir?: string;
28
- /** Network policy for a URL Token Source. */
29
- readonly http?: HttpOptions;
30
- }
31
- /** What a successful conversion reports back. Deliberately carries no CSS. */
32
- interface GenerateCssResult {
33
- /** Absolute path of the stylesheet that was written. */
34
- readonly outputPath: string;
35
- /** How many custom properties it declares. */
36
- readonly tokenCount: number;
37
- }
38
- /** The defaults a conversion uses when the caller says nothing. */
39
- declare const DEFAULTS: Readonly<{
40
- outDir: "assets/css";
41
- fileName: "tokens.css";
42
- http: Readonly<{
43
- allowInsecure: false;
44
- timeoutMs: 10000;
45
- maxBytes: 10000000;
46
- maxRedirects: 3;
47
- }>;
48
- }>;
49
- //#endregion
50
1
  //#region src/errors.d.ts
51
2
  /**
52
3
  * The failure contract — public surface, frozen by semver (AD-4).
@@ -72,15 +23,33 @@ declare const FailureCode: Readonly<{
72
23
  readonly ALIAS_CYCLE: 'ALIAS_CYCLE';
73
24
  /** An alias points at a token that does not exist. */
74
25
  readonly ALIAS_DANGLING: 'ALIAS_DANGLING';
75
- /** A value is an object, array, boolean, or null rather than a scalar. */
26
+ /** A value is an object, array, boolean, or null rather than a scalar — reported as a skipped token, not a failure. */
76
27
  readonly COMPOSITE_VALUE: 'COMPOSITE_VALUE';
77
28
  /** Two or more tokens would emit the same custom-property name. */
78
29
  readonly NAME_COLLISION: 'NAME_COLLISION';
79
30
  /** The stylesheet could not be written. */
80
31
  readonly OUTPUT_WRITE_FAILED: 'OUTPUT_WRITE_FAILED';
32
+ /** Every token was skipped, so the stylesheet would declare nothing (FR-24). */
33
+ readonly NOTHING_EMITTED: 'NOTHING_EMITTED';
81
34
  }>;
82
- /** One of the eight failure codes. */
35
+ /** One of the nine failure codes. */
83
36
  type FailureCode = (typeof FailureCode)[keyof typeof FailureCode];
37
+ /**
38
+ * One token that was left out of the stylesheet (FR-24).
39
+ *
40
+ * A skip is not a failure: the conversion succeeded and the document is
41
+ * otherwise intact. It is reported here for callers, and in a comment block
42
+ * above `:root` for the humans who will read the generated file — which is the
43
+ * copy that shows up in a pull request when a token stops being emitted.
44
+ */
45
+ interface SkippedToken {
46
+ /** Dotted path of the token, spelled as the document wrote it. */
47
+ readonly path: string;
48
+ /** Why, in the same vocabulary a failure would have used. */
49
+ readonly code: FailureCode;
50
+ /** The sentence a caller would have seen had this been fatal. Wording is not contract. */
51
+ readonly reason: string;
52
+ }
84
53
  /** What a `TokenCssError` is constructed from. */
85
54
  interface TokenCssErrorInit {
86
55
  /** The failure class. */
@@ -109,20 +78,84 @@ declare class TokenCssError extends Error {
109
78
  constructor(message: string, init: TokenCssErrorInit);
110
79
  }
111
80
  //#endregion
81
+ //#region src/options.d.ts
82
+ /**
83
+ * What a caller passes in and gets back.
84
+ *
85
+ * These live apart from the public surface so the orchestrator can read them
86
+ * without importing the entry point that calls it — a cycle between the two
87
+ * would work in ESM and still be the wrong shape (AD-1).
88
+ */
89
+ /** How a remote Token Source is fetched. Ignored when the source is a local path. */
90
+ interface HttpOptions {
91
+ /** Allow `http:` URLs. Off by default — only `https:` is fetched. */
92
+ readonly allowInsecure?: boolean;
93
+ /** Total budget for the request, in milliseconds. */
94
+ readonly timeoutMs?: number;
95
+ /** Largest response body accepted, in bytes. */
96
+ readonly maxBytes?: number;
97
+ /** How many redirects to follow before giving up. */
98
+ readonly maxRedirects?: number;
99
+ }
100
+ /** Everything a caller can adjust about a conversion. */
101
+ interface GenerateCssOptions {
102
+ /** Directory the stylesheet is written to. Defaults to `assets/css`. */
103
+ readonly outDir?: string;
104
+ /** Filename inside that directory. Defaults to `tokens.css`. */
105
+ readonly fileName?: string;
106
+ /** Base for resolving relative paths. Defaults to the current working directory. */
107
+ readonly baseDir?: string;
108
+ /** Network policy for a URL Token Source. */
109
+ readonly http?: HttpOptions;
110
+ }
111
+ /** What a successful conversion reports back. Deliberately carries no CSS. */
112
+ interface GenerateCssResult {
113
+ /** Absolute path of the stylesheet that was written. */
114
+ readonly outputPath: string;
115
+ /** How many custom properties it declares. */
116
+ readonly tokenCount: number;
117
+ /**
118
+ * Tokens the document contained that the stylesheet could not (FR-24).
119
+ *
120
+ * Empty on a conversion that lost nothing, which is the ordinary case. A
121
+ * caller that wants a conversion to be all-or-nothing checks this and decides
122
+ * for itself; the library's own answer is to convert what it can and say what
123
+ * it could not.
124
+ */
125
+ readonly skipped: readonly SkippedToken[];
126
+ }
127
+ /** The defaults a conversion uses when the caller says nothing. */
128
+ declare const DEFAULTS: Readonly<{
129
+ outDir: "assets/css";
130
+ fileName: "tokens.css";
131
+ http: Readonly<{
132
+ allowInsecure: false;
133
+ timeoutMs: 10000;
134
+ maxBytes: 10000000;
135
+ maxRedirects: 3;
136
+ }>;
137
+ }>;
138
+ //#endregion
112
139
  //#region src/index.d.ts
113
140
  /**
114
141
  * Convert a design-token document into a CSS custom-properties stylesheet.
115
142
  *
116
143
  * Reads the Token Source, validates it completely, and writes the stylesheet —
117
- * or throws a `TokenCssError` and writes nothing at all. There is no partial
118
- * success: a previous stylesheet at the target path is left untouched whenever
119
- * the conversion fails.
144
+ * or throws a `TokenCssError` and writes nothing at all. A failed conversion
145
+ * writes nothing: a previous stylesheet at the target path is left untouched.
146
+ *
147
+ * A token whose value cannot be written as CSS does not fail the conversion
148
+ * (FR-24). It is left out, listed in `skipped` on the result, and named in a
149
+ * comment above `:root` in the stylesheet itself. Everything else — an
150
+ * unreadable source, a document shaped in a way this version does not accept,
151
+ * an alias cycle, a dangling reference, a name collision — still fails whole.
120
152
  *
121
153
  * @param source Path to a single local file, or a URL.
122
154
  * @param options Output location and network policy.
123
- * @returns Where the stylesheet was written, and how many properties it holds.
155
+ * @returns Where the stylesheet was written, how many properties it holds, and
156
+ * which tokens it left out.
124
157
  * @throws {TokenCssError} With a `code` naming the failure class.
125
158
  */
126
159
  declare function generateCss(source: string | URL, options?: GenerateCssOptions): Promise<GenerateCssResult>;
127
160
  //#endregion
128
- export { DEFAULTS, FailureCode, type GenerateCssOptions, type GenerateCssResult, type HttpOptions, TokenCssError, type TokenCssErrorInit, generateCss };
161
+ export { DEFAULTS, FailureCode, type GenerateCssOptions, type GenerateCssResult, type HttpOptions, type SkippedToken, TokenCssError, type TokenCssErrorInit, generateCss };
package/dist/index.js CHANGED
@@ -21,6 +21,13 @@ function ref(path) {
21
21
  path
22
22
  };
23
23
  }
24
+ /** Builds a value assembled from literal text and references. */
25
+ function composite(parts) {
26
+ return {
27
+ kind: "composite",
28
+ parts
29
+ };
30
+ }
24
31
  /** Builds a token node. */
25
32
  function token(path, value) {
26
33
  return {
@@ -32,6 +39,22 @@ function token(path, value) {
32
39
  function isRef(value) {
33
40
  return value.kind === "ref";
34
41
  }
42
+ /** Narrows a value to a composite. */
43
+ function isComposite(value) {
44
+ return value.kind === "composite";
45
+ }
46
+ /**
47
+ * Every reference a token makes, in order.
48
+ *
49
+ * A scalar makes at most one; a composite makes as many as it has aliased
50
+ * sub-values. Both validators read the graph through this, so neither has to
51
+ * know that composites exist.
52
+ */
53
+ function referencesOf(value) {
54
+ if (isRef(value)) return [value];
55
+ if (isComposite(value)) return value.parts.filter((part) => typeof part !== "string");
56
+ return [];
57
+ }
35
58
  /**
36
59
  * Renders a path the way the token document wrote it, for humans.
37
60
  *
@@ -67,12 +90,14 @@ const FailureCode = Object.freeze({
67
90
  ALIAS_CYCLE: "ALIAS_CYCLE",
68
91
  /** An alias points at a token that does not exist. */
69
92
  ALIAS_DANGLING: "ALIAS_DANGLING",
70
- /** A value is an object, array, boolean, or null rather than a scalar. */
93
+ /** A value is an object, array, boolean, or null rather than a scalar — reported as a skipped token, not a failure. */
71
94
  COMPOSITE_VALUE: "COMPOSITE_VALUE",
72
95
  /** Two or more tokens would emit the same custom-property name. */
73
96
  NAME_COLLISION: "NAME_COLLISION",
74
97
  /** The stylesheet could not be written. */
75
- OUTPUT_WRITE_FAILED: "OUTPUT_WRITE_FAILED"
98
+ OUTPUT_WRITE_FAILED: "OUTPUT_WRITE_FAILED",
99
+ /** Every token was skipped, so the stylesheet would declare nothing (FR-24). */
100
+ NOTHING_EMITTED: "NOTHING_EMITTED"
76
101
  });
77
102
  /**
78
103
  * The only error this library throws.
@@ -199,40 +224,58 @@ function customPropertyName(path, source) {
199
224
  }
200
225
  //#endregion
201
226
  //#region src/emit/css.ts
227
+ /** Two spaces. Fixed: a configurable indent would make goldens negotiable. */
228
+ const INDENT = " ";
202
229
  /**
203
- * Writing the stylesheet (FR-9, FR-10, AD-10).
230
+ * The block that announces skipped tokens (FR-24).
204
231
  *
205
- * The whole document becomes one string before anything reaches disk (AD-6), so
206
- * a failure late in emission cannot leave half a stylesheet behind.
232
+ * This is the half of the skip report that reaches people rather than callers.
233
+ * A generated stylesheet lives in a repository, so a token that stopped being
234
+ * emitted shows up here in the next diff — where somebody is already looking —
235
+ * instead of only in a return value most callers never read.
207
236
  *
208
- * The exact bytes produced here are the contract the golden files hold. Two
209
- * runs over the same document must produce identical text, which is why nothing
210
- * in this module reads the clock, the filesystem, the environment, or the
211
- * order a runtime happens to iterate keys — declaration order is the order of
212
- * the array it was handed, and that is document order.
237
+ * Nothing is emitted when nothing was skipped. That is not a nicety: it is what
238
+ * keeps every document that converted before this existed byte-identical.
213
239
  */
214
- /** Two spaces. Fixed: a configurable indent would make goldens negotiable. */
215
- const INDENT = " ";
240
+ function skipComment(skipped) {
241
+ if (skipped.length === 0) return [];
242
+ return [
243
+ `/* ${skipped.length === 1 ? "1 token was skipped:" : `${skipped.length} tokens were skipped:`}`,
244
+ ...skipped.map((s) => ` * ${s.reason}`),
245
+ " */"
246
+ ];
247
+ }
216
248
  /**
217
249
  * Renders a normalized document as a stylesheet.
218
250
  *
219
251
  * A token whose value points at another token is written as `var(--target)`,
220
252
  * never as the target's value. That is the whole point of the product: the
221
253
  * relationship the token file expressed survives into the CSS, so changing a
222
- * primitive still moves everything that referred to it.
254
+ * primitive still moves everything that referred to it. A composite is the same
255
+ * promise applied piecewise — an aliased sub-value stays `var(--target)` inside
256
+ * the larger value, so a shadow still moves when its colour does.
257
+ *
258
+ * A document carrying skipped tokens is preceded by a comment naming them, so
259
+ * the stylesheet says what it is missing rather than quietly being short.
223
260
  *
224
261
  * @param doc The normalized document, in document order.
262
+ * @param skipped Tokens the document held that the stylesheet cannot, announced
263
+ * in a comment above the rule. Empty for a document that lost nothing, and then
264
+ * no comment is written at all.
225
265
  * @param source The Token Source, carried only so a naming failure can name it.
226
266
  * @returns The complete stylesheet text, ending in exactly one newline.
227
267
  */
228
- function emitStylesheet(doc, source) {
268
+ function emitStylesheet(doc, skipped, source) {
269
+ const reference = (path) => `var(${customPropertyName(path, source)})`;
270
+ const declarations = doc.tokens.map((node) => {
271
+ const property = customPropertyName(node.path, source);
272
+ const value = isRef(node.value) ? reference(node.value.path) : isComposite(node.value) ? node.value.parts.map((part) => typeof part === "string" ? part : reference(part.path)).join("") : stringifyLiteral(node.value.value);
273
+ return `${INDENT}${property}: ${value};`;
274
+ });
229
275
  return [
276
+ ...skipComment(skipped),
230
277
  ":root {",
231
- ...doc.tokens.map((node) => {
232
- const property = customPropertyName(node.path, source);
233
- const value = isRef(node.value) ? `var(${customPropertyName(node.value.path, source)})` : stringifyLiteral(node.value.value);
234
- return `${INDENT}${property}: ${value};`;
235
- }),
278
+ ...declarations,
236
279
  "}",
237
280
  ""
238
281
  ].join("\n");
@@ -253,17 +296,20 @@ const DEFAULTS = Object.freeze({
253
296
  //#endregion
254
297
  //#region src/dialects/values.ts
255
298
  /**
256
- * Object-form scalar values (FR-23).
299
+ * Scalars the spec writes in an expanded notation (FR-23, FR-26).
257
300
  *
258
- * The current DTCG spec writes a colour and a dimension as objects rather than
259
- * strings:
301
+ * Four values in this format are one CSS value each, written as an object or an
302
+ * array rather than a string:
260
303
  *
261
304
  * { "colorSpace": "srgb", "components": [0, 0, 0], "alpha": 1, "hex": "#000" }
262
305
  * { "value": 0.25, "unit": "rem" }
306
+ * ["Monaco", "Consolas", "monospace"]
307
+ * [0, 0, 1, 1]
263
308
  *
264
- * Both still describe **one** CSS value. They are scalars written with more
265
- * ceremony, not composites, and refusing them meant refusing every design
266
- * system published against the current spec.
309
+ * All four are scalars written with more ceremony, not composites. FR-23
310
+ * accepted the two written as objects, because refusing them meant refusing
311
+ * every design system published against the current spec; the 2026-09-02 survey
312
+ * found the same mistake still standing for the two written as arrays.
267
313
  *
268
314
  * Two properties of this module matter more than the conversion itself:
269
315
  *
@@ -373,22 +419,427 @@ function colorToCss(raw, path, source) {
373
419
  return `color(${space} ${rendered.join(" ")}${suffix})`;
374
420
  }
375
421
  /**
376
- * Converts an object-form scalar into stylesheet text.
377
- *
378
- * @returns The CSS value, or `null` when the object is not a scalar in object
379
- * form — a typography or shadow block, which stays a composite and is refused
380
- * by the caller.
381
- * @throws {TokenCssError} `FORMAT_NOT_ALLOWED` when the object *is* one of these
382
- * shapes but is malformed or unusable in CSS.
422
+ * Families CSS resolves itself. Quoting one turns the generic family into the
423
+ * name of a font that does not exist, so these are never quoted.
383
424
  */
384
- function objectValueToCss(raw, path, source) {
385
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
425
+ const GENERIC_FAMILIES = /* @__PURE__ */ new Set([
426
+ "serif",
427
+ "sans-serif",
428
+ "monospace",
429
+ "cursive",
430
+ "fantasy",
431
+ "system-ui",
432
+ "ui-serif",
433
+ "ui-sans-serif",
434
+ "ui-monospace",
435
+ "ui-rounded",
436
+ "math",
437
+ "emoji",
438
+ "fangsong"
439
+ ]);
440
+ /** Quoted, or they are read as the keyword rather than as a family name. */
441
+ const CSS_WIDE_KEYWORDS = /* @__PURE__ */ new Set([
442
+ "inherit",
443
+ "initial",
444
+ "unset",
445
+ "revert",
446
+ "revert-layer",
447
+ "default"
448
+ ]);
449
+ /** A name that needs no quotes: one identifier, no spaces, not starting with a digit. */
450
+ const BARE_NAME = /^-?[a-zA-Z_][a-zA-Z0-9_-]*$/;
451
+ /** A comma or a quote means the author already wrote CSS here, not one font name. */
452
+ const ALREADY_CSS = /[,'"]/;
453
+ /**
454
+ * Renders one entry of a font family list.
455
+ *
456
+ * The 2026-09-02 survey found four notations in the wild, and two of them —
457
+ * Microsoft Fluent's and GitHub Primer's — put an entire pre-quoted stack where
458
+ * the spec says one name goes. A quoting rule applied to those would wrap the
459
+ * whole stack in quotes and produce a single bogus font name, in silence. So an
460
+ * entry that already contains a comma or a quote is passed through as written:
461
+ * it is CSS the author wrote, and it is correct as it stands.
462
+ */
463
+ function fontFamilyEntry(name) {
464
+ if (ALREADY_CSS.test(name)) return name;
465
+ if (GENERIC_FAMILIES.has(name.toLowerCase())) return name;
466
+ if (CSS_WIDE_KEYWORDS.has(name.toLowerCase())) return `"${name}"`;
467
+ return BARE_NAME.test(name) ? name : `"${name}"`;
468
+ }
469
+ /** An array of names — every element a non-empty string with no reference in it. */
470
+ function isFontFamily(raw) {
471
+ return raw.length > 0 && raw.every((n) => typeof n === "string" && n.trim() !== "" && !/[{}]/.test(n));
472
+ }
473
+ /** Four finite numbers, and nothing else in DTCG is written that way. */
474
+ function isCubicBezier(raw) {
475
+ return raw.length === 4 && raw.every((n) => typeof n === "number" && Number.isFinite(n));
476
+ }
477
+ /**
478
+ * Converts a scalar written in an expanded notation into stylesheet text.
479
+ *
480
+ * Recognition reads the **shape** and never the declared `$type`, which is what
481
+ * keeps "a `16` never becomes `16px`" true: `{colorSpace, components}` can only
482
+ * be a colour, `{value, unit}` can only be a dimension, an array of names can
483
+ * only be a font family, and four numbers can only be a curve.
484
+ *
485
+ * @returns The CSS value, or `null` when this is not a scalar in disguise — a
486
+ * typography block, a shadow list, an array holding a reference. The caller
487
+ * skips those (FR-24) rather than guessing at them.
488
+ * @throws {TokenCssError} `FORMAT_NOT_ALLOWED` when the value *is* one of these
489
+ * shapes but is malformed or unusable in CSS, which is a different thing from
490
+ * being unrecognized and gets its own message.
491
+ */
492
+ function scalarToCss(raw, path, source) {
493
+ if (typeof raw !== "object" || raw === null) return null;
494
+ if (Array.isArray(raw)) {
495
+ if (isFontFamily(raw)) return raw.map(fontFamilyEntry).join(", ");
496
+ if (isCubicBezier(raw)) return `cubic-bezier(${raw.join(", ")})`;
497
+ return null;
498
+ }
386
499
  const record = raw;
387
500
  if (isColor(record)) return colorToCss(record, path, source);
388
501
  if (isDimension(record)) return dimensionToCss(record, path, source);
389
502
  return null;
390
503
  }
391
504
  //#endregion
505
+ //#region src/dialects/composites.ts
506
+ /**
507
+ * Composite tokens that fit one CSS value (FR-25).
508
+ *
509
+ * Four DTCG types describe several sub-values that CSS writes as a single
510
+ * property: shadow, border, transition and gradient. Each becomes one custom
511
+ * property here. The types that describe *several* CSS properties — typography,
512
+ * and the object form of strokeStyle — cannot, and are not this module's
513
+ * business.
514
+ *
515
+ * Two rules run through everything below.
516
+ *
517
+ * **Component order is public contract.** Once a shadow ships as
518
+ * `offsetX offsetY blur spread color`, every stylesheet in the world holds that
519
+ * order, and changing it is a major version. It is CSS's order, not ours, which
520
+ * is the only defensible way to pick one.
521
+ *
522
+ * **Nothing absent is invented.** Every sub-property the spec marks `MUST` has
523
+ * to be there or the token is skipped: a `spread` assumed to be `0` would be
524
+ * this product's first inference, and there is no principled second place to
525
+ * stop. Where the token genuinely does not say something — the axis of a
526
+ * gradient, the property a transition animates — the emitted value is the part
527
+ * it did say, and the consumer supplies the rest.
528
+ */
529
+ /** A whole-string reference, matching the one the walk uses. */
530
+ const WHOLE_REFERENCE$1 = /^\{([^{}]+)\}$/;
531
+ /** Raised inside this module and caught at its edge; never escapes. */
532
+ var Unwritable = class extends Error {};
533
+ const unwritable = (why) => {
534
+ throw new Unwritable(why);
535
+ };
536
+ /**
537
+ * Renders one sub-value: a reference stays a reference, anything else becomes
538
+ * the CSS text for it.
539
+ *
540
+ * This is where "an aliased sub-value stays an alias" is implemented — the one
541
+ * thing that keeps expansion from duplicating the primitives it was built from.
542
+ */
543
+ function sub(raw, what) {
544
+ if (typeof raw === "string") {
545
+ const whole = WHOLE_REFERENCE$1.exec(raw.trim());
546
+ if (whole) return ref(whole[1].split("."));
547
+ if (/[{}]/.test(raw)) unwritable(`its ${what} has a reference inside a larger value`);
548
+ return raw;
549
+ }
550
+ if (typeof raw === "number") return String(raw);
551
+ const scalar = scalarToCss(raw, [], "");
552
+ if (scalar === null) unwritable(`its ${what} is not a value CSS can hold`);
553
+ return scalar;
554
+ }
555
+ /** Appends one part, merging it into the previous one when both are text. */
556
+ function push(out, part) {
557
+ const last = out.at(-1);
558
+ if (typeof part === "string" && typeof last === "string") out[out.length - 1] = last + part;
559
+ else out.push(part);
560
+ }
561
+ /**
562
+ * Joins groups of parts with a separator **between the groups**.
563
+ *
564
+ * A group is one sub-value, and it may already be several parts — a shadow's
565
+ * colour is one group whether it is a literal or a reference. Flattening before
566
+ * separating would put the separator between the pieces of a sub-value instead
567
+ * of between sub-values, which is how a shadow list first came out as
568
+ * `0, 1px, 2px, 0, #000`.
569
+ */
570
+ function join$1(groups, separator) {
571
+ const out = [];
572
+ groups.forEach((group, index) => {
573
+ if (index > 0 && separator !== "") push(out, separator);
574
+ for (const part of Array.isArray(group) ? group : [group]) push(out, part);
575
+ });
576
+ return out;
577
+ }
578
+ /** Requires every sub-property the spec marks MUST. */
579
+ function require_(value, keys, kind) {
580
+ const missing = keys.filter((k) => !(k in value));
581
+ if (missing.length > 0) unwritable(`its ${kind} is missing ${missing.map((m) => `"${m}"`).join(", ")}`);
582
+ }
583
+ /** `offsetX offsetY blur spread color`, with `inset` in front when true. */
584
+ function shadowToParts(raw) {
585
+ require_(raw, [
586
+ "color",
587
+ "offsetX",
588
+ "offsetY",
589
+ "blur",
590
+ "spread"
591
+ ], "shadow");
592
+ const parts = join$1([...[
593
+ "offsetX",
594
+ "offsetY",
595
+ "blur",
596
+ "spread"
597
+ ].map((k) => sub(raw[k], k)), sub(raw["color"], "color")], " ");
598
+ return raw["inset"] === true ? join$1(["inset", ...parts], " ") : parts;
599
+ }
600
+ /** `width style color`. */
601
+ function borderToParts(raw) {
602
+ require_(raw, [
603
+ "color",
604
+ "width",
605
+ "style"
606
+ ], "border");
607
+ const style = raw["style"];
608
+ if (style !== null && typeof style === "object") unwritable("its style is written as an object, which a CSS border cannot express");
609
+ return join$1([
610
+ sub(raw["width"], "width"),
611
+ sub(style, "style"),
612
+ sub(raw["color"], "color")
613
+ ], " ");
614
+ }
615
+ /**
616
+ * `duration timingFunction delay` — the tail of the `transition` shorthand.
617
+ *
618
+ * The token never says *which* property is animated, so what is emitted is
619
+ * everything else, used as `transition: opacity var(--…)`. In that shorthand
620
+ * the first time is the duration and the second is the delay, which is why the
621
+ * order is not the one the object is written in.
622
+ */
623
+ function transitionToParts(raw) {
624
+ require_(raw, [
625
+ "duration",
626
+ "delay",
627
+ "timingFunction"
628
+ ], "transition");
629
+ return join$1([
630
+ sub(raw["duration"], "duration"),
631
+ sub(raw["timingFunction"], "timing function"),
632
+ sub(raw["delay"], "delay")
633
+ ], " ");
634
+ }
635
+ /**
636
+ * The stop list of a gradient, without an axis.
637
+ *
638
+ * A DTCG gradient is stops and nothing else — no direction, no angle. Emitting
639
+ * `linear-gradient(…)` would mean inventing an axis the token never stated, so
640
+ * the value is the stops, used as `background: linear-gradient(to right, var(--…))`.
641
+ *
642
+ * `position` is 0–1 in the token and a percentage in CSS, and the spec says an
643
+ * out-of-range number is clamped rather than refused.
644
+ */
645
+ function gradientToParts(raw) {
646
+ if (raw.length === 0) unwritable("it is an empty gradient");
647
+ return join$1(raw.map((stop) => {
648
+ if (typeof stop === "string") {
649
+ const whole = WHOLE_REFERENCE$1.exec(stop.trim());
650
+ if (whole) return [ref(whole[1].split("."))];
651
+ return unwritable("one of its stops is neither a stop nor a reference");
652
+ }
653
+ if (stop === null || typeof stop !== "object" || Array.isArray(stop)) return unwritable("one of its stops is not a gradient stop");
654
+ const record = stop;
655
+ require_(record, ["color", "position"], "gradient stop");
656
+ const position = record["position"];
657
+ if (typeof position !== "number" || !Number.isFinite(position)) return unwritable("one of its stop positions is not a number");
658
+ const clamped = Math.min(1, Math.max(0, position));
659
+ return join$1([sub(record["color"], "colour"), `${clamped * 100}%`], " ");
660
+ }), ", ");
661
+ }
662
+ /** True for `[{...}, ...]` — a list of shadows rather than one. */
663
+ const isObjectList = (raw) => raw.length > 0 && raw.every((e) => e !== null && typeof e === "object" && !Array.isArray(e));
664
+ /** Dispatches on shape. Throws {@link Unwritable} for a recognized-but-broken value. */
665
+ function build(raw) {
666
+ if (Array.isArray(raw)) {
667
+ if (!isObjectList(raw)) return null;
668
+ const first = raw[0];
669
+ if ("position" in first) return gradientToParts(raw);
670
+ if ("offsetX" in first || "offsetY" in first) return join$1(raw.map((s) => shadowToParts(s)), ", ");
671
+ return null;
672
+ }
673
+ if (raw === null || typeof raw !== "object") return null;
674
+ const record = raw;
675
+ if ("offsetX" in record || "offsetY" in record || "blur" in record) return shadowToParts(record);
676
+ if ("width" in record && "style" in record) return borderToParts(record);
677
+ if ("duration" in record || "timingFunction" in record) return transitionToParts(record);
678
+ return null;
679
+ }
680
+ /**
681
+ * Converts a composite that fits one CSS value.
682
+ *
683
+ * Recognition is by shape, never by `$type` — the same rule the object and
684
+ * array scalars follow. A shadow has offsets and a blur; a border has a width
685
+ * and a style; a transition has a duration and a timing function; a gradient is
686
+ * a list of stops. Nothing else is any of those.
687
+ */
688
+ function compositeToParts(raw) {
689
+ try {
690
+ const parts = build(raw);
691
+ return parts === null ? { kind: "unrecognized" } : {
692
+ kind: "parts",
693
+ parts
694
+ };
695
+ } catch (err) {
696
+ if (err instanceof Unwritable) return {
697
+ kind: "unwritable",
698
+ reason: err.message
699
+ };
700
+ throw err;
701
+ }
702
+ }
703
+ /**
704
+ * Sub-property to name suffix — public contract, seven entries.
705
+ *
706
+ * The 2026-09-02 design claimed the suffix would fall out of the existing
707
+ * naming rule for free. It does not: that rule lowercases and splits on
708
+ * non-alphanumerics, so `fontSize` becomes `fontsize`, and teaching it to split
709
+ * camelCase would rename every token already emitted — a major version.
710
+ *
711
+ * So there is a table, and each entry is the **CSS property the sub-value
712
+ * feeds**, which is the one mapping that is not arbitrary: the token is spelled
713
+ * the way the declaration that uses it is spelled.
714
+ *
715
+ * font-size: var(--type-body-font-size);
716
+ *
717
+ * Adding an entry later is additive. Changing one is a major version.
718
+ */
719
+ const CSS_PROPERTY = Object.freeze({
720
+ fontFamily: "font-family",
721
+ fontSize: "font-size",
722
+ fontWeight: "font-weight",
723
+ letterSpacing: "letter-spacing",
724
+ lineHeight: "line-height",
725
+ dashArray: "dash-array",
726
+ lineCap: "line-cap"
727
+ });
728
+ /**
729
+ * The spec's closed alias table for font weights.
730
+ *
731
+ * A word outside it skips the token. Passing it through would emit
732
+ * `font-weight: regular`, which is invalid CSS a browser ignores in silence —
733
+ * and inventing a number for an unknown word would be worse.
734
+ */
735
+ const FONT_WEIGHTS = Object.freeze({
736
+ thin: 100,
737
+ hairline: 100,
738
+ "extra-light": 200,
739
+ "ultra-light": 200,
740
+ light: 300,
741
+ normal: 400,
742
+ regular: 400,
743
+ book: 400,
744
+ medium: 500,
745
+ "semi-bold": 600,
746
+ "demi-bold": 600,
747
+ bold: 700,
748
+ "extra-bold": 800,
749
+ "ultra-bold": 800,
750
+ black: 900,
751
+ heavy: 900,
752
+ "extra-black": 950,
753
+ "ultra-black": 950
754
+ });
755
+ /** A font weight: a number, a reference, or one of the spec's words. */
756
+ function fontWeight(raw) {
757
+ if (typeof raw === "string" && !WHOLE_REFERENCE$1.test(raw.trim())) {
758
+ const mapped = FONT_WEIGHTS[raw.toLowerCase()];
759
+ if (mapped === void 0) unwritable(`its font weight is "${raw}", which is not a weight the spec defines`);
760
+ return [String(mapped)];
761
+ }
762
+ return [sub(raw, "font weight")];
763
+ }
764
+ /**
765
+ * Typography: five CSS properties, so five custom properties.
766
+ *
767
+ * The `font` shorthand is not emitted, not even as an extra convenience
768
+ * property. Used alone it drops `letter-spacing` in silence, which is the exact
769
+ * failure this product exists to prevent.
770
+ */
771
+ function typographyToExpansion(raw) {
772
+ require_(raw, [
773
+ "fontFamily",
774
+ "fontSize",
775
+ "fontWeight",
776
+ "letterSpacing",
777
+ "lineHeight"
778
+ ], "typography");
779
+ return [
780
+ {
781
+ suffix: CSS_PROPERTY["fontFamily"],
782
+ parts: [sub(raw["fontFamily"], "font family")]
783
+ },
784
+ {
785
+ suffix: CSS_PROPERTY["fontSize"],
786
+ parts: [sub(raw["fontSize"], "font size")]
787
+ },
788
+ {
789
+ suffix: CSS_PROPERTY["fontWeight"],
790
+ parts: fontWeight(raw["fontWeight"])
791
+ },
792
+ {
793
+ suffix: CSS_PROPERTY["letterSpacing"],
794
+ parts: [sub(raw["letterSpacing"], "letter spacing")]
795
+ },
796
+ {
797
+ suffix: CSS_PROPERTY["lineHeight"],
798
+ parts: [sub(raw["lineHeight"], "line height")]
799
+ }
800
+ ];
801
+ }
802
+ /** Stroke style in object form: two SVG properties, so two custom properties. */
803
+ function strokeStyleToExpansion(raw) {
804
+ require_(raw, ["dashArray", "lineCap"], "stroke style");
805
+ const dashes = raw["dashArray"];
806
+ if (!Array.isArray(dashes) || dashes.length === 0) unwritable("its dash array is not a list of lengths");
807
+ return [{
808
+ suffix: CSS_PROPERTY["dashArray"],
809
+ parts: join$1(dashes.map((d) => [sub(d, "dash length")]), " ")
810
+ }, {
811
+ suffix: CSS_PROPERTY["lineCap"],
812
+ parts: [sub(raw["lineCap"], "line cap")]
813
+ }];
814
+ }
815
+ /**
816
+ * Converts a composite that describes more than one CSS property (FR-25).
817
+ *
818
+ * Recognition is by shape, as everywhere else: only typography has a font size
819
+ * and a line height, and only an object-form stroke style has a dash array.
820
+ */
821
+ function compositeToExpansion(raw) {
822
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return { kind: "unrecognized" };
823
+ const record = raw;
824
+ try {
825
+ if ("fontFamily" in record || "fontSize" in record || "lineHeight" in record) return {
826
+ kind: "expanded",
827
+ expanded: typographyToExpansion(record)
828
+ };
829
+ if ("dashArray" in record || "lineCap" in record) return {
830
+ kind: "expanded",
831
+ expanded: strokeStyleToExpansion(record)
832
+ };
833
+ return { kind: "unrecognized" };
834
+ } catch (err) {
835
+ if (err instanceof Unwritable) return {
836
+ kind: "unwritable",
837
+ reason: err.message
838
+ };
839
+ throw err;
840
+ }
841
+ }
842
+ //#endregion
392
843
  //#region src/dialects/walk.ts
393
844
  /**
394
845
  * The shared tree walk every dialect uses (AD-2, AD-9, AD-20).
@@ -443,27 +894,82 @@ function toTokenValue(raw, path, source) {
443
894
  }
444
895
  if (ANY_BRACE.test(raw)) fail(`token "${formatPath(path)}" has a reference inside a larger value ("${raw}"). This version emits a reference only when the whole value is one`, source, path);
445
896
  }
446
- const asObjectScalar = objectValueToCss(raw, path, source);
447
- if (asObjectScalar !== null) return literal(asObjectScalar);
897
+ const asScalar = scalarToCss(raw, path, source);
898
+ if (asScalar !== null) return literal(asScalar);
899
+ const asComposite = compositeToParts(raw);
900
+ if (asComposite.kind === "parts") return composite(asComposite.parts);
901
+ if (asComposite.kind === "unwritable") throw new TokenCssError(`token "${formatPath(path)}" is a composite, but ${asComposite.reason}`, {
902
+ code: FailureCode.COMPOSITE_VALUE,
903
+ source,
904
+ tokenPaths: [formatPath(path)]
905
+ });
448
906
  return literal(assertScalar(raw, path, source));
449
907
  }
450
908
  /**
909
+ * Reads one token node into the custom properties it becomes.
910
+ *
911
+ * Almost always one. A composite that describes several CSS properties becomes
912
+ * several (FR-25), each named by appending the sub-property's suffix to the
913
+ * token's path — so naming, collision detection and the alias graph all keep
914
+ * working on ordinary paths and never learn that expansion exists.
915
+ *
916
+ * @throws {TokenCssError} `COMPOSITE_VALUE` when the value cannot be written,
917
+ * which the caller turns into a skip.
918
+ */
919
+ function readToken(raw, path, source) {
920
+ const expansion = compositeToExpansion(raw);
921
+ if (expansion.kind === "expanded") return expansion.expanded.map((one) => token([...path, one.suffix], composite(one.parts)));
922
+ if (expansion.kind === "unwritable") throw new TokenCssError(`token "${formatPath(path)}" is a composite, but ${expansion.reason}`, {
923
+ code: FailureCode.COMPOSITE_VALUE,
924
+ source,
925
+ tokenPaths: [formatPath(path)]
926
+ });
927
+ return [token(path, toTokenValue(raw, path, source))];
928
+ }
929
+ /**
451
930
  * Walks a token document into a flat, ordered list of tokens.
452
931
  *
453
- * Nothing is dropped quietly. A scalar sitting where a group should be is a
454
- * failure, not a skipped entry, because a token that vanishes between the file
455
- * and the stylesheet is the silent breakage the Reliability requirement forbids.
932
+ * Nothing is dropped quietly, but not everything is fatal any more (FR-24).
933
+ * One code is skippable and the rest are not:
934
+ *
935
+ * **`COMPOSITE_VALUE`** — the value is an object, an array, a boolean or null,
936
+ * so there is no CSS to write for it. That is scoped to one token and says
937
+ * nothing about the document around it, so the token is left out and recorded
938
+ * while the rest converts.
939
+ *
940
+ * **Everything else stays fatal**, including the token-scoped ones: a non-CSS
941
+ * unit, a reference buried in a larger string, Tokens Studio arithmetic. Each
942
+ * of those has its own message explaining the specific thing that is wrong, and
943
+ * folding them into a generic skip would replace an explanation with a shrug —
944
+ * arithmetic would be reported as an embedded reference, which is true and
945
+ * useless. A document whose shape this version does not accept is refused by
946
+ * name, the way it always has been.
947
+ *
948
+ * The line is drawn on the code rather than on where the failure was raised.
949
+ * Raising position is an implementation detail that moves; the code is public
950
+ * surface that cannot.
456
951
  *
457
952
  * @throws {TokenCssError} `FORMAT_NOT_ALLOWED` for unsafe keys, stray scalars,
458
- * and malformed references. Multi-file constructs are caught earlier, by the
459
- * registry, because they can appear in a document no dialect claims.
953
+ * malformed references and values this version does not accept. Multi-file
954
+ * constructs are caught earlier, by the registry, because they can appear in a
955
+ * document no dialect claims.
460
956
  */
461
957
  function walkTokenTree(root, source, reader) {
462
958
  const tokens = [];
959
+ const skipped = [];
463
960
  const visit = (node, path) => {
464
961
  const value = reader.read(node);
465
962
  if (value.found) {
466
- tokens.push(token(path, toTokenValue(value.raw, path, source)));
963
+ try {
964
+ tokens.push(...readToken(value.raw, path, source));
965
+ } catch (err) {
966
+ if (!(err instanceof TokenCssError) || err.code !== FailureCode.COMPOSITE_VALUE) throw err;
967
+ skipped.push({
968
+ path: formatPath(path),
969
+ code: err.code,
970
+ reason: err.message
971
+ });
972
+ }
467
973
  return;
468
974
  }
469
975
  for (const key of Object.keys(node)) {
@@ -476,7 +982,10 @@ function walkTokenTree(root, source, reader) {
476
982
  }
477
983
  };
478
984
  visit(root, []);
479
- return tokens;
985
+ return {
986
+ tokens,
987
+ skipped
988
+ };
480
989
  }
481
990
  //#endregion
482
991
  //#region src/dialects/dtcg.ts
@@ -536,7 +1045,11 @@ function looksLikeDtcg(root) {
536
1045
  }
537
1046
  /** Normalizes a DTCG document into the internal representation. */
538
1047
  function normalizeDtcg(root, source) {
539
- return { tokens: walkTokenTree(root, source, reader$2) };
1048
+ const { tokens, skipped } = walkTokenTree(root, source, reader$2);
1049
+ return {
1050
+ doc: { tokens },
1051
+ skipped
1052
+ };
540
1053
  }
541
1054
  //#endregion
542
1055
  //#region src/dialects/sd-legacy.ts
@@ -575,7 +1088,11 @@ function looksLikeSdLegacy(root) {
575
1088
  }
576
1089
  /** Normalizes a Style Dictionary legacy document into the internal representation. */
577
1090
  function normalizeSdLegacy(root, source) {
578
- return { tokens: walkTokenTree(root, source, reader$1) };
1091
+ const { tokens, skipped } = walkTokenTree(root, source, reader$1);
1092
+ return {
1093
+ doc: { tokens },
1094
+ skipped
1095
+ };
579
1096
  }
580
1097
  /**
581
1098
  * Finds a token node that speaks both dialects at once.
@@ -690,9 +1207,12 @@ function normalizeTokensStudio(root, source) {
690
1207
  code: FailureCode.FORMAT_NOT_ALLOWED,
691
1208
  source
692
1209
  });
693
- const tokens = walkTokenTree(set, source, reader);
1210
+ const { tokens, skipped } = walkTokenTree(set, source, reader);
694
1211
  for (const node of tokens) refuseExpressions(node, source);
695
- return { tokens };
1212
+ return {
1213
+ doc: { tokens },
1214
+ skipped
1215
+ };
696
1216
  }
697
1217
  //#endregion
698
1218
  //#region src/dialects/registry.ts
@@ -757,9 +1277,16 @@ function normalizeDocument(root, source) {
757
1277
  tokenPaths: [mixed.join(".")]
758
1278
  });
759
1279
  for (const dialect of DIALECTS) if (dialect.matches(root)) {
760
- const doc = dialect.normalize(root, source);
761
- if (doc.tokens.length === 0) break;
762
- return doc;
1280
+ const read = dialect.normalize(root, source);
1281
+ if (read.doc.tokens.length === 0) {
1282
+ if (read.skipped.length > 0) throw new TokenCssError(`every token in this document was skipped, so the stylesheet would declare nothing:\n` + read.skipped.map((skip) => ` ${skip.reason}`).join("\n"), {
1283
+ code: FailureCode.NOTHING_EMITTED,
1284
+ source,
1285
+ tokenPaths: read.skipped.map((skip) => skip.path)
1286
+ });
1287
+ break;
1288
+ }
1289
+ return read;
763
1290
  }
764
1291
  throw new TokenCssError(`no tokens were recognized in this document. This version reads: ${DIALECTS.map((d) => d.describedAs).join("; ")}`, {
765
1292
  code: FailureCode.FORMAT_NOT_ALLOWED,
@@ -1117,13 +1644,14 @@ function resolveOutputPath(outDir, fileName, baseDir) {
1117
1644
  * confusing half-cycles, and a developer with a typo should be told about the
1118
1645
  * typo rather than about a loop that only exists because of it.
1119
1646
  *
1120
- * Every token has at most one outgoing edge a value is either a literal or a
1121
- * single reference so the graph is a chain per token, and one linear sweep
1122
- * finds every cycle.
1647
+ * A token has as many outgoing edges as its value has references: none for a
1648
+ * literal, one for an alias, and one per aliased sub-value for a composite
1649
+ * (FR-25). Both passes read those edges through `referencesOf`, so neither
1650
+ * needs to know which kind of value produced them.
1123
1651
  */
1124
- /** Where a token's reference points, or `undefined` when it holds a literal. */
1125
- function targetOf(node) {
1126
- return isRef(node.value) ? formatPath(node.value.path) : void 0;
1652
+ /** Every token this one points at, as dotted paths, in order. */
1653
+ function targetsOf(node) {
1654
+ return referencesOf(node.value).map((r) => formatPath(r.path));
1127
1655
  }
1128
1656
  /**
1129
1657
  * Reports every reference whose target is not a token in this document.
@@ -1137,12 +1665,13 @@ function checkDangling(doc, byPath, source) {
1137
1665
  const problems = [];
1138
1666
  const offenders = [];
1139
1667
  for (const node of doc.tokens) {
1140
- const target = targetOf(node);
1141
- if (target === void 0 || byPath.has(target)) continue;
1142
1668
  const from = formatPath(node.path);
1143
- offenders.push(from);
1144
- const isGroup = [...byPath.keys()].some((known) => known.startsWith(`${target}.`));
1145
- problems.push(isGroup ? `"${from}" references "${target}", which is a group of tokens rather than a token` : `"${from}" references "${target}", which does not exist`);
1669
+ for (const target of targetsOf(node)) {
1670
+ if (byPath.has(target)) continue;
1671
+ if (!offenders.includes(from)) offenders.push(from);
1672
+ const isGroup = [...byPath.keys()].some((known) => known.startsWith(`${target}.`));
1673
+ problems.push(isGroup ? `"${from}" references "${target}", which is a group of tokens rather than a token` : `"${from}" references "${target}", which does not exist`);
1674
+ }
1146
1675
  }
1147
1676
  if (problems.length > 0) throw new TokenCssError(`${problems.length} ${problems.length === 1 ? "reference points" : "references point"} nowhere:\n ${problems.join("\n ")}`, {
1148
1677
  code: FailureCode.ALIAS_DANGLING,
@@ -1156,6 +1685,11 @@ const SETTLED = 2;
1156
1685
  /**
1157
1686
  * Reports every cycle in the reference graph.
1158
1687
  *
1688
+ * Depth-first with three colours: a node still on the current path that is
1689
+ * reached again closes a cycle. This replaced a linear sweep when composites
1690
+ * arrived — the sweep followed one edge per token, which is correct only while
1691
+ * a token can make at most one reference, and a composite makes several.
1692
+ *
1159
1693
  * Iterative rather than recursive: a document is allowed to be a chain of ten
1160
1694
  * thousand tokens, and that is a stack overflow rather than a clear failure if
1161
1695
  * this walks itself.
@@ -1163,19 +1697,36 @@ const SETTLED = 2;
1163
1697
  function checkCycles(doc, byPath, source) {
1164
1698
  const state = /* @__PURE__ */ new Map();
1165
1699
  const cycles = [];
1700
+ const seen = /* @__PURE__ */ new Set();
1166
1701
  for (const start of doc.tokens) {
1167
1702
  const startKey = formatPath(start.path);
1168
- if (state.get(startKey) !== void 0) continue;
1169
- const chain = [];
1170
- let cursor = startKey;
1171
- while (cursor !== void 0 && (state.get(cursor) ?? UNVISITED) === UNVISITED) {
1172
- state.set(cursor, ON_PATH);
1173
- chain.push(cursor);
1174
- const node = byPath.get(cursor);
1175
- cursor = node === void 0 ? void 0 : targetOf(node);
1703
+ if ((state.get(startKey) ?? UNVISITED) !== UNVISITED) continue;
1704
+ const path = [startKey];
1705
+ const frames = [[...targetsOf(byPath.get(startKey))].reverse()];
1706
+ state.set(startKey, ON_PATH);
1707
+ while (path.length > 0) {
1708
+ const next = frames[frames.length - 1].pop();
1709
+ if (next === void 0) {
1710
+ state.set(path.pop(), SETTLED);
1711
+ frames.pop();
1712
+ continue;
1713
+ }
1714
+ const node = byPath.get(next);
1715
+ if (node === void 0) continue;
1716
+ if (state.get(next) === ON_PATH) {
1717
+ const cycle = path.slice(path.indexOf(next));
1718
+ const key = [...cycle].sort().join("\0");
1719
+ if (!seen.has(key)) {
1720
+ seen.add(key);
1721
+ cycles.push(cycle);
1722
+ }
1723
+ continue;
1724
+ }
1725
+ if (state.get(next) === SETTLED) continue;
1726
+ state.set(next, ON_PATH);
1727
+ path.push(next);
1728
+ frames.push([...targetsOf(node)].reverse());
1176
1729
  }
1177
- if (cursor !== void 0 && state.get(cursor) === ON_PATH) cycles.push(chain.slice(chain.indexOf(cursor)));
1178
- for (const key of chain) state.set(key, SETTLED);
1179
1730
  }
1180
1731
  if (cycles.length > 0) {
1181
1732
  const described = cycles.map((cycle) => ` ${[...cycle, cycle[0]].join(" → ")}`);
@@ -1317,24 +1868,26 @@ async function writeStylesheet(targetPath, contents, source) {
1317
1868
  * divergence the fixed order exists to prevent.
1318
1869
  */
1319
1870
  function convertDocument(raw, source) {
1320
- const doc = normalizeDocument(raw, source);
1871
+ const { doc, skipped } = normalizeDocument(raw, source);
1321
1872
  validateAliasGraph(doc, source);
1322
1873
  validateNoCollisions(doc, source);
1323
1874
  return {
1324
- css: emitStylesheet(doc, source),
1325
- tokenCount: doc.tokens.length
1875
+ css: emitStylesheet(doc, skipped, source),
1876
+ tokenCount: doc.tokens.length,
1877
+ skipped
1326
1878
  };
1327
1879
  }
1328
1880
  async function runConversion(source, options = {}) {
1329
1881
  const display = String(source);
1330
1882
  const baseDir = options.baseDir ?? process.cwd();
1331
1883
  const resolved = resolveSource(source, baseDir);
1332
- const { css, tokenCount } = convertDocument(parseTokenJson(resolved.kind === "url" ? await fetchTokenDocument(resolved.url, display, options.http ?? {}) : await readTokenFile(resolved.path, display), display), display);
1884
+ const { css, tokenCount, skipped } = convertDocument(parseTokenJson(resolved.kind === "url" ? await fetchTokenDocument(resolved.url, display, options.http ?? {}) : await readTokenFile(resolved.path, display), display), display);
1333
1885
  const outputPath = resolveOutputPath(options.outDir ?? DEFAULTS.outDir, options.fileName ?? DEFAULTS.fileName, baseDir);
1334
1886
  await writeStylesheet(outputPath, css, display);
1335
1887
  return {
1336
1888
  outputPath,
1337
- tokenCount
1889
+ tokenCount,
1890
+ skipped
1338
1891
  };
1339
1892
  }
1340
1893
  //#endregion
@@ -1350,13 +1903,19 @@ async function runConversion(source, options = {}) {
1350
1903
  * Convert a design-token document into a CSS custom-properties stylesheet.
1351
1904
  *
1352
1905
  * Reads the Token Source, validates it completely, and writes the stylesheet —
1353
- * or throws a `TokenCssError` and writes nothing at all. There is no partial
1354
- * success: a previous stylesheet at the target path is left untouched whenever
1355
- * the conversion fails.
1906
+ * or throws a `TokenCssError` and writes nothing at all. A failed conversion
1907
+ * writes nothing: a previous stylesheet at the target path is left untouched.
1908
+ *
1909
+ * A token whose value cannot be written as CSS does not fail the conversion
1910
+ * (FR-24). It is left out, listed in `skipped` on the result, and named in a
1911
+ * comment above `:root` in the stylesheet itself. Everything else — an
1912
+ * unreadable source, a document shaped in a way this version does not accept,
1913
+ * an alias cycle, a dangling reference, a name collision — still fails whole.
1356
1914
  *
1357
1915
  * @param source Path to a single local file, or a URL.
1358
1916
  * @param options Output location and network policy.
1359
- * @returns Where the stylesheet was written, and how many properties it holds.
1917
+ * @returns Where the stylesheet was written, how many properties it holds, and
1918
+ * which tokens it left out.
1360
1919
  * @throws {TokenCssError} With a `code` naming the failure class.
1361
1920
  */
1362
1921
  function generateCss(source, options) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokens-to-css",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Convert design-token JSON into a CSS custom-properties stylesheet.",
5
5
  "keywords": [
6
6
  "design-tokens",