@getvitops/generator 8.3.0-alpha.0 → 8.3.0-alpha.2

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