@getvitops/generator 0.8.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({
@@ -153,6 +296,8 @@ function validate(input) {
153
296
  warnings: []
154
297
  };
155
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.`);
156
301
  const radii = Object.keys(result.data.patterns?.radii ?? {});
157
302
  const items = new Set(Object.keys(result.data.patterns?.items ?? {}));
158
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`);
@@ -164,90 +309,6 @@ function validate(input) {
164
309
  };
165
310
  }
166
311
 
167
- //#endregion
168
- //#region src/shared.ts
169
- /**
170
- * Data shared between the generator (`generate.ts`) and the docs bundle
171
- * (`docs.ts`). Lives in its own module because `generate.ts` imports `docs.ts` —
172
- * importing back the other way would be a cycle.
173
- */
174
- /**
175
- * Pattern base geometry props → per-pattern override-hook shorthand. Each hook
176
- * wraps the authored value (`padding: var(--p-btn, 0.4em 0.8em)`) so a
177
- * consumer restyles one pattern by setting one variable. The hook is named after
178
- * the pattern's key, not its class.
179
- */
180
- const BASE_HOOK = {
181
- padding: "p",
182
- "border-radius": "br",
183
- border: "b",
184
- "box-shadow": "ds",
185
- "font-size": "fs"
186
- };
187
- /**
188
- * Framework utility classes whose names collide with Tailwind's own defaults.
189
- * The `tailwind` format strips any rule targeting these — Tailwind provides
190
- * them, so consumers use Tailwind's utilities for these names instead.
191
- */
192
- const TW_CLASH = new Set([
193
- "block",
194
- "inline",
195
- "inline-block",
196
- "flow-root",
197
- "flex",
198
- "inline-flex",
199
- "grid",
200
- "inline-grid",
201
- "table",
202
- "inline-table",
203
- "table-caption",
204
- "table-cell",
205
- "table-row",
206
- "contents",
207
- "hidden",
208
- "list-item",
209
- "static",
210
- "fixed",
211
- "absolute",
212
- "relative",
213
- "sticky",
214
- "isolate",
215
- "visible",
216
- "invisible",
217
- "collapse",
218
- "sr-only",
219
- "not-sr-only",
220
- "flex-row",
221
- "flex-row-reverse",
222
- "flex-col",
223
- "flex-col-reverse",
224
- "flex-wrap",
225
- "flex-nowrap",
226
- "items-start",
227
- "items-end",
228
- "items-center",
229
- "items-baseline",
230
- "items-stretch",
231
- "justify-start",
232
- "justify-end",
233
- "justify-center",
234
- "justify-between",
235
- "justify-around",
236
- "justify-evenly",
237
- "content-start",
238
- "content-end",
239
- "content-center",
240
- "content-between",
241
- "content-around",
242
- "content-evenly",
243
- "text-left",
244
- "text-center",
245
- "text-right",
246
- "text-justify",
247
- "text-start",
248
- "text-end"
249
- ]);
250
-
251
312
  //#endregion
252
313
  //#region src/tokens.ts
253
314
  /**
@@ -386,6 +447,11 @@ const EMPHASIS_DARK = {
386
447
  bold: "300",
387
448
  "x-bold": "100"
388
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);
389
455
  /** Resolve the functional token set for one role over its hue. */
390
456
  function functionalRole(role, hueName, hue) {
391
457
  const numeric = hue.numeric;
@@ -981,9 +1047,17 @@ Families: ${code(families)} (\`--font-*\`).
981
1047
  ## Colour
982
1048
 
983
1049
  **Functional tokens are the primary vocabulary** — classes name the *job*, not the tone, and
984
- every one remaps automatically under \`:root[data-brx-theme="dark"]\` (background/text ends
1050
+ every one remaps automatically under \`${DARK_SEL}\` (background/text ends
985
1051
  swap; \`solid\` fills stay mode-stable with a computed \`on-\` foreground). Prefer these over
986
- 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):
987
1061
 
988
1062
  - **Surfaces** — \`bg-<role>\` (the role's background wash; for \`surface\` this is the
989
1063
  card/panel plane), \`bg-<role>-muted\` (subtle / sunken), \`bg-surface-bold\` (raised plane).
@@ -997,9 +1071,10 @@ raw steps. Rules (role ∈ ${code(roles)}):
997
1071
  \`--color-<role>-{x-muted,muted,bold,x-bold}\` — \`muted\` recedes toward the background
998
1072
  extreme, \`bold\` advances toward the foreground, in *either* appearance.
999
1073
 
1000
- Everyday pairings: page \`bg-neutral\` + \`text-neutral\`; cards \`bg-surface\`; captions
1001
- \`text-neutral-muted\`; buttons \`bg-ui-primary-solid text-on-ui-primary\`; status text
1002
- \`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"}\`.
1003
1078
 
1004
1079
  **Raw scale** (secondary / fine control) — rule \`<util>-<hue>-<step>\` with util ∈
1005
1080
  ${code(utils)}: every hue is an 11-step OKLCH scale generated from its seed (or fixed brand
@@ -1007,6 +1082,28 @@ tones), numeric steps \`50\` … \`950\` (tinted near-white → tinted near-blac
1007
1082
  \`bg-${ramps[0] ?? "brand"}-100\`, \`text-${ramps[0] ?? "brand"}-800\`. Hues: ${code(ramps)}.
1008
1083
  The **bare** role name (\`bg-<role>\`, \`text-<role>\`) is always the functional token.
1009
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
+
1010
1107
  ## Shadows
1011
1108
 
1012
1109
  Rule: **\`drop-shadow-<size>\`** — size ∈ ${code(shadows)} (applied as a \`filter\`, so it
@@ -1168,9 +1265,27 @@ ${clashList.map((c) => `\`${c}\``).join(", ")}
1168
1265
  Other Tailwind-specific behaviour:
1169
1266
 
1170
1267
  - **Variants are Tailwind's job.** No pre-expanded breakpoint/state classes are emitted
1171
- (the framework's \`@container (min-width: …)\` variant blocks are dropped); use Tailwind
1172
- 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
1173
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.
1174
1289
  - **The space scale is NOT mapped into Tailwind's \`--spacing-*\` namespace** (that would
1175
1290
  corrupt Tailwind's numeric multipliers and \`max-w-*\` sizes). Numeric utilities like
1176
1291
  \`p-4\` keep Tailwind's 0.25rem meaning; use the design-system scale via arbitrary
@@ -1180,6 +1295,18 @@ Other Tailwind-specific behaviour:
1180
1295
  (\`bg-<role>\`, \`text-<role>-muted\`, …) is the public API. The raw hue scales ARE
1181
1296
  \`@theme\` colours, so native \`bg-<hue>-500\`-style utilities work.
1182
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
+
1183
1310
  ## \`css\` — standalone bundle
1184
1311
 
1185
1312
  Emits a bundled, self-contained \`styles.css\` + \`tokens.json\` + \`design-manifest.json\`.
@@ -1188,6 +1315,26 @@ pre-expanded — including breakpoint/state variants with \`-\` separators
1188
1315
  (\`md-split-1-2\`, \`hover-fade-in\`). For non-Bricks, non-Tailwind consumers and the
1189
1316
  docs build.
1190
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
+
1191
1338
  ## \`bricks\` — WordPress / Bricks Builder payload
1192
1339
 
1193
1340
  Emits the full deployable theme payload: \`styles.min.css\`, the Bricks import JSONs
@@ -1261,11 +1408,16 @@ Classes name the token, not the tone: \`bg-<role>\`, \`text-<role>-muted\`, \`te
1261
1408
 
1262
1409
  ## Automatic dark mode
1263
1410
 
1264
- 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
1265
1412
  text ends of each scale swap, while \`solid\` fills stay mode-stable with a recomputed
1266
1413
  \`on-solid\` foreground. There is no per-appearance scheme grammar to author and no named
1267
1414
  steps — a role token means the same *job* in both appearances.
1268
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
+
1269
1421
  ## Contrast guarantees
1270
1422
 
1271
1423
  Text tokens target APCA Lc ≥ 75 and muted text Lc ≥ 60, in **both** appearances —
@@ -1342,9 +1494,34 @@ function renderPatternsConcept(ds) {
1342
1494
  # Component patterns — the CSS chain
1343
1495
 
1344
1496
  Patterns (currently ${code(patternNames)}) are authored declaratively under \`patterns\` in
1345
- \`design-system.json\` (see [authoring.md](../authoring.md)) and compiled to CSS in the
1346
- **\`components\` cascade layer** — so utility classes (declared in a later layer) always win
1347
- 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.
1348
1525
 
1349
1526
  ## 1 — Token cascade
1350
1527
 
@@ -1505,22 +1682,6 @@ function generateDocs(ds, assetsDir) {
1505
1682
  * and the pre-built JS bundles) live under `assets/` in this package and are read /
1506
1683
  * copied from there — they are the same for every consumer.
1507
1684
  */
1508
- /**
1509
- * Selector the dark functional-token flip hangs off.
1510
- *
1511
- * `data-brx-theme` is Bricks' own attribute — Bricks sets it, so it's what the
1512
- * WordPress target needs. Nothing sets it anywhere else, which meant the dark
1513
- * flip was unreachable outside Bricks: the shipped `<color-scheme-toggle>` web
1514
- * component writes `documentElement.dataset.theme` (i.e. `data-theme`), so
1515
- * clicking "Dark" changed an attribute no rule matched. Matching both makes the
1516
- * component work on every target without changing what Bricks already does.
1517
- *
1518
- * Note this covers the explicit choice only — there is deliberately no
1519
- * `prefers-color-scheme` block, so the toggle's "System" position currently
1520
- * resolves to light. Adding one would flip every existing consumer site dark for
1521
- * dark-OS users, which is a product decision, not a bug fix.
1522
- */
1523
- const DARK_SEL = ":root[data-brx-theme=\"dark\"], :root[data-theme=\"dark\"]";
1524
1685
  const DEFAULT_ASSETS = join(dirname(fileURLToPath(import.meta.url)), "..", "assets");
1525
1686
  const UTILITY_PROPS = {
1526
1687
  bg: "background-color",
@@ -1531,24 +1692,55 @@ const UTILITY_PROPS = {
1531
1692
  stroke: "stroke"
1532
1693
  };
1533
1694
  const fnVar = (role, token) => token.startsWith("stop-") ? `--color-${role}-${token.slice(5)}` : `--${role}-${token}`;
1534
- const fnUtilities = (role, utilities, isSurface) => {
1535
- const rows = [];
1536
- if (utilities.includes("bg")) {
1537
- rows.push(`.bg-${role} { background-color: var(--${role}-bg); }`);
1538
- rows.push(`.bg-${role}-muted { background-color: var(--${role}-bg-muted); }`);
1539
- rows.push(`.bg-${role}-solid { background-color: var(--${role}-solid); }`);
1540
- rows.push(`.bg-${role}-solid-bold { background-color: var(--${role}-solid-bold); }`);
1541
- 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)`));
1542
1727
  }
1543
- if (utilities.includes("text")) {
1544
- rows.push(`.text-${role} { color: var(--${role}-text); }`);
1545
- rows.push(`.text-${role}-muted { color: var(--${role}-text-muted); }`);
1546
- rows.push(`.text-${role}-x-muted { color: var(--${role}-text-x-muted); }`);
1547
- 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
+ });
1548
1739
  }
1549
- if (utilities.includes("border")) rows.push(`.border-${role} { border-color: var(--${role}-border); }`, `.border-${role}-bold { border-color: var(--${role}-border-bold); }`);
1550
- return rows.join("\n");
1551
- };
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");
1552
1744
  const decls = (obj, indent = " ") => Object.entries(obj).map(([k, v]) => `${indent}${k}: ${v};`).join("\n");
1553
1745
  const round = (n) => Number(n.toFixed(4)).toString();
1554
1746
  const rem = (v) => parseFloat(v);
@@ -1663,18 +1855,10 @@ function build(ds, format, assetsDir) {
1663
1855
  css += `${DARK_SEL} {\n color-scheme: dark;\n${fb}}\n\n`;
1664
1856
  }
1665
1857
  }
1666
- const allTokens = [];
1667
- for (const [name, hue] of Object.entries(expandedPalette)) for (const n of Object.keys(hue.numeric)) allTokens.push(`${name}-${n}`);
1668
- for (const fr of scaleRoles) for (const stop of [
1669
- "x-muted",
1670
- "muted",
1671
- "bold",
1672
- "x-bold"
1673
- ]) allTokens.push(`${fr.role}-${stop}`);
1674
1858
  css += `/* Colour utilities — ${UTILITIES.join(", ")} */\n`;
1675
- for (const token of allTokens) css += UTILITIES.map((cls) => `.${cls}-${token} { ${UTILITY_PROPS[cls]}: var(--color-${token}); }`).join("\n") + "\n";
1676
- css += `\n/* Functional utilities (the public API) */\n`;
1677
- 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`;
1678
1862
  if (surfaceRole) css += `.glass { background-color: var(--surface-glass); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); }\n`;
1679
1863
  generated["color.css"] = css;
1680
1864
  }
@@ -2062,7 +2246,8 @@ function build(ds, format, assetsDir) {
2062
2246
  twTypeTokensCss,
2063
2247
  twPatternTokensCss,
2064
2248
  patternsCss: pat,
2065
- assetsDir
2249
+ assetsDir,
2250
+ utilities: UTILITIES
2066
2251
  }),
2067
2252
  twTypeTokensCss,
2068
2253
  twPatternTokensCss,
@@ -2070,10 +2255,10 @@ function build(ds, format, assetsDir) {
2070
2255
  };
2071
2256
  }
2072
2257
  function emitTailwind(ctx) {
2073
- 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;
2074
2259
  const cssDir = join(assetsDir, "css");
2075
2260
  const nl = (arr) => arr.filter(Boolean).join("\n");
2076
- 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 */"];
2077
2262
  parts.push(`@import "tailwindcss";\n`);
2078
2263
  const theme = [];
2079
2264
  theme.push(` /* Hue scales → bg-<hue>-<step> text-* border-* (numeric, 50…950) */`);
@@ -2114,15 +2299,8 @@ function emitTailwind(ctx) {
2114
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");
2115
2300
  const util = [];
2116
2301
  if (scaleRoles.length) {
2117
- util.push(`/* Functional colour utilities (the public API) */`);
2118
- for (const fr of scaleRoles) for (const row of fnUtilities(fr.role, [
2119
- "bg",
2120
- "text",
2121
- "border"
2122
- ], fr.role === "surface").split("\n")) {
2123
- const m = /^\.([^ ]+) \{ (.*) \}$/.exec(row);
2124
- if (m) util.push(`@utility ${m[1]} {\n ${m[2]}\n}`);
2125
- }
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}`);
2126
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}`);
2127
2305
  }
2128
2306
  util.push(`/* Typography roles */`);
@@ -2250,11 +2428,42 @@ body {
2250
2428
  }
2251
2429
  }
2252
2430
  `);
2253
- const animEngine = readFileSync(join(cssDir, "animation.css"), "utf8");
2254
- 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}`);
2255
2434
  parts.push(`/* ── Component patterns ── */\n@layer components {\n${patternsCss}\n}`);
2256
2435
  const droppedClashes = /* @__PURE__ */ new Set();
2257
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
+ };
2258
2467
  const stripForTailwind = (css) => {
2259
2468
  const kept = [];
2260
2469
  let depth = 0;
@@ -2281,7 +2490,7 @@ body {
2281
2490
  const raw = css.slice(start, i + 1).trim();
2282
2491
  const prelude = css.slice(start, preludeEnd).replace(/\/\*[\s\S]*?\*\//g, "").trim();
2283
2492
  const clash = /^\.([A-Za-z0-9_-]+)/.exec(prelude);
2284
- if (/^@container\s*\(\s*min-width:/.test(prelude)) droppedVariantBlocks++;
2493
+ if (/^@container\s*\(\s*min-width:/.test(prelude) && isVariantBlock(raw)) droppedVariantBlocks++;
2285
2494
  else if (clash && TW_CLASH.has(clash[1])) droppedClashes.add(clash[1]);
2286
2495
  else if (raw) kept.push(raw);
2287
2496
  start = i + 1;
@@ -2293,7 +2502,7 @@ body {
2293
2502
  const componentCss = componentPartialOrder(cssDir).map((rel) => {
2294
2503
  return `/* ── ${rel} ── */\n${stripForTailwind(readFileSync(join(cssDir, rel), "utf8"))}`;
2295
2504
  }).join("\n\n");
2296
- 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}`);
2297
2506
  return nl(parts) + "\n";
2298
2507
  }
2299
2508
  /**
@@ -2330,11 +2539,61 @@ function componentPartialOrder(cssDir) {
2330
2539
  }
2331
2540
  return order;
2332
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
+ };
2333
2579
  /**
2334
2580
  * Assemble the full stylesheet by resolving index.css's @import order against the
2335
2581
  * static partials (from assets) and the freshly generated token partials (in
2336
2582
  * memory), then minify with lightningcss. No @import survives, so no disk
2337
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).
2338
2597
  */
2339
2598
  function bundleCss(generated, assetsDir, minify) {
2340
2599
  const cssDir = join(assetsDir, "css");
@@ -2344,12 +2603,13 @@ function bundleCss(generated, assetsDir, minify) {
2344
2603
  const m = /@import\s+['"]\.\/([^'"]+)['"]/.exec(line);
2345
2604
  if (!m) continue;
2346
2605
  const rel = m[1];
2347
- if (rel.startsWith("generated/")) {
2348
- const name = rel.slice(10);
2349
- chunks.push(generated[name] ?? "");
2350
- } 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}`);
2351
2609
  }
2352
- 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");
2353
2613
  const res = transform({
2354
2614
  filename: "styles.css",
2355
2615
  code: Buffer.from(merged),
@@ -3113,15 +3373,14 @@ function defaultConfig() {
3113
3373
  patterns: {
3114
3374
  defaults: {
3115
3375
  ds: "none",
3116
- b: "1px solid var(--color-surface-xl)",
3376
+ b: "1px solid var(--surface-bg-muted)",
3117
3377
  br: "0.375rem",
3118
3378
  p: "1rem",
3119
3379
  fs: "1rem"
3120
3380
  },
3121
3381
  radii: {
3122
3382
  pill: "999px",
3123
- circle: "50%",
3124
- card: "0.5rem"
3383
+ circle: "50%"
3125
3384
  },
3126
3385
  groups: {
3127
3386
  control: {
@@ -3224,7 +3483,7 @@ function defaultConfig() {
3224
3483
  base: {
3225
3484
  "border-radius": "var(--br-card-group)",
3226
3485
  padding: "1.5rem",
3227
- background: "var(--color-surface-xxl)",
3486
+ background: "var(--surface-bg)",
3228
3487
  border: "var(--b-card-group)",
3229
3488
  "box-shadow": "var(--ds-card-group)"
3230
3489
  },
@@ -3293,5 +3552,5 @@ function defaultConfig() {
3293
3552
  }
3294
3553
 
3295
3554
  //#endregion
3296
- 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 };
3297
3556
  //# sourceMappingURL=index.mjs.map