@getvitops/generator 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -6,6 +6,149 @@ import { transform } from "lightningcss";
6
6
  import * as z from "zod/mini";
7
7
  import { generateOklchPalette, hexToOklch, oklchStringToHex, pickOn } from "@getvitops/utils/color";
8
8
 
9
+ //#region src/shared.ts
10
+ /**
11
+ * Data shared between the generator (`generate.ts`) and the docs bundle
12
+ * (`docs.ts`). Lives in its own module because `generate.ts` imports `docs.ts` —
13
+ * importing back the other way would be a cycle.
14
+ */
15
+ /**
16
+ * Selector the dark functional-token flip hangs off.
17
+ *
18
+ * `data-brx-theme` is Bricks' own attribute — Bricks sets it, so it's what the
19
+ * WordPress target needs. Nothing sets it anywhere else, which meant the dark
20
+ * flip was unreachable outside Bricks: the shipped `<color-scheme-toggle>` web
21
+ * component writes `documentElement.dataset.theme` (i.e. `data-theme`), so
22
+ * clicking "Dark" changed an attribute no rule matched. Matching both makes the
23
+ * component work on every target without changing what Bricks already does.
24
+ *
25
+ * Note this covers the explicit choice only — there is deliberately no
26
+ * `prefers-color-scheme` block, so the toggle's "System" position currently
27
+ * resolves to light. Adding one would flip every existing consumer site dark for
28
+ * dark-OS users, which is a product decision, not a bug fix.
29
+ *
30
+ * Shared so the docs quote the selector the generator actually emits.
31
+ */
32
+ const DARK_SEL = ":root[data-brx-theme=\"dark\"], :root[data-theme=\"dark\"]";
33
+ /**
34
+ * Roles the shipped CSS partials reference with NO fallback, so a config that
35
+ * omits one leaves those rules resolving to nothing — an invalid declaration,
36
+ * not a graceful degrade.
37
+ *
38
+ * `colors.roles` is an open record: any name works and generates a full token
39
+ * set, utilities and dark flip. But the framework's own component CSS is
40
+ * written against these six, so "roles are arbitrary" is only half true — they
41
+ * are *extensible*, over a required core. `validate()` warns when one is
42
+ * missing; `required-roles.test.ts` re-derives this list from the partials so
43
+ * it cannot drift as components are added.
44
+ *
45
+ * Kept as a constant rather than scanned at call time because `validate()` is
46
+ * a pure function — the CLI, the Vite plugin and `toJSONSchema` all import it,
47
+ * and none of them should touch the filesystem to answer "is this config ok?".
48
+ */
49
+ const REQUIRED_ROLES = [
50
+ "brand-primary",
51
+ "danger",
52
+ "neutral",
53
+ "surface",
54
+ "ui-primary",
55
+ "warning"
56
+ ];
57
+ /**
58
+ * The functional-token and emphasis-stop suffixes a role resolves to. Shared so
59
+ * the required-roles drift test greps for exactly what the generator emits.
60
+ */
61
+ const ROLE_TOKEN_SUFFIXES = [
62
+ "bg",
63
+ "bg-muted",
64
+ "bg-bold",
65
+ "border",
66
+ "border-bold",
67
+ "solid",
68
+ "solid-bold",
69
+ "on-solid",
70
+ "text",
71
+ "text-muted",
72
+ "text-x-muted"
73
+ ];
74
+ /**
75
+ * Pattern base geometry props → per-pattern override-hook shorthand. Each hook
76
+ * wraps the authored value (`padding: var(--p-btn, 0.4em 0.8em)`) so a
77
+ * consumer restyles one pattern by setting one variable. The hook is named after
78
+ * the pattern's key, not its class.
79
+ */
80
+ const BASE_HOOK = {
81
+ padding: "p",
82
+ "border-radius": "br",
83
+ border: "b",
84
+ "box-shadow": "ds",
85
+ "font-size": "fs"
86
+ };
87
+ /**
88
+ * Framework utility classes whose names collide with Tailwind's own defaults.
89
+ * The `tailwind` format strips any rule targeting these — Tailwind provides
90
+ * them, so consumers use Tailwind's utilities for these names instead.
91
+ */
92
+ const TW_CLASH = new Set([
93
+ "block",
94
+ "inline",
95
+ "inline-block",
96
+ "flow-root",
97
+ "flex",
98
+ "inline-flex",
99
+ "grid",
100
+ "inline-grid",
101
+ "table",
102
+ "inline-table",
103
+ "table-caption",
104
+ "table-cell",
105
+ "table-row",
106
+ "contents",
107
+ "hidden",
108
+ "list-item",
109
+ "static",
110
+ "fixed",
111
+ "absolute",
112
+ "relative",
113
+ "sticky",
114
+ "isolate",
115
+ "visible",
116
+ "invisible",
117
+ "collapse",
118
+ "sr-only",
119
+ "not-sr-only",
120
+ "flex-row",
121
+ "flex-row-reverse",
122
+ "flex-col",
123
+ "flex-col-reverse",
124
+ "flex-wrap",
125
+ "flex-nowrap",
126
+ "items-start",
127
+ "items-end",
128
+ "items-center",
129
+ "items-baseline",
130
+ "items-stretch",
131
+ "justify-start",
132
+ "justify-end",
133
+ "justify-center",
134
+ "justify-between",
135
+ "justify-around",
136
+ "justify-evenly",
137
+ "content-start",
138
+ "content-end",
139
+ "content-center",
140
+ "content-between",
141
+ "content-around",
142
+ "content-evenly",
143
+ "text-left",
144
+ "text-center",
145
+ "text-right",
146
+ "text-justify",
147
+ "text-start",
148
+ "text-end"
149
+ ]);
150
+
151
+ //#endregion
9
152
  //#region src/schema.ts
10
153
  /**
11
154
  * Canonical `design-system.json` schema — the single source of truth.
@@ -42,7 +185,7 @@ const UtilityName = z.enum([
42
185
  ]);
43
186
  const Colors = desc(z.object({
44
187
  palette: desc(z.record(z.string(), Ramp), "Palette hues by name. Each becomes an 11-step numeric OKLCH scale (`--color-<hue>-50…950`)."),
45
- roles: desc(z.record(z.string(), z.string()), "Maps each semantic role (neutral, surface, ui-primary/secondary/accent, brand-primary/secondary, info, success, warning, danger) to the palette hue backing it. The generator derives every role's FUNCTIONAL tokens (`--<role>-{bg,bg-muted,border,border-bold,solid,solid-bold,on-solid,text,text-muted,text-x-muted}`) plus emphasis stops from the hue's scale, and dark mode flips automatically — there is no per-appearance scheme grammar."),
188
+ roles: desc(z.record(z.string(), z.string()), `Maps semantic role names onto palette hues. Role names are ARBITRARY — add a key and the generator emits that role's full FUNCTIONAL token set (\`--<role>-{bg,bg-muted,border,border-bold,solid,solid-bold,on-solid,text,text-muted,text-x-muted}\`), its emphasis stops, its dark-mode flip and its utility classes. Dark mode flips automatically; there is no per-appearance scheme grammar. Six roles are a required core, because the shipped framework CSS references them with no fallback: ${REQUIRED_ROLES.join(", ")}. Conventional additions are ui-secondary/accent, brand-secondary, info and success.`),
46
189
  utilities: z.optional(desc(z.array(UtilityName), "Which colour utility-class families to emit (`bg-*`, `text-*`, `border-*`, `outline-*`, `fill-*`, `stroke-*`). Defaults to bg, text, border."))
47
190
  }), "The colour system (the only required section): `palette` hues become generated OKLCH scales; `roles` map semantic roles onto those hues, from which all functional tokens and dark mode derive.");
48
191
  const Scale = z.object({
@@ -122,13 +265,20 @@ const jsonSchema = {
122
265
  $id: SCHEMA_URL,
123
266
  ...z.toJSONSchema(DesignSystemSchema, { target: "draft-2020-12" })
124
267
  };
125
- /** Validate an unknown value against the design-system schema. */
268
+ /**
269
+ * Validate an unknown value against the design-system schema.
270
+ *
271
+ * `warnings` are configurations that parse and generate, but produce output that
272
+ * won't behave as authored — currently only the token-namespace collisions the
273
+ * flat `--<prop>-<name>` grammar makes possible.
274
+ */
126
275
  function validate(input) {
127
276
  const result = z.safeParse(DesignSystemSchema, input);
128
277
  if (!result.success) return {
129
278
  ok: false,
130
279
  data: void 0,
131
- errors: result.error.issues
280
+ errors: result.error.issues,
281
+ warnings: []
132
282
  };
133
283
  const { palette, roles } = result.data.colors;
134
284
  for (const [role, hue] of Object.entries(roles)) if (palette[hue] == null) return {
@@ -142,99 +292,23 @@ function validate(input) {
142
292
  role
143
293
  ],
144
294
  message: `role "${role}" references unknown palette hue "${hue}"`
145
- }]
295
+ }],
296
+ warnings: []
146
297
  };
298
+ const warnings = [];
299
+ const missing = REQUIRED_ROLES.filter((r) => roles[r] == null);
300
+ if (missing.length) warnings.push(`colors.roles is missing ${missing.map((r) => `"${r}"`).join(", ")} — the framework's own component CSS references ${missing.length === 1 ? "it" : "them"} with no fallback, so those components will render uncoloured. Add any role you like, but keep these defined.`);
301
+ const radii = Object.keys(result.data.patterns?.radii ?? {});
302
+ const items = new Set(Object.keys(result.data.patterns?.items ?? {}));
303
+ for (const name of radii) if (items.has(name)) warnings.push(`patterns.radii.${name} collides with the "${name}" pattern on --br-${name}; the pattern's override hook wins — rename the radius`);
147
304
  return {
148
305
  ok: true,
149
306
  data: result.data,
150
- errors: []
307
+ errors: [],
308
+ warnings
151
309
  };
152
310
  }
153
311
 
154
- //#endregion
155
- //#region src/shared.ts
156
- /**
157
- * Data shared between the generator (`generate.ts`) and the docs bundle
158
- * (`docs.ts`). Lives in its own module because `generate.ts` imports `docs.ts` —
159
- * importing back the other way would be a cycle.
160
- */
161
- /**
162
- * Pattern base geometry props → per-pattern override-hook shorthand. Each hook
163
- * wraps the authored value (`padding: var(--p-btn, 0.4em 0.8em)`) so a
164
- * consumer restyles one pattern by setting one variable. The hook is named after
165
- * the pattern's key, not its class.
166
- */
167
- const BASE_HOOK = {
168
- padding: "p",
169
- "border-radius": "br",
170
- border: "b",
171
- "box-shadow": "ds",
172
- "font-size": "fs"
173
- };
174
- /**
175
- * Framework utility classes whose names collide with Tailwind's own defaults.
176
- * The `tailwind` format strips any rule targeting these — Tailwind provides
177
- * them, so consumers use Tailwind's utilities for these names instead.
178
- */
179
- const TW_CLASH = new Set([
180
- "block",
181
- "inline",
182
- "inline-block",
183
- "flow-root",
184
- "flex",
185
- "inline-flex",
186
- "grid",
187
- "inline-grid",
188
- "table",
189
- "inline-table",
190
- "table-caption",
191
- "table-cell",
192
- "table-row",
193
- "contents",
194
- "hidden",
195
- "list-item",
196
- "static",
197
- "fixed",
198
- "absolute",
199
- "relative",
200
- "sticky",
201
- "isolate",
202
- "visible",
203
- "invisible",
204
- "collapse",
205
- "sr-only",
206
- "not-sr-only",
207
- "flex-row",
208
- "flex-row-reverse",
209
- "flex-col",
210
- "flex-col-reverse",
211
- "flex-wrap",
212
- "flex-nowrap",
213
- "items-start",
214
- "items-end",
215
- "items-center",
216
- "items-baseline",
217
- "items-stretch",
218
- "justify-start",
219
- "justify-end",
220
- "justify-center",
221
- "justify-between",
222
- "justify-around",
223
- "justify-evenly",
224
- "content-start",
225
- "content-end",
226
- "content-center",
227
- "content-between",
228
- "content-around",
229
- "content-evenly",
230
- "text-left",
231
- "text-center",
232
- "text-right",
233
- "text-justify",
234
- "text-start",
235
- "text-end"
236
- ]);
237
-
238
312
  //#endregion
239
313
  //#region src/tokens.ts
240
314
  /**
@@ -373,6 +447,11 @@ const EMPHASIS_DARK = {
373
447
  bold: "300",
374
448
  "x-bold": "100"
375
449
  };
450
+ /**
451
+ * The emphasis-stop names, derived from the table rather than re-listed, so the
452
+ * utility emitter in `generate.ts` cannot drift from the tokens it emits.
453
+ */
454
+ const EMPHASIS_STOPS = Object.keys(EMPHASIS_LIGHT);
376
455
  /** Resolve the functional token set for one role over its hue. */
377
456
  function functionalRole(role, hueName, hue) {
378
457
  const numeric = hue.numeric;
@@ -968,9 +1047,17 @@ Families: ${code(families)} (\`--font-*\`).
968
1047
  ## Colour
969
1048
 
970
1049
  **Functional tokens are the primary vocabulary** — classes name the *job*, not the tone, and
971
- every one remaps automatically under \`:root[data-brx-theme="dark"]\` (background/text ends
1050
+ every one remaps automatically under \`${DARK_SEL}\` (background/text ends
972
1051
  swap; \`solid\` fills stay mode-stable with a computed \`on-\` foreground). Prefer these over
973
- raw steps. Rules (role ∈ ${code(roles)}):
1052
+ raw steps.
1053
+
1054
+ **Role names are yours.** \`colors.roles\` is an open map: add a key, and the generator emits
1055
+ that role's full functional token set, its dark flip and every utility below. Your config
1056
+ currently defines ${code(roles)}. Six of those are a **required core** — the framework's own
1057
+ component CSS references \`${REQUIRED_ROLES.join("`, `")}\` with no fallback, so removing one
1058
+ leaves those components uncoloured (\`vitops validate\` warns). Everything beyond them is free.
1059
+
1060
+ Rules (role ∈ ${code(roles)}, or whatever you add):
974
1061
 
975
1062
  - **Surfaces** — \`bg-<role>\` (the role's background wash; for \`surface\` this is the
976
1063
  card/panel plane), \`bg-<role>-muted\` (subtle / sunken), \`bg-surface-bold\` (raised plane).
@@ -984,9 +1071,10 @@ raw steps. Rules (role ∈ ${code(roles)}):
984
1071
  \`--color-<role>-{x-muted,muted,bold,x-bold}\` — \`muted\` recedes toward the background
985
1072
  extreme, \`bold\` advances toward the foreground, in *either* appearance.
986
1073
 
987
- Everyday pairings: page \`bg-neutral\` + \`text-neutral\`; cards \`bg-surface\`; captions
988
- \`text-neutral-muted\`; buttons \`bg-ui-primary-solid text-on-ui-primary\`; status text
989
- \`text-danger\` / \`text-success\` / \`text-warning\` / \`text-info\`.
1074
+ Everyday pairings, using the roles this config defines: page
1075
+ \`bg-${roles[0] ?? "neutral"}\` + \`text-${roles[0] ?? "neutral"}\`; captions
1076
+ \`text-${roles[0] ?? "neutral"}-muted\`; buttons
1077
+ \`bg-${roles[2] ?? roles[0] ?? "ui-primary"}-solid text-on-${roles[2] ?? roles[0] ?? "ui-primary"}\`.
990
1078
 
991
1079
  **Raw scale** (secondary / fine control) — rule \`<util>-<hue>-<step>\` with util ∈
992
1080
  ${code(utils)}: every hue is an 11-step OKLCH scale generated from its seed (or fixed brand
@@ -994,6 +1082,28 @@ tones), numeric steps \`50\` … \`950\` (tinted near-white → tinted near-blac
994
1082
  \`bg-${ramps[0] ?? "brand"}-100\`, \`text-${ramps[0] ?? "brand"}-800\`. Hues: ${code(ramps)}.
995
1083
  The **bare** role name (\`bg-<role>\`, \`text-<role>\`) is always the functional token.
996
1084
 
1085
+ > **Raw scale classes are frozen — they do NOT remap in dark mode.**
1086
+ > \`bg-${ramps[0] ?? "brand"}-800\` is that exact colour in every appearance. The automatic
1087
+ > dark flip described above applies **only** to the functional role tokens, because
1088
+ > \`--color-<hue>-<step>\` is emitted once and never re-pointed under \`${DARK_SEL}\`.
1089
+ > A raw step on a page that can switch appearance is a latent bug: it looks correct in
1090
+ > whichever mode you built it in and inverts in the other.
1091
+ >
1092
+ > If you are reaching for a raw step, you usually want a **role** instead — and roles are
1093
+ > extensible, so adding one is a two-line config change:
1094
+ >
1095
+ > | instead of | use | why |
1096
+ > | --- | --- | --- |
1097
+ > | \`bg-<hue>-50\` / \`-950\` | \`bg-<role>\` | the page/backdrop plane, flips automatically |
1098
+ > | \`bg-<hue>-100\` / \`-900\` | \`bg-<role>-muted\` | one plane in from the page |
1099
+ > | \`bg-<hue>-500\`…\`-700\` | \`bg-<role>-solid\` | vivid fill, mode-stable, pairs with \`text-on-<role>\` |
1100
+ > | \`text-<hue>-950\` / \`-50\` | \`text-<role>\` | contrast-guaranteed body text |
1101
+ > | \`text-<hue>-800\` / \`-200\` | \`text-<role>-muted\` | secondary text |
1102
+ > | \`border-<hue>-200\` / \`-800\` | \`border-<role>\` | |
1103
+ >
1104
+ > Raw steps stay the right tool for genuinely fixed colours — a brand mark, a chart series,
1105
+ > an illustration — where the value must not move between appearances.
1106
+
997
1107
  ## Shadows
998
1108
 
999
1109
  Rule: **\`drop-shadow-<size>\`** — size ∈ ${code(shadows)} (applied as a \`filter\`, so it
@@ -1155,9 +1265,27 @@ ${clashList.map((c) => `\`${c}\``).join(", ")}
1155
1265
  Other Tailwind-specific behaviour:
1156
1266
 
1157
1267
  - **Variants are Tailwind's job.** No pre-expanded breakpoint/state classes are emitted
1158
- (the framework's \`@container (min-width: …)\` variant blocks are dropped); use Tailwind
1159
- syntax — \`@md:split-1-2\`, \`hover:flip-fade-in\`. Container breakpoints are registered as
1268
+ (the framework's \`@container (min-width: …)\` **variant** blocks are dropped; component
1269
+ container queries such as the sitenav's desktop switch are kept); use Tailwind syntax —
1270
+ \`@md:split-1-2\`, \`hover:flip-fade-in\`. Container breakpoints are registered as
1160
1271
  \`--container-{sm,md,lg,xl}\` = 30/48/64/80rem, backing the \`@sm:\`…\`@xl:\` variants.
1272
+
1273
+ > **Three spellings, and only two of them work here.**
1274
+ >
1275
+ > | you write | in \`tailwind\` | note |
1276
+ > | --- | --- | --- |
1277
+ > | \`@md:flex-row\` | ✅ container query | the framework's breakpoints (48rem) |
1278
+ > | \`md:flex-row\` | ✅ media query | **Tailwind's** breakpoints, which differ — \`sm:\` is 40rem where \`@sm:\` is 30rem |
1279
+ > | \`md-flex-row\` | ❌ silently nothing | the css/bricks spelling; not emitted in this format |
1280
+ >
1281
+ > \`md-*\` is the trap: it is a real class in \`css\`/\`bricks\` and a no-op here, and
1282
+ > nothing errors — the element simply never changes at the breakpoint. Prefer \`@md:\`
1283
+ > so one vocabulary of breakpoints applies throughout.
1284
+
1285
+ - **Overriding \`--container-*\` also moves Tailwind's width scale.** Registering the
1286
+ framework breakpoints in \`@theme\` re-points \`max-w-sm\`…\`max-w-xl\` at the same values,
1287
+ so \`max-w-md\` is 48rem here rather than Tailwind's stock 28rem. Use
1288
+ \`max-w-(--container-md)\` style arbitrary values if you need to be explicit.
1161
1289
  - **The space scale is NOT mapped into Tailwind's \`--spacing-*\` namespace** (that would
1162
1290
  corrupt Tailwind's numeric multipliers and \`max-w-*\` sizes). Numeric utilities like
1163
1291
  \`p-4\` keep Tailwind's 0.25rem meaning; use the design-system scale via arbitrary
@@ -1167,6 +1295,18 @@ Other Tailwind-specific behaviour:
1167
1295
  (\`bg-<role>\`, \`text-<role>-muted\`, …) is the public API. The raw hue scales ARE
1168
1296
  \`@theme\` colours, so native \`bg-<hue>-500\`-style utilities work.
1169
1297
 
1298
+ This split is deliberate and load-bearing. When a token sits in \`@theme\` *and* an
1299
+ \`@utility\` of the derived name exists, Tailwind merges both into one rule with the
1300
+ \`@theme\` declaration last — regardless of source order. Promoting the role tokens
1301
+ would therefore silently replace the functional plane with the emphasis stop on
1302
+ \`bg-<role>-muted\`, \`text-<role>-muted\`, \`text-<role>-x-muted\`, \`border-<role>-bold\`
1303
+ and \`bg-surface-bold\`, two of which are the contrast-guaranteed text tokens.
1304
+ - **\`colors.utilities\` is a floor here, not a ceiling.** It controls which families get
1305
+ explicit role \`@utility\` rules, exactly as in \`css\`/\`bricks\`. But the raw hue scales
1306
+ are \`@theme\` colours, and Tailwind derives *every* colour family from those on demand
1307
+ (\`ring-\`, \`divide-\`, \`accent-\`, \`caret-\`, …), so hue-step utilities you did not enable
1308
+ still resolve in this format.
1309
+
1170
1310
  ## \`css\` — standalone bundle
1171
1311
 
1172
1312
  Emits a bundled, self-contained \`styles.css\` + \`tokens.json\` + \`design-manifest.json\`.
@@ -1175,6 +1315,26 @@ pre-expanded — including breakpoint/state variants with \`-\` separators
1175
1315
  (\`md-split-1-2\`, \`hover-fade-in\`). For non-Bricks, non-Tailwind consumers and the
1176
1316
  docs build.
1177
1317
 
1318
+ **Cascade layers.** The bundle ships three, in precedence order:
1319
+
1320
+ \`\`\`
1321
+ @layer vitops.base, vitops.components, vitops.utilities;
1322
+ \`\`\`
1323
+
1324
+ - \`vitops.base\` — the UA reset and the pure \`:root\` token blocks.
1325
+ - \`vitops.components\` — the animation engine, structural layout, and every pattern.
1326
+ - \`vitops.utilities\` — \`bg-*\`, \`text-*\`, \`border-*\`, \`drop-shadow-*\`, \`font-*\`,
1327
+ animation effects, and the display/\`sr-only\` families.
1328
+
1329
+ So a utility overrides a pattern: \`class="card bg-danger-muted"\` tints the card. Your own
1330
+ unlayered CSS beats all three — see [concepts/patterns.md](concepts/patterns.md) for the
1331
+ override story and the one gotcha (a reset must be layered and ordered first).
1332
+
1333
+ Known gap: \`layout.css\` is a single partial mixing structural rules (\`.rhythm\`,
1334
+ \`.centered\`) with utilities (\`.m-*\`, \`.flex\`, \`.split-*\`), so it sits in
1335
+ \`vitops.components\` whole — its utility half cannot yet override a pattern. Splitting it is
1336
+ tracked separately.
1337
+
1178
1338
  ## \`bricks\` — WordPress / Bricks Builder payload
1179
1339
 
1180
1340
  Emits the full deployable theme payload: \`styles.min.css\`, the Bricks import JSONs
@@ -1248,11 +1408,16 @@ Classes name the token, not the tone: \`bg-<role>\`, \`text-<role>-muted\`, \`te
1248
1408
 
1249
1409
  ## Automatic dark mode
1250
1410
 
1251
- Dark mode is a **functional flip** under \`:root[data-brx-theme="dark"]\`: background and
1411
+ Dark mode is a **functional flip** under \`${DARK_SEL}\`: background and
1252
1412
  text ends of each scale swap, while \`solid\` fills stay mode-stable with a recomputed
1253
1413
  \`on-solid\` foreground. There is no per-appearance scheme grammar to author and no named
1254
1414
  steps — a role token means the same *job* in both appearances.
1255
1415
 
1416
+ Two attributes, one flip: \`data-brx-theme\` is Bricks' own (Bricks sets it on the
1417
+ WordPress target), \`data-theme\` is what the shipped \`<color-scheme-toggle>\` writes on
1418
+ \`<html>\`, so the toggle works on every other target. Set either. There is deliberately
1419
+ no \`prefers-color-scheme\` block — the flip follows an explicit choice only.
1420
+
1256
1421
  ## Contrast guarantees
1257
1422
 
1258
1423
  Text tokens target APCA Lc ≥ 75 and muted text Lc ≥ 60, in **both** appearances —
@@ -1329,9 +1494,34 @@ function renderPatternsConcept(ds) {
1329
1494
  # Component patterns — the CSS chain
1330
1495
 
1331
1496
  Patterns (currently ${code(patternNames)}) are authored declaratively under \`patterns\` in
1332
- \`design-system.json\` (see [authoring.md](../authoring.md)) and compiled to CSS in the
1333
- **\`components\` cascade layer** — so utility classes (declared in a later layer) always win
1334
- over pattern styling without specificity fights.
1497
+ \`design-system.json\` (see [authoring.md](../authoring.md)) and compiled to CSS in a
1498
+ **components cascade layer** — so utility classes, which live in a later layer, always win over
1499
+ pattern styling without specificity fights. \`class="card bg-danger-muted"\` tints the card.
1500
+
1501
+ The layer names differ by format, because each defers to its host:
1502
+
1503
+ | format | layer stack (last wins) |
1504
+ | --- | --- |
1505
+ | \`css\`, \`bricks\` | \`vitops.base\` → \`vitops.components\` → \`vitops.utilities\` |
1506
+ | \`tailwind\` | Tailwind's own \`theme\` → \`base\` → \`components\` → \`utilities\` |
1507
+
1508
+ **Your own CSS beats all of it.** Unlayered styles outrank every cascade layer regardless of
1509
+ specificity, so a plain stylesheet, an Astro scoped \`<style>\`, or a Bricks-authored class
1510
+ overrides the framework with no \`!important\` and no specificity escalation. That is the
1511
+ intended override story.
1512
+
1513
+ The one thing to watch: a **reset** must be layered too, and ordered *below* the framework —
1514
+ left unlayered it beats the very component rules it is meant to sit under (a bare
1515
+ \`p { margin: 0 }\` would defeat \`.rhythm\`). Declare the order before you load the stylesheet,
1516
+ since layer precedence is fixed by first declaration:
1517
+
1518
+ \`\`\`html
1519
+ <style>@layer my.reset, vitops.base, vitops.components, vitops.utilities;</style>
1520
+ <link rel="stylesheet" href="/styles.css">
1521
+ \`\`\`
1522
+
1523
+ Put it *after* the link and \`my.reset\` becomes a new name introduced later — which sorts
1524
+ **last**, i.e. highest priority, and the reset wins. That is the one non-obvious step.
1335
1525
 
1336
1526
  ## 1 — Token cascade
1337
1527
 
@@ -1502,24 +1692,55 @@ const UTILITY_PROPS = {
1502
1692
  stroke: "stroke"
1503
1693
  };
1504
1694
  const fnVar = (role, token) => token.startsWith("stop-") ? `--color-${role}-${token.slice(5)}` : `--${role}-${token}`;
1505
- const fnUtilities = (role, utilities, isSurface) => {
1506
- const rows = [];
1507
- if (utilities.includes("bg")) {
1508
- rows.push(`.bg-${role} { background-color: var(--${role}-bg); }`);
1509
- rows.push(`.bg-${role}-muted { background-color: var(--${role}-bg-muted); }`);
1510
- rows.push(`.bg-${role}-solid { background-color: var(--${role}-solid); }`);
1511
- rows.push(`.bg-${role}-solid-bold { background-color: var(--${role}-solid-bold); }`);
1512
- if (isSurface) rows.push(`.bg-${role}-bold { background-color: var(--${role}-bg-bold); }`);
1695
+ /**
1696
+ * Every role colour utility, each class name emitted EXACTLY once.
1697
+ *
1698
+ * Two axes share the `<family>-<role>-<modifier>` namespace: the functional
1699
+ * background/text/border **planes** (`--<role>-bg-muted`) and the
1700
+ * appearance-relative emphasis **stops** (`--color-<role>-muted`). Where both
1701
+ * would produce the same class the plane wins.
1702
+ *
1703
+ * That precedence used to be implicit — the css path emitted the stop matrix,
1704
+ * then the planes, and relied on the CSS minifier dropping the shadowed
1705
+ * earlier rule. The tailwind path never ran the stop half at all, so the two
1706
+ * formats shipped different vocabularies (87 role classes existed in css and
1707
+ * not in tailwind) with nothing to catch it. Deciding it here, once, is what
1708
+ * keeps all three formats in step; `format-parity.test.ts` holds them there.
1709
+ *
1710
+ * Order is stops-then-planes to match the css bundle's historical rule order.
1711
+ */
1712
+ function roleColorUtilities(roles, utilities) {
1713
+ const plane = (cls, prop, value) => ({
1714
+ cls,
1715
+ prop,
1716
+ value,
1717
+ axis: "plane"
1718
+ });
1719
+ const planes = [];
1720
+ for (const { role } of roles) {
1721
+ if (utilities.includes("bg")) {
1722
+ planes.push(plane(`bg-${role}`, "background-color", `var(--${role}-bg)`), plane(`bg-${role}-muted`, "background-color", `var(--${role}-bg-muted)`), plane(`bg-${role}-solid`, "background-color", `var(--${role}-solid)`), plane(`bg-${role}-solid-bold`, "background-color", `var(--${role}-solid-bold)`));
1723
+ if (role === "surface") planes.push(plane(`bg-${role}-bold`, "background-color", `var(--${role}-bg-bold)`));
1724
+ }
1725
+ if (utilities.includes("text")) planes.push(plane(`text-${role}`, "color", `var(--${role}-text)`), plane(`text-${role}-muted`, "color", `var(--${role}-text-muted)`), plane(`text-${role}-x-muted`, "color", `var(--${role}-text-x-muted)`), plane(`text-on-${role}`, "color", `var(--${role}-on-solid)`));
1726
+ if (utilities.includes("border")) planes.push(plane(`border-${role}`, "border-color", `var(--${role}-border)`), plane(`border-${role}-bold`, "border-color", `var(--${role}-border-bold)`));
1513
1727
  }
1514
- if (utilities.includes("text")) {
1515
- rows.push(`.text-${role} { color: var(--${role}-text); }`);
1516
- rows.push(`.text-${role}-muted { color: var(--${role}-text-muted); }`);
1517
- rows.push(`.text-${role}-x-muted { color: var(--${role}-text-x-muted); }`);
1518
- rows.push(`.text-on-${role} { color: var(--${role}-on-solid); }`);
1728
+ const claimed = new Set(planes.map((p) => p.cls));
1729
+ const stops = [];
1730
+ for (const { role } of roles) for (const stop of EMPHASIS_STOPS) for (const fam of utilities) {
1731
+ const cls = `${fam}-${role}-${stop}`;
1732
+ if (claimed.has(cls) || !(fam in UTILITY_PROPS)) continue;
1733
+ stops.push({
1734
+ cls,
1735
+ prop: UTILITY_PROPS[fam],
1736
+ value: `var(--color-${role}-${stop})`,
1737
+ axis: "stop"
1738
+ });
1519
1739
  }
1520
- if (utilities.includes("border")) rows.push(`.border-${role} { border-color: var(--${role}-border); }`, `.border-${role}-bold { border-color: var(--${role}-border-bold); }`);
1521
- return rows.join("\n");
1522
- };
1740
+ return [...stops, ...planes];
1741
+ }
1742
+ /** Render the shared set as plain rules (css/bricks formats). */
1743
+ const colorUtilityRules = (u) => u.map(({ cls, prop, value }) => `.${cls} { ${prop}: ${value}; }`).join("\n");
1523
1744
  const decls = (obj, indent = " ") => Object.entries(obj).map(([k, v]) => `${indent}${k}: ${v};`).join("\n");
1524
1745
  const round = (n) => Number(n.toFixed(4)).toString();
1525
1746
  const rem = (v) => parseFloat(v);
@@ -1631,21 +1852,13 @@ function build(ds, format, assetsDir) {
1631
1852
  if (surfaceRole) fb += ` --overlay: color-mix(in oklch, var(--color-${surfaceRole.hue}-950) 60%, transparent);\n`;
1632
1853
  if (fb) {
1633
1854
  css += `/* Functional role tokens (dark) */\n`;
1634
- css += `:root[data-brx-theme="dark"] {\n color-scheme: dark;\n${fb}}\n\n`;
1855
+ css += `${DARK_SEL} {\n color-scheme: dark;\n${fb}}\n\n`;
1635
1856
  }
1636
1857
  }
1637
- const allTokens = [];
1638
- for (const [name, hue] of Object.entries(expandedPalette)) for (const n of Object.keys(hue.numeric)) allTokens.push(`${name}-${n}`);
1639
- for (const fr of scaleRoles) for (const stop of [
1640
- "x-muted",
1641
- "muted",
1642
- "bold",
1643
- "x-bold"
1644
- ]) allTokens.push(`${fr.role}-${stop}`);
1645
1858
  css += `/* Colour utilities — ${UTILITIES.join(", ")} */\n`;
1646
- for (const token of allTokens) css += UTILITIES.map((cls) => `.${cls}-${token} { ${UTILITY_PROPS[cls]}: var(--color-${token}); }`).join("\n") + "\n";
1647
- css += `\n/* Functional utilities (the public API) */\n`;
1648
- for (const fr of scaleRoles) css += `${fnUtilities(fr.role, UTILITIES, fr.role === "surface")}\n`;
1859
+ for (const [name, hue] of Object.entries(expandedPalette)) for (const n of Object.keys(hue.numeric)) css += UTILITIES.map((cls) => `.${cls}-${name}-${n} { ${UTILITY_PROPS[cls]}: var(--color-${name}-${n}); }`).join("\n") + "\n";
1860
+ css += `\n/* Role utilities (the public API) */\n`;
1861
+ css += `${colorUtilityRules(roleColorUtilities(scaleRoles, UTILITIES))}\n`;
1649
1862
  if (surfaceRole) css += `.glass { background-color: var(--surface-glass); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }\n`;
1650
1863
  generated["color.css"] = css;
1651
1864
  }
@@ -1936,11 +2149,25 @@ function build(ds, format, assetsDir) {
1936
2149
  }, null, 2),
1937
2150
  designManifest: (() => {
1938
2151
  const reverseIndex = {};
1939
- for (const [name, hue] of Object.entries(expandedPalette)) for (const n of Object.keys(hue.numeric)) reverseIndex[`--color-${name}-${n}`] = `colors.palette.${name}.seed`;
2152
+ 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}`;
1940
2153
  const colorRoles = Object.entries(roleMap).map(([name, ramp]) => ({
1941
2154
  name,
1942
2155
  ramp
1943
2156
  }));
2157
+ const roleTokens = Object.fromEntries(Object.entries(expandedPalette).map(([hueName, hue]) => {
2158
+ const def = functionalRole("_", hueName, hue);
2159
+ const sfc = functionalRole("surface", hueName, hue);
2160
+ return [hueName, {
2161
+ default: {
2162
+ light: def.light,
2163
+ dark: def.dark
2164
+ },
2165
+ surface: {
2166
+ light: sfc.light,
2167
+ dark: sfc.dark
2168
+ }
2169
+ }];
2170
+ }));
1944
2171
  const typoRoles = typography?.roles ?? {};
1945
2172
  const hooks = {};
1946
2173
  for (const [key, [prop, sfx]] of Object.entries(TYPO_KEYMAP)) hooks[sfx] = {
@@ -1953,7 +2180,6 @@ function build(ds, format, assetsDir) {
1953
2180
  const pGroups = patterns?.groups ?? {};
1954
2181
  const pItems = patterns?.items ?? {};
1955
2182
  for (const prop of Object.keys(pDefaults)) reverseIndex[`--${prop}-default`] = `patterns.defaults.${prop}`;
1956
- for (const name of Object.keys(pRadii)) reverseIndex[`--br-${name}`] = `patterns.radii.${name}`;
1957
2183
  for (const [group, props] of Object.entries(pGroups)) for (const prop of Object.keys(props)) reverseIndex[`--${prop}-${group}`] = `patterns.groups.${group}.${prop}`;
1958
2184
  const items = Object.entries(pItems).map(([name, p]) => {
1959
2185
  const base = p.base ?? {};
@@ -1970,12 +2196,14 @@ function build(ds, format, assetsDir) {
1970
2196
  roles: p.roles ?? []
1971
2197
  };
1972
2198
  });
2199
+ for (const name of Object.keys(pRadii)) if (!(`--br-${name}` in reverseIndex)) reverseIndex[`--br-${name}`] = `patterns.radii.${name}`;
1973
2200
  return JSON.stringify({
1974
2201
  colors: {
1975
2202
  ramps: Object.keys(expandedPalette),
1976
2203
  steps: NUMERIC_STEPS,
1977
2204
  palette: numericPalette,
1978
- roles: colorRoles
2205
+ roles: colorRoles,
2206
+ roleTokens
1979
2207
  },
1980
2208
  typography: {
1981
2209
  roles: Object.keys(typoRoles),
@@ -2003,10 +2231,6 @@ function build(ds, format, assetsDir) {
2003
2231
  items
2004
2232
  },
2005
2233
  shadows: shadows ?? {},
2006
- interaction: {
2007
- duration: "200ms",
2008
- easing: "ease"
2009
- },
2010
2234
  reverseIndex
2011
2235
  }, null, 2);
2012
2236
  })(),
@@ -2022,7 +2246,8 @@ function build(ds, format, assetsDir) {
2022
2246
  twTypeTokensCss,
2023
2247
  twPatternTokensCss,
2024
2248
  patternsCss: pat,
2025
- assetsDir
2249
+ assetsDir,
2250
+ utilities: UTILITIES
2026
2251
  }),
2027
2252
  twTypeTokensCss,
2028
2253
  twPatternTokensCss,
@@ -2030,10 +2255,10 @@ function build(ds, format, assetsDir) {
2030
2255
  };
2031
2256
  }
2032
2257
  function emitTailwind(ctx) {
2033
- const { ds, expandedPalette, scaleRoles, shadows, typeSteps, spaceSteps, typography, roleDecls, twTypeTokensCss, twPatternTokensCss, patternsCss, assetsDir } = ctx;
2258
+ const { ds, expandedPalette, scaleRoles, shadows, typeSteps, spaceSteps, typography, roleDecls, twTypeTokensCss, twPatternTokensCss, patternsCss, assetsDir, utilities } = ctx;
2034
2259
  const cssDir = join(assetsDir, "css");
2035
2260
  const nl = (arr) => arr.filter(Boolean).join("\n");
2036
- const parts = [`/* GENERATED for Tailwind v4 (Astro) — do not edit by hand. */`];
2261
+ const parts = ["/* GENERATED for Tailwind v4 (Astro) — do not edit by hand.\n Class reference: npx vitops docs classes · All topics: npx vitops docs */"];
2037
2262
  parts.push(`@import "tailwindcss";\n`);
2038
2263
  const theme = [];
2039
2264
  theme.push(` /* Hue scales → bg-<hue>-<step> text-* border-* (numeric, 50…950) */`);
@@ -2070,19 +2295,12 @@ function emitTailwind(ctx) {
2070
2295
  const surface = scaleRoles.find((r) => r.role === "surface");
2071
2296
  if (surface) darkBlock += ` --overlay: color-mix(in oklch, var(--color-${surface.hue}-950) 60%, transparent);\n`;
2072
2297
  }
2073
- if (darkBlock) parts.push(`/* Functional role tokens (dark) */\n:root[data-brx-theme="dark"] {\n color-scheme: dark;\n${darkBlock}}\n`);
2298
+ if (darkBlock) parts.push(`/* Functional role tokens (dark) */\n${DARK_SEL} {\n color-scheme: dark;\n${darkBlock}}\n`);
2074
2299
  parts.push("/* State variant for the active flip (hover/focus-visible are built in). */\n@custom-variant is-active (&.is-active, &[data-active]);\n");
2075
2300
  const util = [];
2076
2301
  if (scaleRoles.length) {
2077
- util.push(`/* Functional colour utilities (the public API) */`);
2078
- for (const fr of scaleRoles) for (const row of fnUtilities(fr.role, [
2079
- "bg",
2080
- "text",
2081
- "border"
2082
- ], fr.role === "surface").split("\n")) {
2083
- const m = /^\.([^ ]+) \{ (.*) \}$/.exec(row);
2084
- if (m) util.push(`@utility ${m[1]} {\n ${m[2]}\n}`);
2085
- }
2302
+ util.push(`/* Role colour utilities (the public API) */`);
2303
+ for (const { cls, prop, value } of roleColorUtilities(scaleRoles, utilities)) util.push(`@utility ${cls} {\n ${prop}: ${value};\n}`);
2086
2304
  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}`);
2087
2305
  }
2088
2306
  util.push(`/* Typography roles */`);
@@ -2210,11 +2428,42 @@ body {
2210
2428
  }
2211
2429
  }
2212
2430
  `);
2213
- const animEngine = readFileSync(join(cssDir, "animation.css"), "utf8");
2214
- parts.push(`/* ── Animation engine (animation.css) ── */\n@layer components {\n${animEngine}\n}`);
2431
+ const animPath = join(cssDir, "animation.css");
2432
+ const animEngine = existsSync(animPath) ? readFileSync(animPath, "utf8") : "";
2433
+ if (animEngine) parts.push(`/* ── Animation engine (animation.css) ── */\n@layer components {\n${animEngine}\n}`);
2215
2434
  parts.push(`/* ── Component patterns ── */\n@layer components {\n${patternsCss}\n}`);
2216
2435
  const droppedClashes = /* @__PURE__ */ new Set();
2217
2436
  let droppedVariantBlocks = 0;
2437
+ /**
2438
+ * Is this `@container (min-width: …)` block a PRE-EXPANDED VARIANT block —
2439
+ * i.e. only `.sm-*`/`.md-*`/`.lg-*`/`.xl-*` utility classes, which Tailwind
2440
+ * regenerates on demand as `@md:`?
2441
+ *
2442
+ * Anything else in a container query is component *behaviour*, not a variant,
2443
+ * and dropping it breaks the component. `sitenav.css` is the case that caught
2444
+ * this: its `@container (min-width: 48rem) { .sitenav--bp-md { … } }` is what
2445
+ * switches the nav to its desktop layout, and blanket-dropping every
2446
+ * container block meant the tailwind format shipped a nav stuck in mobile.
2447
+ */
2448
+ const isVariantBlock = (raw) => {
2449
+ const open = raw.indexOf("{");
2450
+ const body = raw.slice(open + 1, raw.lastIndexOf("}"));
2451
+ const selectors = [];
2452
+ let depth = 0;
2453
+ let start = 0;
2454
+ for (let i = 0; i < body.length; i++) {
2455
+ const ch = body[i];
2456
+ if (ch === "{") {
2457
+ if (depth === 0) selectors.push(body.slice(start, i));
2458
+ depth++;
2459
+ } else if (ch === "}") {
2460
+ depth--;
2461
+ if (depth === 0) start = i + 1;
2462
+ }
2463
+ }
2464
+ const names = selectors.flatMap((s) => s.split(",")).map((s) => s.replace(/\/\*[\s\S]*?\*\//g, "").trim()).filter(Boolean);
2465
+ return names.length > 0 && names.every((n) => /^\.(sm|md|lg|xl)-/.test(n));
2466
+ };
2218
2467
  const stripForTailwind = (css) => {
2219
2468
  const kept = [];
2220
2469
  let depth = 0;
@@ -2241,7 +2490,7 @@ body {
2241
2490
  const raw = css.slice(start, i + 1).trim();
2242
2491
  const prelude = css.slice(start, preludeEnd).replace(/\/\*[\s\S]*?\*\//g, "").trim();
2243
2492
  const clash = /^\.([A-Za-z0-9_-]+)/.exec(prelude);
2244
- if (/^@container\s*\(\s*min-width:/.test(prelude)) droppedVariantBlocks++;
2493
+ if (/^@container\s*\(\s*min-width:/.test(prelude) && isVariantBlock(raw)) droppedVariantBlocks++;
2245
2494
  else if (clash && TW_CLASH.has(clash[1])) droppedClashes.add(clash[1]);
2246
2495
  else if (raw) kept.push(raw);
2247
2496
  start = i + 1;
@@ -2253,7 +2502,7 @@ body {
2253
2502
  const componentCss = componentPartialOrder(cssDir).map((rel) => {
2254
2503
  return `/* ── ${rel} ── */\n${stripForTailwind(readFileSync(join(cssDir, rel), "utf8"))}`;
2255
2504
  }).join("\n\n");
2256
- parts.push(`/* ── Component + structural partials (Tailwind-clashing utilities dropped) ── */\n@layer components {\n${componentCss}\n}`);
2505
+ if (componentCss) parts.push(`/* ── Component + structural partials (Tailwind-clashing utilities dropped) ── */\n/* Dropped ${droppedVariantBlocks} pre-expanded breakpoint block(s) — write @sm:/@md:/@lg:/@xl: instead.\n Dropped ${droppedClashes.size} clashing utilit(ies) Tailwind provides itself:\n ${[...droppedClashes].sort().join(" ") || "(none)"} */\n@layer components {\n${componentCss}\n}`);
2257
2506
  return nl(parts) + "\n";
2258
2507
  }
2259
2508
  /**
@@ -2290,11 +2539,61 @@ function componentPartialOrder(cssDir) {
2290
2539
  }
2291
2540
  return order;
2292
2541
  }
2542
+ /** The bundle's cascade layers, in precedence order (last wins). */
2543
+ const CSS_LAYERS = [
2544
+ "vitops.base",
2545
+ "vitops.components",
2546
+ "vitops.utilities"
2547
+ ];
2548
+ /**
2549
+ * Which layer each `index.css` chunk belongs to. Anything unmapped defaults to
2550
+ * `vitops.components` — the safe default, since an unrecognised partial is far
2551
+ * more likely to be a component than a utility family.
2552
+ *
2553
+ * The split is by what the file *emits*, not what it's named:
2554
+ * • base — the UA reset and the pure `:root` token blocks.
2555
+ * • components — the animation engine, structural layout, and every pattern.
2556
+ * • utilities — the single-purpose classes that must be able to override a
2557
+ * pattern (`bg-*`, `drop-shadow-*`, `font-*`, effects, …).
2558
+ *
2559
+ * `color.css` and `shadows.css` each mix a `:root` block with utility classes
2560
+ * and go to `utilities` whole. That is safe because custom-property resolution
2561
+ * is NOT source-order dependent — a `:root` block declared after a rule that
2562
+ * reads it still resolves.
2563
+ *
2564
+ * `layout.css` is genuinely mixed (structural `.rhythm`/`.centered` AND
2565
+ * `.m-*`/`.flex`/`.split-*` utilities) and stays in `components` whole, so its
2566
+ * utility half still can't override a pattern. Splitting it is a separate
2567
+ * change; see the 0.9.0 changelog.
2568
+ */
2569
+ const CHUNK_LAYER = {
2570
+ "global.css": "vitops.base",
2571
+ "generated/type-tokens.css": "vitops.base",
2572
+ "generated/tokens.css": "vitops.base",
2573
+ "generated/color.css": "vitops.utilities",
2574
+ "generated/shadows.css": "vitops.utilities",
2575
+ "generated/typography.css": "vitops.utilities",
2576
+ "generated/animation-effects.css": "vitops.utilities",
2577
+ "utilities.css": "vitops.utilities"
2578
+ };
2293
2579
  /**
2294
2580
  * Assemble the full stylesheet by resolving index.css's @import order against the
2295
2581
  * static partials (from assets) and the freshly generated token partials (in
2296
2582
  * memory), then minify with lightningcss. No @import survives, so no disk
2297
2583
  * resolution / shared `generated/` dir is needed.
2584
+ *
2585
+ * Each chunk is wrapped in a cascade layer. This is what lets a single-purpose
2586
+ * utility override a component pattern — `class="card bg-danger-muted"` — which
2587
+ * previously depended on source order and so silently did nothing here while
2588
+ * working in the tailwind format, where Tailwind's own layers already ordered
2589
+ * them correctly.
2590
+ *
2591
+ * Consequence worth knowing: unlayered CSS beats ALL layered CSS regardless of
2592
+ * specificity, so a consumer's own stylesheet now wins over the framework by
2593
+ * default. That is deliberate — it is the override story every layered
2594
+ * framework ships — but it means an unlayered *reset* will beat framework
2595
+ * component rules it used to lose to. Such a reset belongs in a layer declared
2596
+ * before `vitops.base` (see the `<style>` block in this repo's index.html).
2298
2597
  */
2299
2598
  function bundleCss(generated, assetsDir, minify) {
2300
2599
  const cssDir = join(assetsDir, "css");
@@ -2304,12 +2603,13 @@ function bundleCss(generated, assetsDir, minify) {
2304
2603
  const m = /@import\s+['"]\.\/([^'"]+)['"]/.exec(line);
2305
2604
  if (!m) continue;
2306
2605
  const rel = m[1];
2307
- if (rel.startsWith("generated/")) {
2308
- const name = rel.slice(10);
2309
- chunks.push(generated[name] ?? "");
2310
- } else chunks.push(readFileSync(join(cssDir, rel), "utf8"));
2606
+ const body = rel.startsWith("generated/") ? generated[rel.slice(10)] ?? "" : readFileSync(join(cssDir, rel), "utf8");
2607
+ if (!body.trim()) continue;
2608
+ chunks.push(`@layer ${CHUNK_LAYER[rel] ?? "vitops.components"} {\n${body}\n}`);
2311
2609
  }
2312
- const merged = chunks.join("\n");
2610
+ const merged = `/*! GENERATED by @getvitops/generator — do not edit by hand.
2611
+ Class reference: npx vitops docs classes · All topics: npx vitops docs */
2612
+ @layer ${CSS_LAYERS.join(", ")};\n` + chunks.join("\n");
2313
2613
  const res = transform({
2314
2614
  filename: "styles.css",
2315
2615
  code: Buffer.from(merged),
@@ -2327,6 +2627,7 @@ function loadConfig(input) {
2327
2627
  const lines = result.errors.slice(0, 12).map((e) => ` • ${e.path.join(".") || "(root)"}: ${e.message}`).join("\n");
2328
2628
  throw new Error(`Invalid design-system.json:\n${lines}`);
2329
2629
  }
2630
+ for (const w of result.warnings) console.warn(`[vitops] ${w}`);
2330
2631
  return result.data;
2331
2632
  }
2332
2633
  async function generate(options) {
@@ -3072,15 +3373,14 @@ function defaultConfig() {
3072
3373
  patterns: {
3073
3374
  defaults: {
3074
3375
  ds: "none",
3075
- b: "1px solid var(--color-surface-xl)",
3376
+ b: "1px solid var(--surface-bg-muted)",
3076
3377
  br: "0.375rem",
3077
3378
  p: "1rem",
3078
3379
  fs: "1rem"
3079
3380
  },
3080
3381
  radii: {
3081
3382
  pill: "999px",
3082
- circle: "50%",
3083
- card: "0.5rem"
3383
+ circle: "50%"
3084
3384
  },
3085
3385
  groups: {
3086
3386
  control: {
@@ -3144,7 +3444,7 @@ function defaultConfig() {
3144
3444
  group: "control",
3145
3445
  class: "cta",
3146
3446
  fill: true,
3147
- default_role: "brand-primary",
3447
+ default_role: "ui-primary",
3148
3448
  overrides: {
3149
3449
  p: "0.75em 1.5em",
3150
3450
  b: "none",
@@ -3164,7 +3464,7 @@ function defaultConfig() {
3164
3464
  "font-weight": "600",
3165
3465
  "text-decoration": "none",
3166
3466
  cursor: "pointer",
3167
- color: "var(--brand-primary-on-solid)",
3467
+ color: "var(--ui-primary-on-solid)",
3168
3468
  "box-shadow": "var(--ds-cta-group)"
3169
3469
  },
3170
3470
  states: {
@@ -3183,7 +3483,7 @@ function defaultConfig() {
3183
3483
  base: {
3184
3484
  "border-radius": "var(--br-card-group)",
3185
3485
  padding: "1.5rem",
3186
- background: "var(--color-surface-xxl)",
3486
+ background: "var(--surface-bg)",
3187
3487
  border: "var(--b-card-group)",
3188
3488
  "box-shadow": "var(--ds-card-group)"
3189
3489
  },
@@ -3252,5 +3552,5 @@ function defaultConfig() {
3252
3552
  }
3253
3553
 
3254
3554
  //#endregion
3255
- export { BASE_HOOK, DesignSystemSchema, SCHEMA_URL, SITE_SCHEMA_URL, SiteConfigSchema, TW_CLASH, deepMerge, defaultConfig, generate, generateDocs, jsonSchema, resolveSiteConfig, resolveTheme, siteJsonSchema, stripNulls, validate, validateSite };
3555
+ export { BASE_HOOK, DARK_SEL, DesignSystemSchema, REQUIRED_ROLES, ROLE_TOKEN_SUFFIXES, SCHEMA_URL, SITE_SCHEMA_URL, SiteConfigSchema, TW_CLASH, deepMerge, defaultConfig, expandPalette, functionalRole, generate, generateDocs, jsonSchema, resolveSiteConfig, resolveTheme, roleColorUtilities, siteJsonSchema, stripNulls, validate, validateSite };
3256
3556
  //# sourceMappingURL=index.mjs.map