@getvitops/generator 0.1.0 → 0.2.1

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/dist/index.mjs CHANGED
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
4
4
  import { createHash } from "node:crypto";
5
5
  import { transform } from "lightningcss";
6
6
  import * as z from "zod/mini";
7
+ import { generateOklchPalette, hexToOklch, oklchStringToHex, pickOn } from "@getvitops/utils/color";
7
8
 
8
9
  //#region src/schema.ts
9
10
  /**
@@ -17,26 +18,12 @@ import * as z from "zod/mini";
17
18
  const CssDecls = z.record(z.string(), z.string());
18
19
  const Scalar = z.union([z.string(), z.number()]);
19
20
  const VarMap = z.record(z.string(), Scalar);
20
- const Ramp = z.object({
21
- xxd: z.optional(z.string()),
22
- xd: z.optional(z.string()),
23
- d: z.optional(z.string()),
24
- base: z.optional(z.string()),
25
- l: z.optional(z.string()),
26
- xl: z.optional(z.string()),
27
- xxl: z.optional(z.string())
28
- });
29
- const RoleSpec = z.union([z.string(), z.object({
30
- ramp: z.optional(z.string()),
31
- invert: z.optional(z.boolean()),
32
- shift: z.optional(z.number()),
33
- steps: z.optional(z.record(z.string(), z.string())),
34
- value: z.optional(z.record(z.string(), z.string()))
35
- })]);
36
- const Scheme = z.object({
37
- appearance: z.enum(["light", "dark"]),
38
- semantic: z.record(z.string(), RoleSpec)
21
+ const SeededRamp = z.object({
22
+ seed: z.string(),
23
+ anchors: z.optional(z.record(z.string(), z.string()))
39
24
  });
25
+ const FixedRamp = z.object({ tones: z.union([z.array(z.string()), z.record(z.string(), z.string())]) });
26
+ const Ramp = z.union([SeededRamp, FixedRamp]);
40
27
  const UtilityName = z.enum([
41
28
  "bg",
42
29
  "text",
@@ -47,8 +34,8 @@ const UtilityName = z.enum([
47
34
  ]);
48
35
  const Colors = z.object({
49
36
  palette: z.record(z.string(), Ramp),
50
- utilities: z.optional(z.array(UtilityName)),
51
- schemes: z.record(z.string(), Scheme)
37
+ roles: z.record(z.string(), z.string()),
38
+ utilities: z.optional(z.array(UtilityName))
52
39
  });
53
40
  const Scale = z.object({
54
41
  base: z.string(),
@@ -111,11 +98,8 @@ const DesignSystemSchema = z.object({
111
98
  });
112
99
  const ColorsPatch = z.object({
113
100
  palette: z.optional(z.record(z.string(), Ramp)),
114
- utilities: z.optional(z.array(UtilityName)),
115
- schemes: z.optional(z.record(z.string(), z.object({
116
- appearance: z.optional(z.enum(["light", "dark"])),
117
- semantic: z.optional(z.record(z.string(), RoleSpec))
118
- })))
101
+ roles: z.optional(z.record(z.string(), z.string())),
102
+ utilities: z.optional(z.array(UtilityName))
119
103
  });
120
104
  const DesignSystemPatchSchema = z.extend(z.partial(DesignSystemSchema), { colors: z.optional(ColorsPatch) });
121
105
  /**
@@ -137,17 +121,18 @@ function validate(input) {
137
121
  data: void 0,
138
122
  errors: result.error.issues
139
123
  };
140
- if (result.data.colors.schemes.default == null) return {
124
+ const { palette, roles } = result.data.colors;
125
+ for (const [role, hue] of Object.entries(roles)) if (palette[hue] == null) return {
141
126
  ok: false,
142
127
  data: void 0,
143
128
  errors: [{
144
129
  code: "custom",
145
130
  path: [
146
131
  "colors",
147
- "schemes",
148
- "default"
132
+ "roles",
133
+ role
149
134
  ],
150
- message: "colors.schemes must include a \"default\" (base) scheme"
135
+ message: `role "${role}" references unknown palette hue "${hue}"`
151
136
  }]
152
137
  };
153
138
  return {
@@ -157,6 +142,185 @@ function validate(input) {
157
142
  };
158
143
  }
159
144
 
145
+ //#endregion
146
+ //#region src/tokens.ts
147
+ /**
148
+ * Palette expansion + semantic functional tokens.
149
+ *
150
+ * A palette hue is authored one of two ways:
151
+ * • `{ seed, anchors? }` — an 11-step numeric scale (50…950) generated in
152
+ * OKLCH via @getvitops/utils/color (lightness transposed toward tinted
153
+ * near-white/near-black, chroma dampened at the extremes),
154
+ * • `{ tones }` — a fixed brand kit: authored tones placed verbatim at their
155
+ * nearest steps + tinted endpoints; nothing else is invented (strict kits).
156
+ *
157
+ * Every role (from `colors.roles`) gets FUNCTIONAL tokens — the public API:
158
+ * --<role>-bg / -bg-muted / -border / -border-bold / -solid / -solid-bold /
159
+ * -on-solid / -text / -text-muted / -text-x-muted
160
+ * plus the appearance-relative emphasis stops (x-muted · muted · bold · x-bold)
161
+ * and, for `surface`, the translucent `--surface-glass` + `--overlay` scrim.
162
+ * Dark mode flips the mapping automatically (solid stays mode-stable).
163
+ */
164
+ const NUMERIC_STEPS = [
165
+ "50",
166
+ "100",
167
+ "200",
168
+ "300",
169
+ "400",
170
+ "500",
171
+ "600",
172
+ "700",
173
+ "800",
174
+ "900",
175
+ "950"
176
+ ];
177
+ const STEP_L = {
178
+ "50": .95,
179
+ "100": .9,
180
+ "200": .8,
181
+ "300": .7,
182
+ "400": .6,
183
+ "500": .5,
184
+ "600": .4,
185
+ "700": .3,
186
+ "800": .2,
187
+ "900": .1,
188
+ "950": .05
189
+ };
190
+ const isHex = (v) => v.startsWith("#");
191
+ const toOklchStr = (v) => {
192
+ if (!isHex(v)) return v;
193
+ const { l, c, h } = hexToOklch(v);
194
+ return `oklch(${(l * 100).toFixed(2)}% ${c.toFixed(4)} ${h.toFixed(2)})`;
195
+ };
196
+ const nearestStep = (l) => NUMERIC_STEPS.reduce((best, s) => Math.abs(l - STEP_L[s]) < Math.abs(l - STEP_L[best]) ? s : best);
197
+ function expandSeeded(input) {
198
+ const anchors = {};
199
+ const seedStep = nearestStep(hexToOklch(input.seed).l);
200
+ anchors[seedStep] = toOklchStr(input.seed);
201
+ for (const [key, value] of Object.entries(input.anchors ?? {})) {
202
+ const step = NUMERIC_STEPS.includes(key) ? key : nearestStep(hexToOklch(value).l);
203
+ anchors[step] = toOklchStr(value);
204
+ }
205
+ const hue = hexToOklch(input.seed).h;
206
+ anchors["50"] ??= `oklch(97% 0.008 ${hue.toFixed(2)})`;
207
+ anchors["950"] ??= `oklch(13% 0.015 ${hue.toFixed(2)})`;
208
+ const scale = generateOklchPalette(anchors);
209
+ const numeric = {};
210
+ for (const s of NUMERIC_STEPS) if (scale[s] != null) numeric[s] = oklchStringToHex(scale[s]);
211
+ return { numeric };
212
+ }
213
+ function expandTones(input) {
214
+ const tones = Array.isArray(input.tones) ? input.tones : Object.values(input.tones);
215
+ const numeric = {};
216
+ for (const hex of tones) numeric[nearestStep(hexToOklch(hex).l)] = hex;
217
+ const hue = hexToOklch(tones[0] ?? "#808080").h;
218
+ numeric["50"] ??= oklchStringToHex(`oklch(97% 0.010 ${hue.toFixed(2)})`);
219
+ numeric["950"] ??= oklchStringToHex(`oklch(15% 0.015 ${hue.toFixed(2)})`);
220
+ return {
221
+ numeric,
222
+ sparse: true
223
+ };
224
+ }
225
+ /** Snap to the nearest AVAILABLE numeric step (sparse kits). */
226
+ function snap(numeric, want) {
227
+ if (numeric[want] != null) return want;
228
+ 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);
229
+ }
230
+ /** Expand every palette hue into its numeric scale. */
231
+ function expandPalette(raw) {
232
+ const out = {};
233
+ for (const [name, entry] of Object.entries(raw)) {
234
+ const e = entry;
235
+ if (typeof e?.seed === "string") out[name] = expandSeeded(e);
236
+ else if (e?.tones != null) out[name] = expandTones(e);
237
+ else throw new Error(`palette hue "${name}" must have a "seed" or "tones"`);
238
+ }
239
+ return out;
240
+ }
241
+ const LIGHT = {
242
+ bg: "50",
243
+ "bg-muted": "100",
244
+ border: "200",
245
+ "border-bold": "300",
246
+ text: "950",
247
+ "text-muted": "800",
248
+ "text-x-muted": "600"
249
+ };
250
+ const DARK = {
251
+ bg: "950",
252
+ "bg-muted": "900",
253
+ border: "800",
254
+ "border-bold": "700",
255
+ text: "50",
256
+ "text-muted": "200",
257
+ "text-x-muted": "400"
258
+ };
259
+ const SURFACE_LIGHT = {
260
+ ...LIGHT,
261
+ bg: "100",
262
+ "bg-muted": "200",
263
+ "bg-bold": "50"
264
+ };
265
+ const SURFACE_DARK = {
266
+ ...DARK,
267
+ bg: "900",
268
+ "bg-muted": "950",
269
+ "bg-bold": "800"
270
+ };
271
+ const EMPHASIS_LIGHT = {
272
+ "x-muted": "100",
273
+ muted: "300",
274
+ bold: "700",
275
+ "x-bold": "900"
276
+ };
277
+ const EMPHASIS_DARK = {
278
+ "x-muted": "900",
279
+ muted: "700",
280
+ bold: "300",
281
+ "x-bold": "100"
282
+ };
283
+ /** Resolve the functional token set for one role over its hue. */
284
+ function functionalRole(role, hueName, hue) {
285
+ const numeric = hue.numeric;
286
+ const v = (n) => `var(--color-${hueName}-${snap(numeric, n)})`;
287
+ const hex = (n) => numeric[snap(numeric, n)];
288
+ const naturalRaw = NUMERIC_STEPS.find((s) => numeric[s] === hex("500")) ?? "500";
289
+ const natural = Number(snap(numeric, naturalRaw));
290
+ const solidStep = snap(numeric, String(Math.min(Math.max(natural, 500), 700)));
291
+ const solidBold = snap(numeric, String(Math.min(Number(solidStep) + 100, 950)));
292
+ const onSolid = pickOn(numeric[solidStep]);
293
+ const isSurface = role === "surface";
294
+ const map = (table) => {
295
+ const out = {};
296
+ for (const [token, step] of Object.entries(table)) out[token] = v(step);
297
+ return out;
298
+ };
299
+ const emphasis = (table, prefix) => {
300
+ const out = {};
301
+ for (const [stop, step] of Object.entries(table)) out[`${prefix}-${stop}`] = v(step);
302
+ return out;
303
+ };
304
+ return {
305
+ role,
306
+ hue: hueName,
307
+ light: {
308
+ ...map(isSurface ? SURFACE_LIGHT : LIGHT),
309
+ solid: v(solidStep),
310
+ "solid-bold": v(solidBold),
311
+ "on-solid": onSolid,
312
+ ...emphasis(EMPHASIS_LIGHT, "stop")
313
+ },
314
+ dark: {
315
+ ...map(isSurface ? SURFACE_DARK : DARK),
316
+ solid: v(solidStep),
317
+ "solid-bold": v(solidBold),
318
+ "on-solid": onSolid,
319
+ ...emphasis(EMPHASIS_DARK, "stop")
320
+ }
321
+ };
322
+ }
323
+
160
324
  //#endregion
161
325
  //#region src/docs.ts
162
326
  /**
@@ -326,7 +490,8 @@ var Parser = class {
326
490
  const first = this.parseValue();
327
491
  if (this.peek()?.t === "arrow") {
328
492
  this.next();
329
- map[String(first)] = this.parseValue();
493
+ const val = this.parseValue();
494
+ map[typeof first === "object" && first !== null ? JSON.stringify(first) : String(first)] = val;
330
495
  keyed = true;
331
496
  } else list.push(first);
332
497
  if (this.peek()?.t === "punc" && this.peek()?.v === ",") this.next();
@@ -467,9 +632,11 @@ function parseElement(src) {
467
632
  keywords: extractKeywords(src)
468
633
  };
469
634
  }
635
+ /** Stringify a parsed-PHP Json value for prose (objects → JSON, not [object Object]). */
636
+ const asText = (v) => v == null ? "" : typeof v === "object" ? JSON.stringify(v) : String(v);
470
637
  function controlLine(c) {
471
638
  const def = c.def;
472
- if (def.type === "info") return `- _Note:_ ${def.content ?? ""}`;
639
+ if (def.type === "info") return `- _Note:_ ${asText(def.content)}`;
473
640
  const label = def.label || c.key;
474
641
  const options = def.options && typeof def.options === "object" && !Array.isArray(def.options) ? def.options : null;
475
642
  const meta = [];
@@ -477,21 +644,21 @@ function controlLine(c) {
477
644
  if (def.units === true) type += "+units";
478
645
  meta.push(type);
479
646
  if (def.default !== void 0) {
480
- const raw = String(def.default);
647
+ const raw = asText(def.default);
481
648
  const shown = options && options[raw] ? options[raw] : raw;
482
649
  meta.push(`default \`${shown}\``);
483
650
  }
484
- if (def.placeholder !== void 0 && def.placeholder !== "") meta.push(`placeholder \`${def.placeholder}\``);
485
- if (def.min !== void 0 || def.max !== void 0) meta.push(`range ${def.min ?? "–"}–${def.max ?? "∞"}`);
651
+ if (def.placeholder !== void 0 && def.placeholder !== "") meta.push(`placeholder \`${asText(def.placeholder)}\``);
652
+ if (def.min !== void 0 || def.max !== void 0) meta.push(`range ${def.min != null ? asText(def.min) : "–"}–${def.max != null ? asText(def.max) : "∞"}`);
486
653
  const tail = [];
487
- if (def.description) tail.push(String(def.description));
654
+ if (def.description) tail.push(asText(def.description));
488
655
  if (options) tail.push(`Options: ${Object.values(options).map((o) => `\`${o}\``).join(", ")}.`);
489
656
  if (Array.isArray(def.css) && def.css.length) {
490
657
  const first = def.css[0];
491
- if (first && typeof first === "object" && !Array.isArray(first) && "property" in first) tail.push(`Bound to \`${first.property}\`.`);
658
+ if (first && typeof first === "object" && !Array.isArray(first) && "property" in first) tail.push(`Bound to \`${asText(first.property)}\`.`);
492
659
  }
493
660
  if (def.type === "repeater" && def.fields && typeof def.fields === "object") {
494
- const fields = Object.values(def.fields).map((f) => f && typeof f === "object" && !Array.isArray(f) ? f.label : null).filter(Boolean);
661
+ const fields = Object.values(def.fields).map((f) => f && typeof f === "object" && !Array.isArray(f) ? asText(f.label) : null).filter(Boolean);
495
662
  if (fields.length) tail.push(`Fields: ${fields.join(", ")}.`);
496
663
  }
497
664
  const head = `- **${label}** (${meta.join(", ")})`;
@@ -613,11 +780,9 @@ function renderElementsDoc(elementsDir) {
613
780
  }
614
781
  const code = (xs) => xs.map((x) => `\`${x}\``).join(", ");
615
782
  function renderCssClasses(ds) {
616
- const palette = ds.colors.palette;
617
- const ramps = Object.keys(palette);
618
- const firstRamp = ramps[0];
619
- const nonBase = (firstRamp ? Object.keys(palette[firstRamp] ?? {}) : []).filter((s) => s !== "base");
620
- const roles = Object.keys(ds.colors.schemes.default?.semantic ?? {});
783
+ const expanded = expandPalette(ds.colors.palette);
784
+ const ramps = Object.keys(expanded);
785
+ const roles = Object.keys(ds.colors.roles ?? {});
621
786
  const utils = ds.colors.utilities ?? [
622
787
  "bg",
623
788
  "text",
@@ -704,13 +869,32 @@ Families: ${code(families)} (\`--font-*\`).
704
869
 
705
870
  ## Colour
706
871
 
707
- Rule: **\`<util>-<color>\`** — util ∈ ${code(utils)}; \`<color>\` is either a **named ramp**
708
- step or a **semantic role**.
872
+ **Functional tokens are the primary vocabulary** — classes name the *job*, not the tone, and
873
+ every one remaps automatically under \`:root[data-brx-theme="dark"]\` (background/text ends
874
+ swap; \`solid\` fills stay mode-stable with a computed \`on-\` foreground). Prefer these over
875
+ raw steps. Rules (role ∈ ${code(roles)}):
876
+
877
+ - **Surfaces** — \`bg-<role>\` (the role's background wash; for \`surface\` this is the
878
+ card/panel plane), \`bg-<role>-muted\` (subtle / sunken), \`bg-surface-bold\` (raised plane).
879
+ - **Solid fills** — \`bg-<role>-solid\`, \`bg-<role>-solid-bold\` (hover / emphasis), paired
880
+ with \`text-on-<role>\` for guaranteed-contrast foreground.
881
+ - **Content** — \`text-<role>\` (primary, contrast-guaranteed in both appearances),
882
+ \`text-<role>-muted\` (secondary), \`text-<role>-x-muted\` (tertiary / disabled).
883
+ - **Borders** — \`border-<role>\`, \`border-<role>-bold\`.
884
+ - **Translucency** — \`glass\` (translucent surface + backdrop blur); \`--overlay\` scrim var.
885
+ - **Emphasis stops** (appearance-relative vars for power use):
886
+ \`--color-<role>-{x-muted,muted,bold,x-bold}\` — \`muted\` recedes toward the background
887
+ extreme, \`bold\` advances toward the foreground, in *either* appearance.
888
+
889
+ Everyday pairings: page \`bg-neutral\` + \`text-neutral\`; cards \`bg-surface\`; captions
890
+ \`text-neutral-muted\`; buttons \`bg-ui-primary-solid text-on-ui-primary\`; status text
891
+ \`text-danger\` / \`text-success\` / \`text-warning\` / \`text-info\`.
709
892
 
710
- - Named ramps: ${code(ramps)}. Steps: base (no suffix) + ${code(nonBase)} — e.g. \`bg-pine\`
711
- (base), \`text-navy-xl\`, \`border-grey-d\`.
712
- - Semantic roles: ${code(roles)} — e.g. \`bg-brand-primary\`, \`text-danger\`. Roles remap
713
- automatically under \`:root[data-brx-theme="dark"]\`.
893
+ **Raw scale** (secondary / fine control) — rule \`<util>-<hue>-<step>\` with util ∈
894
+ ${code(utils)}: every hue is an 11-step OKLCH scale generated from its seed (or fixed brand
895
+ tones), numeric steps \`50\` … \`950\` (tinted near-white → tinted near-black) — e.g.
896
+ \`bg-${ramps[0] ?? "brand"}-100\`, \`text-${ramps[0] ?? "brand"}-800\`. Hues: ${code(ramps)}.
897
+ The **bare** role name (\`bg-<role>\`, \`text-<role>\`) is always the functional token.
714
898
 
715
899
  ## Shadows
716
900
 
@@ -813,31 +997,6 @@ function generateDocs(ds, assetsDir) {
813
997
  * copied from there — they are the same for every consumer.
814
998
  */
815
999
  const DEFAULT_ASSETS = join(dirname(fileURLToPath(import.meta.url)), "..", "assets");
816
- const STEP_ORDER = [
817
- "xxd",
818
- "xd",
819
- "d",
820
- "base",
821
- "l",
822
- "xl",
823
- "xxl"
824
- ];
825
- const suffix = (step) => step === "base" ? "" : `-${step}`;
826
- const MIRROR = {
827
- xxd: "xxl",
828
- xd: "xl",
829
- d: "l",
830
- base: "base",
831
- l: "d",
832
- xl: "xd",
833
- xxl: "xxd"
834
- };
835
- const STEP_INDEX = Object.fromEntries(STEP_ORDER.map((s, i) => [s, i]));
836
- const shiftStep = (step, by) => {
837
- const i = STEP_INDEX[step] ?? STEP_ORDER.indexOf("base");
838
- return STEP_ORDER[Math.max(0, Math.min(STEP_ORDER.length - 1, i - by))];
839
- };
840
- const BASE_STEP = "d";
841
1000
  const UTILITY_PROPS = {
842
1001
  bg: "background-color",
843
1002
  text: "color",
@@ -846,20 +1005,25 @@ const UTILITY_PROPS = {
846
1005
  fill: "fill",
847
1006
  stroke: "stroke"
848
1007
  };
849
- const roleRamp = (spec, inherited = "") => typeof spec === "string" ? spec : spec.ramp ?? inherited;
850
- const resolveSlot = (spec, step, inherited) => {
851
- if (typeof spec === "string") return {
852
- ramp: spec,
853
- step
854
- };
855
- if (spec.value?.[step] != null) return { value: spec.value[step] };
856
- return {
857
- ramp: spec.ramp ?? inherited,
858
- step: spec.steps?.[step] ?? shiftStep(spec.invert ? MIRROR[step] : step, spec.shift ?? 0)
859
- };
1008
+ const fnVar = (role, token) => token.startsWith("stop-") ? `--color-${role}-${token.slice(5)}` : `--${role}-${token}`;
1009
+ const fnUtilities = (role, utilities, isSurface) => {
1010
+ const rows = [];
1011
+ if (utilities.includes("bg")) {
1012
+ rows.push(`.bg-${role} { background-color: var(--${role}-bg); }`);
1013
+ rows.push(`.bg-${role}-muted { background-color: var(--${role}-bg-muted); }`);
1014
+ rows.push(`.bg-${role}-solid { background-color: var(--${role}-solid); }`);
1015
+ rows.push(`.bg-${role}-solid-bold { background-color: var(--${role}-solid-bold); }`);
1016
+ if (isSurface) rows.push(`.bg-${role}-bold { background-color: var(--${role}-bg-bold); }`);
1017
+ }
1018
+ if (utilities.includes("text")) {
1019
+ rows.push(`.text-${role} { color: var(--${role}-text); }`);
1020
+ rows.push(`.text-${role}-muted { color: var(--${role}-text-muted); }`);
1021
+ rows.push(`.text-${role}-x-muted { color: var(--${role}-text-x-muted); }`);
1022
+ rows.push(`.text-on-${role} { color: var(--${role}-on-solid); }`);
1023
+ }
1024
+ if (utilities.includes("border")) rows.push(`.border-${role} { border-color: var(--${role}-border); }`, `.border-${role}-bold { border-color: var(--${role}-border-bold); }`);
1025
+ return rows.join("\n");
860
1026
  };
861
- const srcVar = (s) => "value" in s ? s.value : `var(--color-${s.ramp}${suffix(s.step)})`;
862
- const srcEq = (a, b) => "value" in a || "value" in b ? "value" in a && "value" in b && a.value === b.value : a.ramp === b.ramp && a.step === b.step;
863
1027
  const decls = (obj, indent = " ") => Object.entries(obj).map(([k, v]) => `${indent}${k}: ${v};`).join("\n");
864
1028
  const round = (n) => Number(n.toFixed(4)).toString();
865
1029
  const rem = (v) => parseFloat(v);
@@ -940,13 +1104,15 @@ const BASE_HOOK = {
940
1104
  const id = (key) => createHash("sha256").update(key).digest("hex").slice(0, 6);
941
1105
  function build(ds, format, assetsDir) {
942
1106
  const BRICKS = format === "bricks";
943
- const palette = ds.colors.palette;
944
- const schemes = ds.colors.schemes;
945
- const base = schemes.default;
946
- if (base == null) throw new Error("colors.schemes must include a \"default\" scheme");
947
- const defaultSemantic = base.semantic;
948
- const altSchemes = Object.entries(schemes).filter(([k]) => k !== "default");
949
- const darkScheme = altSchemes.map(([, s]) => s).find((s) => s.appearance === "dark");
1107
+ const expandedPalette = expandPalette(ds.colors.palette);
1108
+ const roleMap = ds.colors.roles;
1109
+ const scaleRoles = [];
1110
+ for (const [role, hueName] of Object.entries(roleMap)) {
1111
+ const hue = expandedPalette[hueName];
1112
+ if (hue == null) throw new Error(`role "${role}" references unknown palette hue "${hueName}"`);
1113
+ scaleRoles.push(functionalRole(role, hueName, hue));
1114
+ }
1115
+ const surfaceRole = scaleRoles.find((r) => r.role === "surface");
950
1116
  const patterns = ds.patterns;
951
1117
  const shadows = ds.shadows;
952
1118
  const typography = ds.typography;
@@ -959,41 +1125,39 @@ function build(ds, format, assetsDir) {
959
1125
  if (BRICKS) generated["color.css"] = "/* Colours provided by Bricks (palette import generates tokens + utilities). */\n";
960
1126
  else {
961
1127
  let css = `/* GENERATED from design-system.json — do not edit by hand. */\n:root {\n`;
962
- css += ` color-scheme: ${base.appearance};\n`;
963
- css += ` /* Palette ramps */\n`;
964
- for (const [name, steps] of Object.entries(palette)) for (const step of STEP_ORDER) if (steps[step] != null) css += ` --color-${name}${suffix(step)}: ${steps[step]};\n`;
965
- css += `\n /* Semantic roles → ramps (default: ${base.appearance}) */\n`;
966
- for (const [role, spec] of Object.entries(defaultSemantic)) {
967
- const ramp = roleRamp(spec);
968
- for (const step of STEP_ORDER) if (palette[ramp]?.[step] != null) css += ` --color-${role}${suffix(step)}: ${srcVar(resolveSlot(spec, step, ramp))};\n`;
1128
+ css += ` color-scheme: light;\n`;
1129
+ css += ` /* Hue scales (numeric steps, 50 = tinted near-white … 950 = tinted near-black) */\n`;
1130
+ for (const [name, hue] of Object.entries(expandedPalette)) for (const [n, hex] of Object.entries(hue.numeric)) css += ` --color-${name}-${n}: ${hex};\n`;
1131
+ css += `\n /* Functional role tokens (the public API) */\n`;
1132
+ for (const fr of scaleRoles) for (const [t, val] of Object.entries(fr.light)) css += ` ${fnVar(fr.role, t)}: ${val};\n`;
1133
+ if (surfaceRole) {
1134
+ css += `\n /* Translucent surface + scrim */\n`;
1135
+ css += ` --surface-glass: color-mix(in oklch, var(--surface-bg) 72%, transparent);\n`;
1136
+ css += ` --overlay: color-mix(in oklch, var(--color-${surfaceRole.hue}-950) 45%, transparent);\n`;
969
1137
  }
970
1138
  css += `}\n\n`;
971
- for (const [key, scheme] of altSchemes) {
972
- let block = "";
973
- for (const [role, spec] of Object.entries(scheme.semantic)) {
974
- const baseSpec = defaultSemantic[role];
975
- if (baseSpec == null) continue;
976
- const ramp = roleRamp(baseSpec);
977
- for (const step of STEP_ORDER) {
978
- if (palette[ramp]?.[step] == null) continue;
979
- const alt = resolveSlot(spec, step, ramp);
980
- if (srcEq(alt, resolveSlot(baseSpec, step, ramp))) continue;
981
- block += ` --color-${role}${suffix(step)}: ${srcVar(alt)};\n`;
982
- }
983
- }
984
- if (block) {
985
- css += `/* Semantic roles → ramps (${key}: ${scheme.appearance}) */\n`;
986
- css += `:root[data-brx-theme="${scheme.appearance}"] {\n color-scheme: ${scheme.appearance};\n${block}}\n\n`;
1139
+ {
1140
+ let fb = "";
1141
+ for (const fr of scaleRoles) for (const [t, val] of Object.entries(fr.dark)) if (fr.light[t] !== val) fb += ` ${fnVar(fr.role, t)}: ${val};\n`;
1142
+ if (surfaceRole) fb += ` --overlay: color-mix(in oklch, var(--color-${surfaceRole.hue}-950) 60%, transparent);\n`;
1143
+ if (fb) {
1144
+ css += `/* Functional role tokens (dark) */\n`;
1145
+ css += `:root[data-brx-theme="dark"] {\n color-scheme: dark;\n${fb}}\n\n`;
987
1146
  }
988
1147
  }
989
1148
  const allTokens = [];
990
- for (const [name, steps] of Object.entries(palette)) for (const step of STEP_ORDER) if (steps[step] != null) allTokens.push(`${name}${suffix(step)}`);
991
- for (const [role, spec] of Object.entries(defaultSemantic)) {
992
- const ramp = roleRamp(spec);
993
- for (const step of STEP_ORDER) if (palette[ramp]?.[step] != null) allTokens.push(`${role}${suffix(step)}`);
994
- }
1149
+ for (const [name, hue] of Object.entries(expandedPalette)) for (const n of Object.keys(hue.numeric)) allTokens.push(`${name}-${n}`);
1150
+ for (const fr of scaleRoles) for (const stop of [
1151
+ "x-muted",
1152
+ "muted",
1153
+ "bold",
1154
+ "x-bold"
1155
+ ]) allTokens.push(`${fr.role}-${stop}`);
995
1156
  css += `/* Colour utilities — ${UTILITIES.join(", ")} */\n`;
996
1157
  for (const token of allTokens) css += UTILITIES.map((cls) => `.${cls}-${token} { ${UTILITY_PROPS[cls]}: var(--color-${token}); }`).join("\n") + "\n";
1158
+ css += `\n/* Functional utilities (the public API) */\n`;
1159
+ for (const fr of scaleRoles) css += `${fnUtilities(fr.role, UTILITIES, fr.role === "surface")}\n`;
1160
+ if (surfaceRole) css += `.glass { background-color: var(--surface-glass); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }\n`;
997
1161
  generated["color.css"] = css;
998
1162
  }
999
1163
  {
@@ -1067,16 +1231,18 @@ function build(ds, format, assetsDir) {
1067
1231
  }
1068
1232
  const stateRules = (sel, role, states, colorProp = "background-color") => {
1069
1233
  let out = "";
1070
- const roleVar = (step) => role ? `var(--color-${role}-${step})` : "";
1071
1234
  for (const [state, spec] of Object.entries(states)) {
1072
1235
  const body = [];
1073
- if (typeof spec.step === "number" && role) body.push(`${colorProp}: ${roleVar(shiftStep(BASE_STEP, spec.step))};`);
1236
+ if (typeof spec.step === "number" && role) {
1237
+ const stateVar = colorProp === "background-color" ? `var(--${role}-${spec.step >= 1 ? "solid-bold" : "solid"})` : `var(--color-${role}-${spec.step >= 1 ? "x-bold" : "bold"})`;
1238
+ body.push(`${colorProp}: ${stateVar};`);
1239
+ }
1074
1240
  if (typeof spec.scale === "number") body.push(`scale: ${spec.scale};`);
1075
1241
  if (typeof spec.lift === "string") body.push(`translate: 0 calc(-1 * ${spec.lift});`);
1076
1242
  if (typeof spec.shadow === "string" && shadows?.[spec.shadow]) body.push(`filter: drop-shadow(var(--shadow-${spec.shadow}));`);
1077
1243
  else if (spec.shadow === true) body.push(`box-shadow: var(--lift-shadow, 0 8px 20px -6px rgb(0 0 0 / 0.25));`);
1078
1244
  if (spec.ring === true) {
1079
- const ringColor = role ? `var(--color-${role}-l)` : `var(--color-brand-primary-l)`;
1245
+ const ringColor = role ? `var(--color-${role}-muted)` : `var(--color-ui-primary-muted)`;
1080
1246
  body.push(`outline: none;`, `box-shadow: 0 0 0 3px ${ringColor};`);
1081
1247
  }
1082
1248
  if (spec.css && typeof spec.css === "object") for (const [k, v] of Object.entries(spec.css)) body.push(`${k}: ${v};`);
@@ -1104,7 +1270,7 @@ function build(ds, format, assetsDir) {
1104
1270
  pat += ` transition-timing-function: var(--interact-easing, ease);\n`;
1105
1271
  pat += ` translate: 0 0; scale: 1; rotate: 0deg;\n`;
1106
1272
  }
1107
- if (defaultRole && fills && base["background-color"] == null && base["background"] == null) base["background-color"] = `var(--color-${defaultRole}-${BASE_STEP})`;
1273
+ if (defaultRole && fills && base["background-color"] == null && base["background"] == null) base["background-color"] = `var(--${defaultRole}-solid)`;
1108
1274
  const wrappedBase = {};
1109
1275
  for (const [prop, val] of Object.entries(base)) {
1110
1276
  const sfx = BASE_HOOK[prop];
@@ -1114,7 +1280,7 @@ function build(ds, format, assetsDir) {
1114
1280
  pat += stateRules(defaultSel, defaultRole, states, colorProp);
1115
1281
  for (const role of p.roles ?? []) {
1116
1282
  const variantSel = isElement ? `${p.element}.${role}` : `.${p.class ?? pname}-${role}`;
1117
- const variantColorDecl = fills ? `background-color: var(--color-${role}-${BASE_STEP})` : `color: var(--color-${role}-${BASE_STEP})`;
1283
+ const variantColorDecl = fills ? `background-color: var(--${role}-solid); color: var(--${role}-on-solid)` : `color: var(--color-${role}-bold)`;
1118
1284
  pat += `${variantSel} { ${variantColorDecl}; }\n`;
1119
1285
  pat += stateRules(variantSel, role, states, colorProp);
1120
1286
  }
@@ -1166,10 +1332,8 @@ function build(ds, format, assetsDir) {
1166
1332
  generated["animation-effects.css"] = out;
1167
1333
  }
1168
1334
  const namedColors = [];
1169
- for (const [name, steps] of Object.entries(palette)) for (const step of STEP_ORDER) {
1170
- const hex = steps[step];
1171
- if (hex == null) continue;
1172
- const token = `${name}${suffix(step)}`;
1335
+ for (const [name, hue] of Object.entries(expandedPalette)) for (const [n, hex] of Object.entries(hue.numeric)) {
1336
+ const token = `${name}-${n}`;
1173
1337
  namedColors.push({
1174
1338
  raw: `var(--color-${token})`,
1175
1339
  light: hex,
@@ -1178,98 +1342,99 @@ function build(ds, format, assetsDir) {
1178
1342
  });
1179
1343
  }
1180
1344
  const semanticColors = [];
1181
- for (const [role, spec] of Object.entries(defaultSemantic)) {
1182
- const ramp = roleRamp(spec);
1183
- const altSpec = darkScheme?.semantic[role];
1184
- for (const step of STEP_ORDER) {
1185
- if (palette[ramp]?.[step] == null) continue;
1186
- const lightSrc = resolveSlot(spec, step, ramp);
1187
- const darkSrc = altSpec != null ? resolveSlot(altSpec, step, ramp) : lightSrc;
1188
- const hasDark = !srcEq(darkSrc, lightSrc);
1189
- semanticColors.push({
1190
- raw: `var(--color-${role}${suffix(step)})`,
1191
- light: srcVar(lightSrc),
1192
- darkModeEnabled: hasDark,
1193
- dark: srcVar(darkSrc),
1194
- id: id(`semantic:${role}${suffix(step)}`),
1195
- utilityClasses: UTILITIES
1196
- });
1197
- }
1345
+ for (const fr of scaleRoles) for (const [t, light] of Object.entries(fr.light)) {
1346
+ const dark = fr.dark[t];
1347
+ semanticColors.push({
1348
+ raw: `var(${fnVar(fr.role, t)})`,
1349
+ light,
1350
+ darkModeEnabled: light !== dark,
1351
+ dark,
1352
+ id: id(`semantic:${fr.role}-${t}`),
1353
+ utilityClasses: UTILITIES
1354
+ });
1198
1355
  }
1199
- return {
1200
- generated,
1201
- bricksColorsNamed: JSON.stringify({
1202
- id: id("palette:named"),
1203
- name: "Named",
1204
- colors: namedColors
1205
- }, null, 2),
1206
- bricksColorsSemantic: JSON.stringify({
1207
- id: id("palette:semantic"),
1208
- name: "Semantic",
1209
- colors: semanticColors
1210
- }, null, 2),
1211
- bricksVariables: (() => {
1212
- const scaleMeta = (scope, prefix, s, names) => {
1213
- const px = Math.round(rem(s.base) * 16);
1214
- const lo = s.fluid?.minRatio ?? s.ratio;
1215
- return {
1216
- scaleScope: scope,
1217
- scaleType: "tshirt",
1218
- scaleNames: names,
1219
- prefix,
1220
- minFontSize: px,
1221
- minScaleRatio: lo,
1222
- minScaleRatioSelect: lo,
1223
- maxFontSize: px,
1224
- maxScaleRatio: s.ratio,
1225
- maxScaleRatioSelect: s.ratio,
1226
- baseline: s.baseline ?? names[Math.floor((names.length - 1) / 2)]
1227
- };
1356
+ const bricksColorsNamed = JSON.stringify({
1357
+ id: id("palette:named"),
1358
+ name: "Named",
1359
+ colors: namedColors
1360
+ }, null, 2);
1361
+ const bricksColorsSemantic = JSON.stringify({
1362
+ id: id("palette:semantic"),
1363
+ name: "Semantic",
1364
+ colors: semanticColors
1365
+ }, null, 2);
1366
+ const bricksVariables = (() => {
1367
+ const scaleMeta = (scope, prefix, s, names) => {
1368
+ const px = Math.round(rem(s.base) * 16);
1369
+ const lo = s.fluid?.minRatio ?? s.ratio;
1370
+ return {
1371
+ scaleScope: scope,
1372
+ scaleType: "tshirt",
1373
+ scaleNames: names,
1374
+ prefix,
1375
+ minFontSize: px,
1376
+ minScaleRatio: lo,
1377
+ minScaleRatioSelect: lo,
1378
+ maxFontSize: px,
1379
+ maxScaleRatio: s.ratio,
1380
+ maxScaleRatioSelect: s.ratio,
1381
+ baseline: s.baseline ?? names[Math.floor((names.length - 1) / 2)]
1228
1382
  };
1229
- const categories = [];
1230
- const variables = [];
1231
- const addScale = (label, scope, prefix, cfg, steps) => {
1232
- if (!cfg || !steps.length) return;
1233
- const names = steps.map((s) => s.name);
1234
- const catId = id(`varcat:${scope}`);
1235
- categories.push({
1236
- id: catId,
1237
- name: label,
1238
- scale: scaleMeta(scope, prefix, cfg, names),
1239
- utilityClasses: []
1240
- });
1241
- for (const st of steps) variables.push({
1242
- id: id(`var:${prefix}${st.name}`),
1243
- name: `${prefix}${st.name}`,
1244
- value: st.value,
1245
- category: catId,
1246
- scale: {
1247
- scale: st.offset,
1248
- scaleName: st.name
1249
- }
1250
- });
1251
- };
1252
- addScale("Typography", "typography", "text-", ds.typeScale, typeSteps);
1253
- addScale("Spacing", "spacing", "space-", ds.spaceScale, spaceSteps);
1254
- for (const [name, stack] of Object.entries(ds.fonts ?? {})) variables.push({
1255
- id: id(`var:font-${name}`),
1256
- name: `font-${name}`,
1257
- value: stack
1383
+ };
1384
+ const categories = [];
1385
+ const variables = [];
1386
+ const addScale = (label, scope, prefix, cfg, steps) => {
1387
+ if (!cfg || !steps.length) return;
1388
+ const names = steps.map((s) => s.name);
1389
+ const catId = id(`varcat:${scope}`);
1390
+ categories.push({
1391
+ id: catId,
1392
+ name: label,
1393
+ scale: scaleMeta(scope, prefix, cfg, names),
1394
+ utilityClasses: []
1258
1395
  });
1259
- for (const [name, val] of Object.entries(ds.patterns?.radii ?? {})) variables.push({
1260
- id: id(`var:br-${name}`),
1261
- name: `br-${name}`,
1262
- value: val
1396
+ for (const st of steps) variables.push({
1397
+ id: id(`var:${prefix}${st.name}`),
1398
+ name: `${prefix}${st.name}`,
1399
+ value: st.value,
1400
+ category: catId,
1401
+ scale: {
1402
+ scale: st.offset,
1403
+ scaleName: st.name
1404
+ }
1263
1405
  });
1264
- return JSON.stringify({
1265
- variables,
1266
- categories
1267
- }, null, 2);
1268
- })(),
1406
+ };
1407
+ addScale("Typography", "typography", "text-", ds.typeScale, typeSteps);
1408
+ addScale("Spacing", "spacing", "space-", ds.spaceScale, spaceSteps);
1409
+ for (const [name, stack] of Object.entries(ds.fonts ?? {})) variables.push({
1410
+ id: id(`var:font-${name}`),
1411
+ name: `font-${name}`,
1412
+ value: stack
1413
+ });
1414
+ for (const [name, val] of Object.entries(ds.patterns?.radii ?? {})) variables.push({
1415
+ id: id(`var:br-${name}`),
1416
+ name: `br-${name}`,
1417
+ value: val
1418
+ });
1419
+ return JSON.stringify({
1420
+ variables,
1421
+ categories
1422
+ }, null, 2);
1423
+ })();
1424
+ const numericPalette = Object.fromEntries(Object.entries(expandedPalette).map(([k, v]) => [k, v.numeric]));
1425
+ return {
1426
+ generated,
1427
+ bricksColorsNamed,
1428
+ bricksColorsSemantic,
1429
+ bricksVariables,
1269
1430
  tokensJson: JSON.stringify({
1270
1431
  colors: {
1271
- palette,
1272
- semantic: Object.fromEntries(Object.entries(defaultSemantic).map(([role, spec]) => [role, roleRamp(spec)]))
1432
+ palette: numericPalette,
1433
+ roles: roleMap,
1434
+ functional: Object.fromEntries(scaleRoles.map((fr) => [fr.role, {
1435
+ light: fr.light,
1436
+ dark: fr.dark
1437
+ }]))
1273
1438
  },
1274
1439
  fonts: ds.fonts ?? {},
1275
1440
  text: Object.fromEntries(typeSteps.map((s) => [s.name, s.value])),
@@ -1279,10 +1444,10 @@ function build(ds, format, assetsDir) {
1279
1444
  }, null, 2),
1280
1445
  designManifest: (() => {
1281
1446
  const reverseIndex = {};
1282
- for (const [name, steps] of Object.entries(palette)) for (const step of STEP_ORDER) if (steps[step] != null) reverseIndex[`--color-${name}${suffix(step)}`] = `colors.palette.${name}.${step}`;
1283
- const colorRoles = Object.entries(defaultSemantic).map(([name, spec]) => ({
1447
+ for (const [name, hue] of Object.entries(expandedPalette)) for (const n of Object.keys(hue.numeric)) reverseIndex[`--color-${name}-${n}`] = `colors.palette.${name}.seed`;
1448
+ const colorRoles = Object.entries(roleMap).map(([name, ramp]) => ({
1284
1449
  name,
1285
- ramp: roleRamp(spec)
1450
+ ramp
1286
1451
  }));
1287
1452
  const typoRoles = typography?.roles ?? {};
1288
1453
  const hooks = {};
@@ -1315,9 +1480,9 @@ function build(ds, format, assetsDir) {
1315
1480
  });
1316
1481
  return JSON.stringify({
1317
1482
  colors: {
1318
- ramps: Object.keys(palette),
1319
- steps: STEP_ORDER,
1320
- palette,
1483
+ ramps: Object.keys(expandedPalette),
1484
+ steps: NUMERIC_STEPS,
1485
+ palette: numericPalette,
1321
1486
  roles: colorRoles
1322
1487
  },
1323
1488
  typography: {
@@ -1355,9 +1520,8 @@ function build(ds, format, assetsDir) {
1355
1520
  })(),
1356
1521
  tailwind: emitTailwind({
1357
1522
  ds,
1358
- palette,
1359
- defaultSemantic,
1360
- darkScheme,
1523
+ expandedPalette,
1524
+ scaleRoles,
1361
1525
  shadows,
1362
1526
  typeSteps,
1363
1527
  spaceSteps,
@@ -1374,25 +1538,18 @@ function build(ds, format, assetsDir) {
1374
1538
  };
1375
1539
  }
1376
1540
  function emitTailwind(ctx) {
1377
- const { ds, palette, defaultSemantic, darkScheme, shadows, typeSteps, spaceSteps, typography, roleDecls, twTypeTokensCss, twPatternTokensCss, patternsCss, assetsDir } = ctx;
1541
+ const { ds, expandedPalette, scaleRoles, shadows, typeSteps, spaceSteps, typography, roleDecls, twTypeTokensCss, twPatternTokensCss, patternsCss, assetsDir } = ctx;
1378
1542
  const cssDir = join(assetsDir, "css");
1379
1543
  const nl = (arr) => arr.filter(Boolean).join("\n");
1380
1544
  const parts = [`/* GENERATED for Tailwind v4 (Astro) — do not edit by hand. */`];
1381
1545
  parts.push(`@import "tailwindcss";\n`);
1382
1546
  const theme = [];
1383
- theme.push(` /* Colour ramps → bg-* text-* border-* */`);
1384
- for (const [name, steps] of Object.entries(palette)) for (const step of STEP_ORDER) if (steps[step] != null) theme.push(` --color-${name}${suffix(step)}: ${steps[step]};`);
1385
- theme.push(`\n /* Semantic roles → ramps (default scheme) */`);
1386
- for (const [role, spec] of Object.entries(defaultSemantic)) {
1387
- const ramp = roleRamp(spec);
1388
- for (const step of STEP_ORDER) if (palette[ramp]?.[step] != null) theme.push(` --color-${role}${suffix(step)}: ${srcVar(resolveSlot(spec, step, ramp))};`);
1389
- }
1547
+ theme.push(` /* Hue scales → bg-<hue>-<step> text-* border-* (numeric, 50…950) */`);
1548
+ for (const [name, hue] of Object.entries(expandedPalette)) for (const [n, hex] of Object.entries(hue.numeric)) theme.push(` --color-${name}-${n}: ${hex};`);
1390
1549
  theme.push(`\n /* Fonts → font-* */`);
1391
1550
  for (const [name, stack] of Object.entries(ds.fonts ?? {})) theme.push(` --font-${name}: ${stack};`);
1392
1551
  theme.push(`\n /* Fluid type scale → text-* */`);
1393
1552
  for (const s of typeSteps) theme.push(` --text-${s.name}: ${s.value};`);
1394
- theme.push(`\n /* Fluid spacing scale → p-* m-* gap-* */`);
1395
- for (const s of spaceSteps) theme.push(` --spacing-${s.name}: ${s.value};`);
1396
1553
  theme.push(`\n /* Drop-shadows → shadow-* */`);
1397
1554
  for (const [name, value] of Object.entries(shadows ?? {})) theme.push(` --shadow-${name}: ${value};`);
1398
1555
  theme.push(`\n /* Container breakpoints → @sm:/@md:/@lg:/@xl: */`);
@@ -1403,22 +1560,39 @@ function emitTailwind(ctx) {
1403
1560
  ["xl", "80rem"]
1404
1561
  ]) theme.push(` --container-${name}: ${val};`);
1405
1562
  parts.push(`@theme {\n${theme.join("\n")}\n}\n`);
1563
+ if (spaceSteps.length) parts.push(`/* Fluid space scale — var(--space-<name>) or p-(--space-<name>) */\n:root {\n` + spaceSteps.map((s) => ` --space-${s.name}: ${s.value};`).join("\n") + `\n}\n`);
1564
+ if (scaleRoles.length) {
1565
+ let fn = "";
1566
+ for (const fr of scaleRoles) for (const [t, val] of Object.entries(fr.light)) fn += ` ${fnVar(fr.role, t)}: ${val};\n`;
1567
+ const surface = scaleRoles.find((r) => r.role === "surface");
1568
+ if (surface) {
1569
+ fn += ` --surface-glass: color-mix(in oklch, var(--surface-bg) 72%, transparent);\n`;
1570
+ fn += ` --overlay: color-mix(in oklch, var(--color-${surface.hue}-950) 45%, transparent);\n`;
1571
+ }
1572
+ parts.push(`/* Functional role tokens (the public API) */\n:root {\n${fn}}\n`);
1573
+ }
1406
1574
  parts.push(`/* ── Framework tokens (consumed by the inlined components) ── */\n${twTypeTokensCss}\n${twPatternTokensCss}`);
1407
1575
  let darkBlock = "";
1408
- if (darkScheme) for (const [role, spec] of Object.entries(darkScheme.semantic)) {
1409
- const baseSpec = defaultSemantic[role];
1410
- if (baseSpec == null) continue;
1411
- const ramp = roleRamp(baseSpec);
1412
- for (const step of STEP_ORDER) {
1413
- if (palette[ramp]?.[step] == null) continue;
1414
- const alt = resolveSlot(spec, step, ramp);
1415
- if (srcEq(alt, resolveSlot(baseSpec, step, ramp))) continue;
1416
- darkBlock += ` --color-${role}${suffix(step)}: ${srcVar(alt)};\n`;
1417
- }
1576
+ for (const fr of scaleRoles) for (const [t, val] of Object.entries(fr.dark)) if (fr.light[t] !== val) darkBlock += ` ${fnVar(fr.role, t)}: ${val};\n`;
1577
+ {
1578
+ const surface = scaleRoles.find((r) => r.role === "surface");
1579
+ if (surface) darkBlock += ` --overlay: color-mix(in oklch, var(--color-${surface.hue}-950) 60%, transparent);\n`;
1418
1580
  }
1419
- if (darkBlock && darkScheme) parts.push(`/* Dark-mode semantic overrides */\n:root[data-brx-theme="${darkScheme.appearance}"] {\n${darkBlock}}\n`);
1581
+ if (darkBlock) parts.push(`/* Functional role tokens (dark) */\n:root[data-brx-theme="dark"] {\n color-scheme: dark;\n${darkBlock}}\n`);
1420
1582
  parts.push("/* State variant for the active flip (hover/focus-visible are built in). */\n@custom-variant is-active (&.is-active, &[data-active]);\n");
1421
1583
  const util = [];
1584
+ if (scaleRoles.length) {
1585
+ util.push(`/* Functional colour utilities (the public API) */`);
1586
+ for (const fr of scaleRoles) for (const row of fnUtilities(fr.role, [
1587
+ "bg",
1588
+ "text",
1589
+ "border"
1590
+ ], fr.role === "surface").split("\n")) {
1591
+ const m = /^\.([^ ]+) \{ (.*) \}$/.exec(row);
1592
+ if (m) util.push(`@utility ${m[1]} {\n ${m[2]}\n}`);
1593
+ }
1594
+ if (scaleRoles.some((r) => r.role === "surface")) util.push(`@utility glass {\n background-color: var(--surface-glass);\n backdrop-filter: blur(12px);\n -webkit-backdrop-filter: blur(12px);\n}`);
1595
+ }
1422
1596
  util.push(`/* Typography roles */`);
1423
1597
  for (const [role, spec] of Object.entries(typography?.roles ?? {})) util.push(`@utility font-${role} {\n${decls(roleDecls(role, spec))}\n}`);
1424
1598
  util.push(`\n/* Animation effects (pair with .transition or a driver) */`);
@@ -1480,6 +1654,11 @@ body {
1480
1654
  container-type: inline-size;
1481
1655
  }
1482
1656
 
1657
+ /* Class-level structure rules live in \`components\` so track/spacing utilities
1658
+ (@layer utilities — later in the layer order) can override them; unlayered
1659
+ CSS would beat ALL layered utilities regardless of specificity (e.g.
1660
+ \`.centered > *\` pinning children to \`measure\` over \`.spotlight\`). */
1661
+ @layer components {
1483
1662
  .rhythm {
1484
1663
  display: flow-root;
1485
1664
  }
@@ -1537,10 +1716,11 @@ body {
1537
1716
  var(--region-space-max, 8rem)
1538
1717
  );
1539
1718
  }
1719
+ }
1540
1720
  `);
1541
1721
  const animEngine = readFileSync(join(cssDir, "animation.css"), "utf8");
1542
- parts.push(`/* ── Animation engine (animation.css) ── */\n${animEngine}`);
1543
- parts.push(`/* ── Component patterns ── */\n${patternsCss}`);
1722
+ parts.push(`/* ── Animation engine (animation.css) ── */\n@layer components {\n${animEngine}\n}`);
1723
+ parts.push(`/* ── Component patterns ── */\n@layer components {\n${patternsCss}\n}`);
1544
1724
  const TW_CLASH = new Set([
1545
1725
  "block",
1546
1726
  "inline",
@@ -1639,7 +1819,7 @@ body {
1639
1819
  const componentCss = componentPartialOrder(cssDir).map((rel) => {
1640
1820
  return `/* ── ${rel} ── */\n${stripForTailwind(readFileSync(join(cssDir, rel), "utf8"))}`;
1641
1821
  }).join("\n\n");
1642
- parts.push(`/* ── Component + structural partials (Tailwind-clashing utilities dropped) ── */\n${componentCss}`);
1822
+ parts.push(`/* ── Component + structural partials (Tailwind-clashing utilities dropped) ── */\n@layer components {\n${componentCss}\n}`);
1643
1823
  return nl(parts) + "\n";
1644
1824
  }
1645
1825
  /**
@@ -2210,7 +2390,13 @@ const SiteConfigSchema = z.object({
2210
2390
  })),
2211
2391
  favicon: z.optional(z.object({
2212
2392
  source: z.string(),
2213
- lowResSource: z.optional(z.string())
2393
+ lowResSource: z.optional(z.string()),
2394
+ /** App name for the generated web manifest / PWA. */
2395
+ name: z.optional(z.string()),
2396
+ /** PWA theme color (also `<meta name="theme-color">`). */
2397
+ themeColor: z.optional(z.string()),
2398
+ /** PWA background color. */
2399
+ backgroundColor: z.optional(z.string())
2214
2400
  })),
2215
2401
  deployment: z.optional(z.object({
2216
2402
  platform: z.optional(z.string()),
@@ -2382,47 +2568,27 @@ function defaultConfig() {
2382
2568
  $schema: SCHEMA_URL,
2383
2569
  colors: {
2384
2570
  palette: {
2385
- brand: {
2386
- xxd: "#0a1f17",
2387
- xd: "#123a2b",
2388
- d: "#1f6b4f",
2389
- base: "#2e9b73",
2390
- l: "#5cbf9a",
2391
- xl: "#93dcc0",
2392
- xxl: "#c8f0e2"
2393
- },
2394
- ink: {
2395
- xxd: "#0b0e13",
2396
- xd: "#151a22",
2397
- d: "#2a323f",
2398
- base: "#3f4a5c",
2399
- l: "#6b7688",
2400
- xl: "#a3abb8",
2401
- xxl: "#d7dbe2"
2402
- }
2571
+ brand: { seed: "#2e9b73" },
2572
+ ink: { seed: "#3f4a5c" },
2573
+ sky: { seed: "#2f7bc0" },
2574
+ sun: { seed: "#d08a1f" },
2575
+ rose: { seed: "#c74a42" }
2576
+ },
2577
+ roles: {
2578
+ neutral: "ink",
2579
+ surface: "ink",
2580
+ "ui-primary": "brand",
2581
+ "brand-primary": "brand",
2582
+ info: "sky",
2583
+ success: "brand",
2584
+ warning: "sun",
2585
+ danger: "rose"
2403
2586
  },
2404
2587
  utilities: [
2405
2588
  "bg",
2406
2589
  "text",
2407
2590
  "border"
2408
- ],
2409
- schemes: {
2410
- default: {
2411
- appearance: "light",
2412
- semantic: {
2413
- "brand-primary": "brand",
2414
- surface: "ink",
2415
- neutral: "ink"
2416
- }
2417
- },
2418
- alternate: {
2419
- appearance: "dark",
2420
- semantic: {
2421
- surface: { invert: true },
2422
- neutral: { invert: true }
2423
- }
2424
- }
2425
- }
2591
+ ]
2426
2592
  },
2427
2593
  shadows: {
2428
2594
  sm: "0 1px 2px rgb(0 0 0 / 0.08)",