@getvitops/generator 8.2.0-alpha.0 → 8.3.0-alpha.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +550 -25
  2. package/assets/css/index.css +7 -0
  3. package/assets/css/patterns/banner.css +20 -0
  4. package/assets/css/patterns/callout.css +39 -0
  5. package/assets/css/patterns/copy.css +5 -0
  6. package/assets/css/patterns/embed.css +39 -0
  7. package/assets/css/patterns/forms.css +19 -0
  8. package/assets/css/patterns/logos.css +45 -0
  9. package/assets/css/patterns/marquee.css +16 -0
  10. package/assets/css/patterns/section.css +53 -0
  11. package/assets/css/patterns/sitenav.css +262 -0
  12. package/assets/css/patterns/splitter.css +11 -0
  13. package/assets/css/patterns/stats.css +61 -0
  14. package/assets/css/patterns/steps.css +98 -0
  15. package/assets/css/patterns/table.css +10 -0
  16. package/assets/css/patterns/tone.css +38 -0
  17. package/assets/js/consent.js +1 -1
  18. package/assets/js/elements.js +5 -5
  19. package/assets/js/polyfills/adoptedStyleSheets.js +1 -1
  20. package/assets/js/polyfills/css-anchor-positioning-fn.js +2 -2
  21. package/assets/js/polyfills/popover.js +2 -2
  22. package/config.schema.json +257 -120
  23. package/dist/config-BfcWKH-5.mjs +1743 -0
  24. package/dist/config-BfcWKH-5.mjs.map +1 -0
  25. package/dist/{config-DvbYx3Gi.mjs → config-Bhg3Xv3D.mjs} +10 -207
  26. package/dist/config-Bhg3Xv3D.mjs.map +1 -0
  27. package/dist/config-D5xuz8--.mjs +1807 -0
  28. package/dist/config-D5xuz8--.mjs.map +1 -0
  29. package/dist/config.d.mts +157 -211
  30. package/dist/config.d.mts.map +1 -1
  31. package/dist/config.mjs +1 -1
  32. package/dist/{icons-sprite-Bf_KmKeX.d.mts → icons-sprite-CQ5Zj4go.d.mts} +4 -6
  33. package/dist/icons-sprite-CQ5Zj4go.d.mts.map +1 -0
  34. package/dist/icons-sprite.d.mts +1 -1
  35. package/dist/icons-sprite.mjs +1 -2
  36. package/dist/index.d.mts +49 -35
  37. package/dist/index.d.mts.map +1 -1
  38. package/dist/index.mjs +607 -75
  39. package/dist/index.mjs.map +1 -1
  40. package/dist/{schema-Dmq1J7S3.d.mts → schema-BV2n2phj.d.mts} +2 -3
  41. package/dist/{schema-Dmq1J7S3.d.mts.map → schema-BV2n2phj.d.mts.map} +1 -1
  42. package/dist/schema-C-P9dF9F.d.mts +337 -0
  43. package/dist/schema-C-P9dF9F.d.mts.map +1 -0
  44. package/dist/{tokens-8SXMDAd1.d.mts → tokens-BzBbTeTi.d.mts} +2 -6
  45. package/dist/tokens-BzBbTeTi.d.mts.map +1 -0
  46. package/dist/{tokens-zi594IZ3.mjs → tokens-C1RJwFeG.mjs} +3 -5
  47. package/dist/{tokens-zi594IZ3.mjs.map → tokens-C1RJwFeG.mjs.map} +1 -1
  48. package/dist/tokens-CaIGQQLk.mjs +740 -0
  49. package/dist/tokens-CaIGQQLk.mjs.map +1 -0
  50. package/dist/tokens-DH5cpvKd.d.mts +242 -0
  51. package/dist/tokens-DH5cpvKd.d.mts.map +1 -0
  52. package/dist/tokens.d.mts +1 -1
  53. package/dist/tokens.mjs +1 -1
  54. package/package.json +6 -5
  55. package/schema.json +95 -5
  56. package/dist/config-DvbYx3Gi.mjs.map +0 -1
  57. package/dist/icons-sprite-Bf_KmKeX.d.mts.map +0 -1
  58. package/dist/tokens-8SXMDAd1.d.mts.map +0 -1
@@ -0,0 +1,740 @@
1
+ import { LIGHTNESS_LADDER, contrastLc, generateOklchPalette, hexToOklch, oklchStringToHex, pickOn, tintedEndpoints } from "@getvitops/utils/color";
2
+
3
+ // src/shared.ts
4
+ /**
5
+ * Data shared between the generator (`generate.ts`) and the docs bundle
6
+ * (`docs.ts`). Lives in its own module because `generate.ts` imports `docs.ts` —
7
+ * importing back the other way would be a cycle.
8
+ */
9
+ /**
10
+ * Selector the dark functional-token flip hangs off.
11
+ *
12
+ * `data-brx-theme` is Bricks' own attribute — Bricks sets it, so it's what the
13
+ * WordPress target needs. Nothing sets it anywhere else, which meant the dark
14
+ * flip was unreachable outside Bricks: the shipped `<wc-color-scheme-toggle>` web
15
+ * component writes `documentElement.dataset.theme` (i.e. `data-theme`), so
16
+ * clicking "Dark" changed an attribute no rule matched. Matching both makes the
17
+ * component work on every target without changing what Bricks already does.
18
+ *
19
+ * This covers the EXPLICIT choice. The OS preference is a second, opt-in block —
20
+ * see `SYSTEM_DARK_SEL` — because turning it on flips a site dark for dark-OS
21
+ * users, which is the site's decision rather than the design system's.
22
+ *
23
+ * Shared so the docs quote the selector the generator actually emits.
24
+ */
25
+ const DARK_SEL = ":root[data-brx-theme=\"dark\"], :root[data-theme=\"dark\"]";
26
+ /**
27
+ * Selector for the OS-preference dark block, emitted inside
28
+ * `@media (prefers-color-scheme: dark)` when a site opts in.
29
+ *
30
+ * Derived from `DARK_SEL`'s attributes rather than written out a third time, and
31
+ * inverted: this must apply when NO explicit choice has been made. That is
32
+ * exactly what `<wc-color-scheme-toggle>`'s "System" position produces — it removes
33
+ * `data-theme` entirely — so with this block present System resolves to the OS
34
+ * instead of silently falling through to light, and needs no JS to do it.
35
+ *
36
+ * The `:not()`s are what keep an explicit *light* choice winning over a dark OS.
37
+ */
38
+ const SYSTEM_DARK_SEL = `:root${DARK_SEL.split(", ").map((s) => s.replace(/^:root\[([\w-]+)="dark"\]$/, "[$1=\"light\"]")).map((attr) => `:not(${attr})`).join("")}`;
39
+ /**
40
+ * Roles the shipped CSS partials reference with NO fallback, so a config that
41
+ * omits one leaves those rules resolving to nothing — an invalid declaration,
42
+ * not a graceful degrade.
43
+ *
44
+ * `colors.roles` is an open record: any name works and generates a full token
45
+ * set, utilities and dark flip. But the framework's own component CSS is
46
+ * written against these six, so "roles are arbitrary" is only half true — they
47
+ * are *extensible*, over a required core. `validate()` warns when one is
48
+ * missing; `required-roles.test.ts` re-derives this list from the partials so
49
+ * it cannot drift as components are added.
50
+ *
51
+ * Kept as a constant rather than scanned at call time because `validate()` is
52
+ * a pure function — the CLI, the Vite plugin and `toJSONSchema` all import it,
53
+ * and none of them should touch the filesystem to answer "is this config ok?".
54
+ */
55
+ const REQUIRED_ROLES = [
56
+ "brand-primary",
57
+ "danger",
58
+ "neutral",
59
+ "surface",
60
+ "ui-primary",
61
+ "warning"
62
+ ];
63
+ /**
64
+ * Every token key a role can resolve to, across both role kinds. Shared so the
65
+ * required-roles drift test greps for exactly what the generator emits.
66
+ *
67
+ * These are *keys*, not var names: the role sits in the middle of the property
68
+ * (`--color-bg-danger-muted`), so compose them with `tokenVar()` from `tokens.ts`
69
+ * rather than concatenating by hand.
70
+ */
71
+ const ROLE_TOKEN_KEYS = [
72
+ "bg",
73
+ "bg-muted",
74
+ "bg-x-muted",
75
+ "bg-bold",
76
+ "bg-x-bold",
77
+ "bg-solid",
78
+ "bg-solid-bold",
79
+ "bg-solid-x-bold",
80
+ "text",
81
+ "text-bold",
82
+ "text-muted",
83
+ "text-x-muted",
84
+ "text-xx-muted",
85
+ "text-on",
86
+ "text-on-bold",
87
+ "icon",
88
+ "icon-muted",
89
+ "border",
90
+ "border-muted",
91
+ "border-bold"
92
+ ];
93
+ /** The solid step / foreground polarity a role asked for (both optional). */
94
+ const roleSolidOptions = (spec) => typeof spec === "string" ? {} : {
95
+ ...spec.solid != null ? { solid: String(spec.solid) } : {},
96
+ ...spec.foreground ? { foreground: spec.foreground } : {}
97
+ };
98
+ /** The palette hue a role points at, through either authoring form. */
99
+ const roleHue = (spec) => typeof spec === "string" ? spec : spec.hue;
100
+ /**
101
+ * A role's kind. The bare-string shorthand means `chromatic`, because signal
102
+ * colours are the common case and surfaces are the two or three you name once.
103
+ */
104
+ const roleKind = (spec) => typeof spec === "string" ? "chromatic" : spec.kind ?? "chromatic";
105
+ /**
106
+ * Pattern base geometry props → per-pattern override-hook shorthand. Each hook
107
+ * wraps the authored value (`padding: var(--p-btn, 0.4em 0.8em)`) so a
108
+ * consumer restyles one pattern by setting one variable. The hook is named after
109
+ * the pattern's key, not its class.
110
+ */
111
+ const BASE_HOOK = {
112
+ padding: "p",
113
+ "border-radius": "br",
114
+ border: "b",
115
+ "box-shadow": "ds",
116
+ "font-size": "fs",
117
+ background: "bg",
118
+ "background-color": "bg"
119
+ };
120
+ /**
121
+ * A typography role's declared key → [CSS property, var-name suffix]. The
122
+ * recognised key set is CLOSED (an unrecognised key is a config error, see
123
+ * `generate.ts`'s `ALSO_MEANT`) — shared here (rather than living only in
124
+ * `generate.ts`) because `tokens.ts`'s `buildReverseIndex` needs the exact
125
+ * same key → suffix mapping to stay in step with what `generate()` itself
126
+ * builds, and `tokens.ts` can't import from `generate.ts` (the CSS-bundling
127
+ * half, which needs lightningcss/node:fs) without losing its own
128
+ * Worker-safety.
129
+ */
130
+ const TYPO_KEYMAP = {
131
+ family: ["font-family", "ff"],
132
+ size: ["font-size", "fs"],
133
+ weight: ["font-weight", "fw"],
134
+ style: ["font-style", "fst"],
135
+ "line-height": ["line-height", "lh"],
136
+ tracking: ["letter-spacing", "ls"],
137
+ "text-decoration": ["text-decoration", "td"],
138
+ "text-transform": ["text-transform", "tt"],
139
+ "text-wrap": ["text-wrap", "tw"],
140
+ color: ["color", "color"]
141
+ };
142
+ /**
143
+ * Framework utility classes whose names collide with Tailwind's own defaults.
144
+ * The `tailwind` format strips any rule targeting these — Tailwind provides
145
+ * them, so consumers use Tailwind's utilities for these names instead.
146
+ */
147
+ const TW_CLASH = /* @__PURE__ */ new Set([
148
+ "block",
149
+ "inline",
150
+ "inline-block",
151
+ "flow-root",
152
+ "flex",
153
+ "inline-flex",
154
+ "grid",
155
+ "inline-grid",
156
+ "table",
157
+ "inline-table",
158
+ "table-caption",
159
+ "table-cell",
160
+ "table-row",
161
+ "contents",
162
+ "hidden",
163
+ "list-item",
164
+ "static",
165
+ "fixed",
166
+ "absolute",
167
+ "relative",
168
+ "sticky",
169
+ "isolate",
170
+ "visible",
171
+ "invisible",
172
+ "collapse",
173
+ "sr-only",
174
+ "not-sr-only",
175
+ "flex-row",
176
+ "flex-row-reverse",
177
+ "flex-col",
178
+ "flex-col-reverse",
179
+ "flex-wrap",
180
+ "flex-nowrap",
181
+ "items-start",
182
+ "items-end",
183
+ "items-center",
184
+ "items-baseline",
185
+ "items-stretch",
186
+ "justify-start",
187
+ "justify-end",
188
+ "justify-center",
189
+ "justify-between",
190
+ "justify-around",
191
+ "justify-evenly",
192
+ "content-start",
193
+ "content-end",
194
+ "content-center",
195
+ "content-between",
196
+ "content-around",
197
+ "content-evenly",
198
+ "text-left",
199
+ "text-center",
200
+ "text-right",
201
+ "text-justify",
202
+ "text-start",
203
+ "text-end",
204
+ "text-wrap",
205
+ "text-nowrap",
206
+ "text-balance",
207
+ "text-pretty",
208
+ "rounded-none",
209
+ "rounded-full"
210
+ ]);
211
+ /**
212
+ * `layout` config key → the custom property it sets. The one table both the
213
+ * emitters (css/bricks/tailwind) and the live-editor manifest's `reverseIndex`
214
+ * read, so a token that is emitted is always one the editor can write back.
215
+ */
216
+ const LAYOUT_TOKENS = {
217
+ "widths.measure": "--width-measure",
218
+ "widths.breakout": "--width-breakout",
219
+ "widths.spotlight": "--width-spotlight",
220
+ "widths.nav": "--width-nav",
221
+ "regionSpace.min": "--region-space-min",
222
+ "regionSpace.preferred": "--region-space",
223
+ "regionSpace.max": "--region-space-max"
224
+ };
225
+ /**
226
+ * The `:root` declarations for an authored `layout` block (only what was set —
227
+ * an absent key leaves the framework's own default in force), keyed by the
228
+ * custom property.
229
+ */
230
+ function layoutDecls(layout) {
231
+ const out = {};
232
+ for (const [path, token] of Object.entries(LAYOUT_TOKENS)) {
233
+ const [group, key] = path.split(".");
234
+ const value = layout?.[group]?.[key];
235
+ if (value != null) out[token] = value;
236
+ }
237
+ return out;
238
+ }
239
+
240
+ // src/tokens.ts
241
+ /**
242
+ * Palette expansion + semantic colour tokens.
243
+ *
244
+ * A palette hue is authored one of two ways:
245
+ * • `{ seed, anchors? }` — an 11-step numeric scale (50…950) generated in
246
+ * OKLCH via @getvitops/utils/color. Lightness comes from the shared
247
+ * `LIGHTNESS_LADDER`; the seed (and any anchors) are reproduced verbatim at
248
+ * their steps.
249
+ * • `{ tones }` — a fixed brand kit: authored tones placed verbatim at their
250
+ * nearest steps + tinted endpoints; nothing else is invented (strict kits).
251
+ *
252
+ * **The grammar.** Every role gets tokens named `--color-<target>-<role>[-<variant>]`,
253
+ * where target ∈ bg | text | icon | border. Putting the target *inside* the name is
254
+ * the point: `--color-bg-danger-muted` and `--color-text-danger-muted` are distinct
255
+ * tokens, so the two axes that used to collide over one `<family>-<role>-<modifier>
256
+ * ` namespace cannot any more. The utility class is the token name minus `--color-`.
257
+ *
258
+ * Variants are ordinal — xx-muted < x-muted < muted < (bare) < bold < x-bold —
259
+ * and the tables are **sparse**: a target only emits the cells that are actually
260
+ * viable. `bold` means "more emphatic than the role's default in the current
261
+ * appearance", not "darker"; the appearance decides polarity.
262
+ *
263
+ * Roles come in two kinds (declared in `colors.roles`):
264
+ * • `surface` — a page/panel colour. Has a bare `bg-<role>` plus the full
265
+ * emphasis range and the complete text scale.
266
+ * • `chromatic` (default) — a signal colour. Backgrounds split into *tints*
267
+ * (`bg-<role>-x-muted` / `-muted`) and *solids* (`bg-<role>-solid[-bold|-x-bold]`);
268
+ * there is deliberately **no bare `bg-<role>`**, because "how loud?" is a
269
+ * question the author has to answer.
270
+ *
271
+ * Dark mode re-points which step each token reads; the solid family and the
272
+ * `text-on-<role>` foreground computed against it stay mode-stable, so a filled
273
+ * button keeps its identity across appearances.
274
+ */
275
+ const NUMERIC_STEPS = [
276
+ "50",
277
+ "100",
278
+ "200",
279
+ "300",
280
+ "400",
281
+ "500",
282
+ "600",
283
+ "700",
284
+ "800",
285
+ "900",
286
+ "950"
287
+ ];
288
+ /**
289
+ * Lightness of each numeric step. Re-exported from the engine's ladder rather
290
+ * than restated — there used to be three copies of this curve (here, the engine,
291
+ * and the linter) and they were free to drift.
292
+ */
293
+ const STEP_L = Object.fromEntries(NUMERIC_STEPS.map((s) => [s, LIGHTNESS_LADDER[Number(s)]]));
294
+ const isHex = (v) => v.startsWith("#");
295
+ const toOklchStr = (v) => {
296
+ if (!isHex(v)) return v;
297
+ const { l, c, h } = hexToOklch(v);
298
+ return `oklch(${(l * 100).toFixed(2)}% ${c.toFixed(4)} ${h.toFixed(2)})`;
299
+ };
300
+ const nearestStep = (l) => NUMERIC_STEPS.reduce((best, s) => Math.abs(l - STEP_L[s]) < Math.abs(l - STEP_L[best]) ? s : best);
301
+ /**
302
+ * Claim `step` for `value`, refusing to overwrite a step another colour already
303
+ * took.
304
+ *
305
+ * Silently dropping one of two colliding tones is the worst outcome: the palette
306
+ * still builds, and the tone the author cared about is simply gone. The record
307
+ * form (`tones: { "600": "#…", "700": "#…" }`) is the escape hatch, so the error
308
+ * points at it.
309
+ */
310
+ function claim(into, step, value, hueLabel, what) {
311
+ const taken = into[step];
312
+ if (taken != null && taken !== value) throw new Error(`palette hue "${hueLabel}": ${what} ${value} and ${taken} both map to step ${step}. Key them explicitly, e.g. { "${step}": "${taken}", "${Number(step) + 100}": "${value}" }.`);
313
+ into[step] = value;
314
+ }
315
+ function expandSeeded(input, hueLabel) {
316
+ const anchors = {};
317
+ for (const [key, value] of Object.entries(input.anchors ?? {})) claim(anchors, NUMERIC_STEPS.includes(key) ? key : nearestStep(hexToOklch(value).l), toOklchStr(value), hueLabel, "anchors");
318
+ const seedStep = nearestStep(hexToOklch(input.seed).l);
319
+ anchors[seedStep] ??= toOklchStr(input.seed);
320
+ const scale = generateOklchPalette(anchors);
321
+ const numeric = {};
322
+ for (const s of NUMERIC_STEPS) if (scale[s] != null) numeric[s] = oklchStringToHex(scale[s]);
323
+ return {
324
+ numeric,
325
+ deviations: deviationsFor(anchors)
326
+ };
327
+ }
328
+ function expandTones(input, hueLabel) {
329
+ const entries = Array.isArray(input.tones) ? input.tones.map((hex) => [null, hex]) : Object.entries(input.tones).map(([k, hex]) => [k, hex]);
330
+ const placed = {};
331
+ for (const [key, hex] of entries) claim(placed, key != null && NUMERIC_STEPS.includes(key) ? key : nearestStep(hexToOklch(hex).l), hex, hueLabel, "tones");
332
+ const numeric = {};
333
+ for (const [step, hex] of Object.entries(placed)) numeric[step] = hex;
334
+ const firstTone = entries[0]?.[1] ?? "#808080";
335
+ const ends = tintedEndpoints(hexToOklch(firstTone).h);
336
+ numeric["50"] ??= ends.light;
337
+ numeric["950"] ??= ends.dark;
338
+ return {
339
+ numeric,
340
+ sparse: true,
341
+ deviations: deviationsFor(placed)
342
+ };
343
+ }
344
+ /** How far each pinned colour sits from its step's ladder lightness. */
345
+ function deviationsFor(pinned) {
346
+ const out = {};
347
+ for (const [step, value] of Object.entries(pinned)) out[step] = (isHex(value) ? hexToOklch(value).l : hexToOklch(oklchStringToHex(value)).l) - STEP_L[step];
348
+ return out;
349
+ }
350
+ /** Snap to the nearest AVAILABLE numeric step (sparse kits). */
351
+ function snap(numeric, want) {
352
+ if (numeric[want] != null) return want;
353
+ return NUMERIC_STEPS.filter((s) => numeric[s] != null).reduce((best, s) => Math.abs(STEP_L[s] - STEP_L[want]) < Math.abs(STEP_L[best] - STEP_L[want]) ? s : best);
354
+ }
355
+ /** Expand every palette hue into its numeric scale. */
356
+ function expandPalette(raw) {
357
+ const out = {};
358
+ for (const [name, entry] of Object.entries(raw)) {
359
+ const e = entry;
360
+ if (typeof e?.seed === "string") out[name] = expandSeeded(e, name);
361
+ else if (e?.tones != null) out[name] = expandTones(e, name);
362
+ else throw new Error(`palette hue "${name}" must have a "seed" or "tones"`);
363
+ }
364
+ return out;
365
+ }
366
+ /**
367
+ * Warn about pinned colours that sit noticeably off the ladder. Not an error:
368
+ * the author asked for that exact colour and gets it. But at ~0.03 L the hue
369
+ * starts reading visibly heavier or lighter than its siblings at that step, and
370
+ * that is the class of drift the fixed ladder exists to prevent.
371
+ */
372
+ const LADDER_TOLERANCE = .03;
373
+ function ladderWarnings(palette, authored = {}) {
374
+ const warnings = [];
375
+ for (const [hue, expanded] of Object.entries(palette)) {
376
+ if (authored[hue]?.acknowledgeOffLadder) continue;
377
+ for (const [step, deviation] of Object.entries(expanded.deviations ?? {})) {
378
+ if (Math.abs(deviation) <= LADDER_TOLERANCE) continue;
379
+ const dir = deviation > 0 ? "lighter" : "darker";
380
+ warnings.push(`colors.palette.${hue}: the colour pinned at step ${step} is ${Math.abs(deviation).toFixed(3)} ${dir} than the shared lightness ladder — this hue will read ${dir} than others at ${step}. Legal, but move it to a neighbouring step if that wasn't intended (or set \`acknowledgeOffLadder: true\` on the hue to silence this).`);
381
+ }
382
+ }
383
+ return warnings;
384
+ }
385
+ /**
386
+ * Gamut mapping nudges lightness by a hair, so an exact `<` comparison would
387
+ * report rounding as an inversion. Well below any deviation a person could see.
388
+ */
389
+ const MONOTONIC_EPSILON = .001;
390
+ /**
391
+ * Ramps whose lightness does not fall monotonically from 50 to 950.
392
+ *
393
+ * A ramp built from the ladder alone cannot invert — only pinned colours (a seed,
394
+ * an `anchor`, a `tones` entry) can, because they are the one thing allowed off
395
+ * it. `ladderWarnings` will not catch it: it compares each pinned step against
396
+ * *its own* ladder rung, never against its neighbours, so it reports the wrong
397
+ * defect ("reads darker than others at 600") while the real problem is that 600
398
+ * is darker than 700. And since `LADDER_TOLERANCE` is 0.03 — the same size as the
399
+ * ladder's own 50→100 gap — two anchors can each sit within tolerance and still
400
+ * invert, with no warning at all.
401
+ *
402
+ * This is an error rather than a warning because the rest of the system assumes
403
+ * the order: `snap` picks the nearest step by ladder lightness, the mode-stable
404
+ * solid family hard-codes 600/700/800 as increasingly dark, and both dark tables
405
+ * re-point steps on the same assumption. An inverted ramp therefore runs hover
406
+ * states backwards — the failure the fixed ladder was introduced to eliminate.
407
+ */
408
+ function monotonicityErrors(palette) {
409
+ const errors = [];
410
+ for (const [hue, expanded] of Object.entries(palette)) {
411
+ const numeric = expanded.numeric ?? {};
412
+ const steps = NUMERIC_STEPS.filter((s) => numeric[s] != null);
413
+ for (let i = 1; i < steps.length; i++) {
414
+ const [prev, step] = [steps[i - 1], steps[i]];
415
+ const lPrev = hexToOklch(numeric[prev]).l;
416
+ const lStep = hexToOklch(numeric[step]).l;
417
+ if (lStep < lPrev + MONOTONIC_EPSILON) continue;
418
+ const pinned = expanded.deviations ?? {};
419
+ const culprits = [prev, step].filter((s) => pinned[s] != null);
420
+ const fix = culprits.length ? culprits.map((s) => {
421
+ const l = hexToOklch(numeric[s]).l;
422
+ return `${numeric[s]} (pinned at ${s}) sits at L ${l.toFixed(3)}, nearest step ${nearestStep(l)}`;
423
+ }).join("; ") : `no step here is pinned, so this is a bug in the generator rather than in your config`;
424
+ errors.push(`colors.palette.${hue}: step ${step} (${numeric[step]}, L ${lStep.toFixed(3)}) is LIGHTER than step ${prev} (${numeric[prev]}, L ${lPrev.toFixed(3)}). A ramp must darken from 50 to 950 — inverted, a hover from \`bg-<role>-solid\` to \`-solid-bold\` gets lighter, and the dark-mode tables re-point steps assuming the order. ${fix}.`);
425
+ }
426
+ }
427
+ return errors;
428
+ }
429
+ /**
430
+ * Surface roles: a bare `bg` (cards, panels, inputs), `bg-muted` for the page
431
+ * behind them, `bg-x-muted` for wells, and `bg-bold`/`-x-bold` for the inverse
432
+ * surface a tooltip sits on. Elevation is expressed by *which* token you reach
433
+ * for — page `bg-muted`, card `bg` — rather than by a raised/sunken pair. That
434
+ * is what lets a future `data-surfaces` axis flatten it without touching markup.
435
+ */
436
+ const SURFACE_LIGHT = {
437
+ bg: "50",
438
+ "bg-muted": "100",
439
+ "bg-x-muted": "200",
440
+ "bg-bold": "800",
441
+ "bg-x-bold": "950",
442
+ text: "900",
443
+ "text-bold": "950",
444
+ "text-muted": "700",
445
+ "text-x-muted": "500",
446
+ "text-xx-muted": "400",
447
+ "text-on-bold": "50",
448
+ icon: "600",
449
+ "icon-muted": "500",
450
+ "border-muted": "200",
451
+ border: "300",
452
+ "border-bold": "600"
453
+ };
454
+ const SURFACE_DARK = {
455
+ bg: "900",
456
+ "bg-muted": "950",
457
+ "bg-x-muted": "950",
458
+ "bg-bold": "200",
459
+ "bg-x-bold": "50",
460
+ text: "100",
461
+ "text-bold": "50",
462
+ "text-muted": "300",
463
+ "text-x-muted": "500",
464
+ "text-xx-muted": "600",
465
+ "text-on-bold": "950",
466
+ icon: "400",
467
+ "icon-muted": "500",
468
+ "border-muted": "800",
469
+ border: "700",
470
+ "border-bold": "400"
471
+ };
472
+ /**
473
+ * Chromatic roles: tints for alert/badge backgrounds, solids for fills. No bare
474
+ * `bg` — see the module docblock. No `text-muted` either: a de-emphasised
475
+ * coloured text could not hold its contrast target off a white surface, so the
476
+ * way to soften coloured text is weight/size, not another colour.
477
+ */
478
+ const CHROMATIC_LIGHT = {
479
+ "bg-x-muted": "50",
480
+ "bg-muted": "100",
481
+ text: "700",
482
+ "text-bold": "900",
483
+ icon: "600",
484
+ border: "200",
485
+ "border-bold": "300"
486
+ };
487
+ const CHROMATIC_DARK = {
488
+ "bg-x-muted": "950",
489
+ "bg-muted": "900",
490
+ text: "300",
491
+ "text-bold": "200",
492
+ icon: "400",
493
+ border: "800",
494
+ "border-bold": "700"
495
+ };
496
+ /**
497
+ * The solid family — mode-stable, so a filled button keeps its identity when the
498
+ * appearance flips. Fixed steps rather than the old scan-and-clamp: with a shared
499
+ * lightness ladder every hue's 600 already sits at the same lightness, so there
500
+ * is nothing left to search for. `text-on-<role>` is computed against `bg-solid`.
501
+ */
502
+ const SOLID = {
503
+ "bg-solid": "600",
504
+ "bg-solid-bold": "700",
505
+ "bg-solid-x-bold": "800"
506
+ };
507
+ /**
508
+ * Map a token key to its CSS custom property for a given role.
509
+ *
510
+ * `on` is the one irregular case: the foreground for a filled surface reads
511
+ * `--color-text-on-<role>`, with the role *after* `on`, because it names what it
512
+ * sits on rather than what it is.
513
+ *
514
+ * The utility class name is this minus the `--color-` prefix, which is why the
515
+ * emitter and the linter can both derive their vocabulary from one function.
516
+ */
517
+ function tokenVar(role, key) {
518
+ const dash = key.indexOf("-");
519
+ const target = dash === -1 ? key : key.slice(0, dash);
520
+ const variant = dash === -1 ? "" : key.slice(dash + 1);
521
+ if (variant === "on") return `--color-${target}-on-${role}`;
522
+ if (variant.startsWith("on-")) return `--color-${target}-on-${role}-${variant.slice(3)}`;
523
+ return variant ? `--color-${target}-${role}-${variant}` : `--color-${target}-${role}`;
524
+ }
525
+ /** The class name a token key produces for a role (`bg-danger-solid`, …). */
526
+ const tokenClass = (role, key) => tokenVar(role, key).slice(8);
527
+ /** Resolve the token set for one role over its hue. */
528
+ function functionalRole(role, hueName, hue, kind = "chromatic", opts = {}) {
529
+ const numeric = hue.numeric;
530
+ const v = (n) => `var(--color-${hueName}-${snap(numeric, n)})`;
531
+ const hex = (n) => numeric[snap(numeric, n)];
532
+ const map = (table) => {
533
+ const out = {};
534
+ for (const [token, step] of Object.entries(table)) out[token] = v(step);
535
+ return out;
536
+ };
537
+ const solidStep = opts.solid ?? SOLID["bg-solid"];
538
+ const at = NUMERIC_STEPS.indexOf(solidStep);
539
+ const stepAt = (i) => NUMERIC_STEPS[Math.min(i, NUMERIC_STEPS.length - 1)];
540
+ const solids = map({
541
+ "bg-solid": solidStep,
542
+ "bg-solid-bold": stepAt(at + 1),
543
+ "bg-solid-x-bold": stepAt(at + 2)
544
+ });
545
+ const onSolid = pickOn(hex(solidStep), void 0, opts.foreground);
546
+ return {
547
+ role,
548
+ hue: hueName,
549
+ kind,
550
+ light: {
551
+ ...map(kind === "surface" ? SURFACE_LIGHT : CHROMATIC_LIGHT),
552
+ ...solids,
553
+ "text-on": onSolid
554
+ },
555
+ dark: {
556
+ ...map(kind === "surface" ? SURFACE_DARK : CHROMATIC_DARK),
557
+ ...solids,
558
+ "text-on": onSolid
559
+ }
560
+ };
561
+ }
562
+ /**
563
+ * APCA targets. `TEXT` is the body-text bar; `SECONDARY` covers text on a
564
+ * non-primary surface and de-emphasised text. `NON_TEXT` is the icon/boundary
565
+ * tier — the APCA analogue of WCAG's 3:1, where a glyph may run more vivid than
566
+ * text because shape carries meaning alongside contrast.
567
+ */
568
+ const CONTRAST = {
569
+ TEXT: 75,
570
+ SECONDARY: 60,
571
+ NON_TEXT: 45
572
+ };
573
+ /**
574
+ * Text tokens deliberately exempt from the contract. Placeholders and disabled
575
+ * text are required to look unavailable; holding them to the body-text bar would
576
+ * defeat the affordance. Nothing else may be exempt.
577
+ */
578
+ const EXEMPT_TEXT = /* @__PURE__ */ new Set(["text-x-muted", "text-xx-muted"]);
579
+ /**
580
+ * Verify a role's contrast targets. Returns human-readable failures.
581
+ *
582
+ * Chromatic roles are checked against the surface planes they actually sit on
583
+ * (`surfaceBg`) as well as their own tints — coloured text almost never appears
584
+ * over its own solid, it appears over the page. Checking only within the role
585
+ * would have missed the pairing that matters most.
586
+ */
587
+ function checkContrast(fr, numericOf, surfaceBg, appearances = ["light", "dark"]) {
588
+ const failures = [];
589
+ const resolve = (val) => {
590
+ const m = /^var\(--color-([a-z0-9-]+)-(\d+)\)$/.exec(val);
591
+ if (!m) return val;
592
+ return numericOf(m[1])[m[2]] ?? val;
593
+ };
594
+ const fail = (appearance, msg) => failures.push(`${fr.role}/${appearance}: ${msg}`);
595
+ for (const [appearance, tokens] of [["light", fr.light], ["dark", fr.dark]]) {
596
+ if (!appearances.includes(appearance)) continue;
597
+ const ownBg = Object.keys(tokens).filter((k) => k === "bg" || k.startsWith("bg-")).filter((k) => !k.startsWith("bg-solid")).filter((k) => !k.startsWith("bg-bold") && !k.startsWith("bg-x-bold"));
598
+ const planes = fr.kind === "surface" ? ownBg.map((k) => [k, resolve(tokens[k])]) : [...(surfaceBg?.[appearance] ?? []).map((h, i) => [`surface#${i}`, h]), ...ownBg.map((k) => [k, resolve(tokens[k])])];
599
+ for (const [planeName, bg] of planes) {
600
+ const textMin = planeName === "bg" ? CONTRAST.TEXT : CONTRAST.SECONDARY;
601
+ for (const key of [
602
+ "text",
603
+ "text-bold",
604
+ "text-muted"
605
+ ]) {
606
+ if (tokens[key] == null || EXEMPT_TEXT.has(key)) continue;
607
+ const min = key === "text-muted" ? CONTRAST.SECONDARY : textMin;
608
+ const lc = contrastLc(resolve(tokens[key]), bg);
609
+ if (lc < min) fail(appearance, `${key} Lc ${lc.toFixed(1)} < ${min} on ${planeName} ${bg}`);
610
+ }
611
+ const nonText = fr.kind === "surface" ? ["icon", "border-bold"] : ["icon"];
612
+ for (const key of nonText) {
613
+ if (tokens[key] == null) continue;
614
+ const lc = contrastLc(resolve(tokens[key]), bg);
615
+ if (lc < CONTRAST.NON_TEXT) fail(appearance, `${key} Lc ${lc.toFixed(1)} < ${CONTRAST.NON_TEXT} on ${planeName} ${bg}`);
616
+ }
617
+ }
618
+ const solid = resolve(tokens["bg-solid"]);
619
+ const onLc = contrastLc(tokens["text-on"], solid);
620
+ if (onLc < CONTRAST.SECONDARY) fail(appearance, `text-on Lc ${onLc.toFixed(1)} < ${CONTRAST.SECONDARY} on bg-solid`);
621
+ if (tokens["bg-bold"] != null && tokens["text-on-bold"] != null) {
622
+ const lc = contrastLc(resolve(tokens["text-on-bold"]), resolve(tokens["bg-bold"]));
623
+ if (lc < CONTRAST.TEXT) fail(appearance, `text-on-bold Lc ${lc.toFixed(1)} < ${CONTRAST.TEXT} on bg-bold`);
624
+ }
625
+ }
626
+ return failures;
627
+ }
628
+ /**
629
+ * Runs the same monotonicity + contrast contract `generate()`'s `build()`
630
+ * enforces at build time (by throwing), standalone and non-throwing — for a
631
+ * caller that needs to validate a `colors` subtree *before* handing it to
632
+ * `generate()`, most notably the EmDash design-system-editor's save route
633
+ * (see AGENTS.md's "live theme editor" section): a client's edit gets the
634
+ * exact same accessibility gate a real build would apply, synchronously, at
635
+ * save time, rather than only discovering a violation at the next deploy.
636
+ *
637
+ * Deliberately re-derives `expandPalette`/`functionalRole` independently
638
+ * rather than sharing state with `build()` — both are cheap, pure
639
+ * computations, and keeping this standalone means it has no dependency on
640
+ * `generate.ts`'s CSS-bundling half (which needs lightningcss/node:fs) and so
641
+ * stays reachable from a Worker.
642
+ */
643
+ function validateColorContract(colors) {
644
+ const expandedPalette = expandPalette(colors.palette);
645
+ const scaleRoles = [];
646
+ for (const [role, spec] of Object.entries(colors.roles)) {
647
+ const hueName = roleHue(spec);
648
+ const hue = expandedPalette[hueName];
649
+ if (hue == null) return [`role "${role}" references unknown palette hue "${hueName}"`];
650
+ scaleRoles.push(functionalRole(role, hueName, hue, roleKind(spec)));
651
+ }
652
+ const monotonicity = monotonicityErrors(expandedPalette);
653
+ if (monotonicity.length) return monotonicity;
654
+ const surfaceRole = scaleRoles.find((r) => r.role === "surface" && "bg" in r.light);
655
+ const numericOf = (hueName) => expandedPalette[hueName]?.numeric ?? {};
656
+ const resolve = (val) => {
657
+ const m = /^var\(--color-([a-z0-9-]+)-(\d+)\)$/.exec(val);
658
+ return m ? numericOf(m[1])[m[2]] ?? val : val;
659
+ };
660
+ const planeKeys = [
661
+ "bg",
662
+ "bg-muted",
663
+ "bg-x-muted"
664
+ ];
665
+ const surfaceBg = surfaceRole ? {
666
+ light: planeKeys.flatMap((k) => surfaceRole.light[k] ? [resolve(surfaceRole.light[k])] : []),
667
+ dark: planeKeys.flatMap((k) => surfaceRole.dark[k] ? [resolve(surfaceRole.dark[k])] : [])
668
+ } : void 0;
669
+ return scaleRoles.flatMap((fr) => checkContrast(fr, numericOf, surfaceBg));
670
+ }
671
+ /**
672
+ * The `reverseIndex` half of `generate()`'s `design-manifest.json` (css
673
+ * format), standalone — a CSS var name → the config path that produced it
674
+ * (e.g. `--color-navy-500` → `colors.palette.navy.anchors.500`). Kept in exact
675
+ * step with the block inside `generate.ts`'s `build()` that builds the same
676
+ * map (both read `TYPO_KEYMAP`/`BASE_HOOK` from `shared.ts`, never duplicated
677
+ * inline) — this is the standalone half for a caller that has a
678
+ * `DesignSystem` but no built `design-manifest.json` to fetch, most notably
679
+ * the EmDash design-system-editor plugin route: a deployed Worker has no
680
+ * filesystem to read a manifest asset from over HTTP without adding a
681
+ * `network:request` capability + a hostname declared in `allowedHosts` for
682
+ * *the site's own, deployment-specific* origin — recomputing this from the
683
+ * `DesignSystem` the site's own build already bundles avoids that entirely.
684
+ */
685
+ function buildReverseIndex(ds) {
686
+ const expandedPalette = expandPalette(ds.colors.palette);
687
+ const reverseIndex = {};
688
+ for (const [name, hue] of Object.entries(expandedPalette)) for (const n of Object.keys(hue.numeric)) reverseIndex[`--color-${name}-${n}`] = `colors.palette.${name}.anchors.${n}`;
689
+ const typoRoles = ds.typography?.roles ?? {};
690
+ for (const role of Object.keys(typoRoles)) for (const [key, [, sfx]] of Object.entries(TYPO_KEYMAP)) reverseIndex[`--${role}-${sfx}`] = `typography.roles.${role}.${key}`;
691
+ for (const [path, token] of Object.entries(LAYOUT_TOKENS)) reverseIndex[token] = `layout.${path}`;
692
+ const pDefaults = ds.patterns?.defaults ?? {};
693
+ const pRadii = ds.patterns?.radii ?? {};
694
+ const pGroups = ds.patterns?.groups ?? {};
695
+ const pItems = ds.patterns?.items ?? {};
696
+ for (const prop of Object.keys(pDefaults)) reverseIndex[`--${prop}-default`] = `patterns.defaults.${prop}`;
697
+ for (const [group, props] of Object.entries(pGroups)) for (const prop of Object.keys(props)) reverseIndex[`--${prop}-${group}`] = `patterns.groups.${group}.${prop}`;
698
+ for (const [name, item] of Object.entries(pItems)) {
699
+ const base = item.base ?? {};
700
+ for (const prop of Object.keys(base)) if (prop in BASE_HOOK) reverseIndex[`--${BASE_HOOK[prop]}-${name}`] = `patterns.items.${name}.base.${prop}`;
701
+ }
702
+ for (const name of Object.keys(pRadii)) if (!(`--br-${name}` in reverseIndex)) reverseIndex[`--br-${name}`] = `patterns.radii.${name}`;
703
+ return reverseIndex;
704
+ }
705
+ /**
706
+ * Turns a design-system PATCH (paths matching `manifest.reverseIndex`'s own
707
+ * config-path values, e.g. `colors.palette.navy.anchors.500`) into the runtime
708
+ * `:root` override `<wc-theme-editor>` already injects client-side for its own
709
+ * live preview — server-rendered instead, for the EmDash design-system-editor
710
+ * plugin route to serve site-wide via a `page:fragments` `html` contribution
711
+ * (see AGENTS.md's "live theme editor" + "EmDash plugin as the design-system
712
+ * editing surface" sections). `''` (no matching var, or an empty patch) means
713
+ * "nothing to override" — the caller should serve no fragment at all rather
714
+ * than an empty `<style>` tag.
715
+ *
716
+ * Inverts `reverseIndex` (`cssVar → configPath`) once into `configPath →
717
+ * cssVar`, so it costs one pass regardless of how deep the patch nests. A
718
+ * patched leaf with no matching var (a `reverseIndex` miss — the same case
719
+ * `<wc-theme-editor>`'s own `buildPatch()` calls "skipped") is silently
720
+ * omitted here too, for the same reason: a value with nothing to preview is
721
+ * not an error, just nothing to show yet.
722
+ */
723
+ function patchToOverrideCss(patch, reverseIndex) {
724
+ const byPath = /* @__PURE__ */ new Map();
725
+ for (const [cssVar, path] of Object.entries(reverseIndex)) byPath.set(path, cssVar);
726
+ const lines = [];
727
+ const walk = (node, path) => {
728
+ if (node == null || typeof node !== "object") {
729
+ const cssVar = byPath.get(path);
730
+ if (cssVar && (typeof node === "string" || typeof node === "number")) lines.push(` ${cssVar}: ${node};`);
731
+ return;
732
+ }
733
+ for (const [key, value] of Object.entries(node)) walk(value, path ? `${path}.${key}` : key);
734
+ };
735
+ walk(patch, "");
736
+ return lines.length ? `:root {\n${lines.join("\n")}\n}` : "";
737
+ }
738
+
739
+ export { roleKind as C, roleHue as S, ROLE_TOKEN_KEYS as _, expandPalette as a, TYPO_KEYMAP as b, monotonicityErrors as c, tokenVar as d, validateColorContract as f, REQUIRED_ROLES as g, LAYOUT_TOKENS as h, checkContrast as i, patchToOverrideCss as l, DARK_SEL as m, NUMERIC_STEPS as n, functionalRole as o, BASE_HOOK as p, buildReverseIndex as r, ladderWarnings as s, CONTRAST as t, tokenClass as u, SYSTEM_DARK_SEL as v, roleSolidOptions as w, layoutDecls as x, TW_CLASH as y };
740
+ //# sourceMappingURL=tokens-CaIGQQLk.mjs.map