@getvitops/generator 1.0.0 → 2.0.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
@@ -1,11 +1,13 @@
1
+ import { createRequire } from "node:module";
1
2
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
3
  import { dirname, join } from "node:path";
3
- import { fileURLToPath } from "node:url";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
5
  import { createHash } from "node:crypto";
5
6
  import { transform } from "lightningcss";
6
7
  import * as z from "zod/mini";
7
8
  import { LIGHTNESS_LADDER, contrastLc, generateOklchPalette, hexToOklch, oklchStringToHex, pickOn, tintedEndpoints } from "@getvitops/utils/color";
8
- import { nodesToHtml, t } from "@getvitops/utils";
9
+ import { generateIconInclude, nodesToHtml, resolveIcon, t } from "@getvitops/utils";
10
+ import { getIconData, iconToSVG, replaceIDs } from "@iconify/utils";
9
11
 
10
12
  //#region src/shared.ts
11
13
  /**
@@ -23,15 +25,27 @@ import { nodesToHtml, t } from "@getvitops/utils";
23
25
  * clicking "Dark" changed an attribute no rule matched. Matching both makes the
24
26
  * component work on every target without changing what Bricks already does.
25
27
  *
26
- * Note this covers the explicit choice only — there is deliberately no
27
- * `prefers-color-scheme` block, so the toggle's "System" position currently
28
- * resolves to light. Adding one would flip every existing consumer site dark for
29
- * dark-OS users, which is a product decision, not a bug fix.
28
+ * This covers the EXPLICIT choice. The OS preference is a second, opt-in block —
29
+ * see `SYSTEM_DARK_SEL` — because turning it on flips a site dark for dark-OS
30
+ * users, which is the site's decision rather than the design system's.
30
31
  *
31
32
  * Shared so the docs quote the selector the generator actually emits.
32
33
  */
33
34
  const DARK_SEL = ":root[data-brx-theme=\"dark\"], :root[data-theme=\"dark\"]";
34
35
  /**
36
+ * Selector for the OS-preference dark block, emitted inside
37
+ * `@media (prefers-color-scheme: dark)` when a site opts in.
38
+ *
39
+ * Derived from `DARK_SEL`'s attributes rather than written out a third time, and
40
+ * inverted: this must apply when NO explicit choice has been made. That is
41
+ * exactly what `<color-scheme-toggle>`'s "System" position produces — it removes
42
+ * `data-theme` entirely — so with this block present System resolves to the OS
43
+ * instead of silently falling through to light, and needs no JS to do it.
44
+ *
45
+ * The `:not()`s are what keep an explicit *light* choice winning over a dark OS.
46
+ */
47
+ const SYSTEM_DARK_SEL = `:root${DARK_SEL.split(", ").map((s) => s.replace(/^:root\[([\w-]+)="dark"\]$/, "[$1=\"light\"]")).map((attr) => `:not(${attr})`).join("")}`;
48
+ /**
35
49
  * Roles the shipped CSS partials reference with NO fallback, so a config that
36
50
  * omits one leaves those rules resolving to nothing — an invalid declaration,
37
51
  * not a graceful degrade.
@@ -168,7 +182,11 @@ const TW_CLASH = new Set([
168
182
  "text-right",
169
183
  "text-justify",
170
184
  "text-start",
171
- "text-end"
185
+ "text-end",
186
+ "text-wrap",
187
+ "text-nowrap",
188
+ "text-balance",
189
+ "text-pretty"
172
190
  ]);
173
191
 
174
192
  //#endregion
@@ -192,18 +210,18 @@ const desc = (schema, text) => schema.check(z.describe(text));
192
210
  const CssDecls = desc(z.record(z.string(), z.string()), "A CSS declaration block: property → value. Values stay strings (they can be hex, var(), clamp(), keywords, …); the generator, not the schema, interprets them.");
193
211
  const Scalar = z.union([z.string(), z.number()]);
194
212
  const VarMap = z.record(z.string(), Scalar);
195
- const SeededRamp = desc(z.object({
213
+ const SeededRamp = desc(z.strictObject({
196
214
  seed: desc(z.string(), "Seed colour (hex or oklch()). An 11-step numeric scale (50…950, tinted near-white → tinted near-black) is generated in OKLCH from it; the seed is preserved at its natural step."),
197
215
  anchors: z.optional(desc(z.record(z.string(), z.string()), "Step → colour overrides (hex or oklch()) pinned VERBATIM at those steps. Every other step takes its lightness from the shared ladder, with chroma and hue interpolated between the anchors — so an anchor is reproduced exactly and is the only step allowed off the ladder. An explicit anchor overrides the seed at that step; two anchors that resolve to the same step are an error."))
198
216
  }), "Seeded hue: the 11-step scale is GENERATED in OKLCH from `seed` (anchors pin specific steps).");
199
- const FixedRamp = desc(z.object({ tones: desc(z.union([z.array(z.string()), z.record(z.string(), z.string())]), "Fixed brand kit: authored tones placed verbatim at their nearest steps plus tinted off-white/off-black endpoints; no interpolation. Either an ordered light → dark array or a step → colour map.") }), "Fixed hue: authored brand tones used verbatim; no generation.");
217
+ const FixedRamp = desc(z.strictObject({ tones: desc(z.union([z.array(z.string()), z.record(z.string(), z.string())]), "Fixed brand kit: authored tones placed verbatim at their nearest steps plus tinted off-white/off-black endpoints; no interpolation. Either an ordered light → dark array or a step → colour map.") }), "Fixed hue: authored brand tones used verbatim; no generation.");
200
218
  const Ramp = desc(z.union([SeededRamp, FixedRamp]), "A palette hue, authored one of two ways: `{ seed, anchors? }` generates an 11-step numeric OKLCH scale (50…950) from the seed, or `{ tones }` supplies a fixed brand kit used verbatim.");
201
219
  /**
202
220
  * A role points at a hue, and optionally declares its kind. The bare-string form
203
221
  * is the shorthand for a chromatic role — signal colours are the common case,
204
222
  * and the two or three surfaces are worth naming explicitly.
205
223
  */
206
- const RoleSpecSchema = z.union([desc(z.string(), "A palette hue name. Shorthand for `{ hue, kind: \"chromatic\" }`."), z.object({
224
+ const RoleSpecSchema = z.union([desc(z.string(), "A palette hue name. Shorthand for `{ hue, kind: \"chromatic\" }`."), z.strictObject({
207
225
  hue: desc(z.string(), "The palette hue this role resolves to."),
208
226
  kind: z.optional(desc(z.enum(["surface", "chromatic"]), "`surface` — a page/panel colour: gets a bare `bg-<role>` plus the full emphasis range and text scale. `chromatic` (default) — a signal colour: tints and solids only, no bare `bg-<role>`."))
209
227
  })]);
@@ -216,25 +234,25 @@ const UtilityName = z.enum([
216
234
  "fill",
217
235
  "stroke"
218
236
  ]);
219
- const Colors = desc(z.object({
220
- palette: desc(z.record(z.string(), Ramp), "Palette hues by name. Each becomes an 11-step numeric OKLCH scale (`--color-<hue>-50…950`)."),
237
+ const Colors = desc(z.strictObject({
238
+ palette: desc(z.record(z.string(), Ramp), "Palette hues by name. Each becomes an 11-step numeric OKLCH scale (`--color-<hue>-50…950`). Every ramp shares one fixed lightness ladder, so a step means the same lightness in every hue; only chroma and hue vary. Beyond the outermost authored colour the chroma decays towards a small endpoint value (0.008 light / 0.015 dark) so the near-white and near-black ends keep a whisper of the hue — but that is a **ceiling**, not a target, so a low-chroma seed stays low and **a chroma-0 seed gives a true neutral** rather than a tinted one. The ladder is also enforced: if pinned colours leave a ramp non-monotonic (some step darker than the one below it), the build fails rather than shipping a scale whose hover states run backwards."),
221
239
  roles: desc(z.record(z.string(), RoleSpecSchema), `Maps semantic role names onto palette hues. Role names are ARBITRARY — add a key and the generator emits that role's token set (\`--color-<target>-<role>[-<variant>]\` for target bg/text/icon/border), its dark-mode flip and its utility classes.\n\nA value is either a hue name (\`"danger": "rust"\`) or \`{ "hue": …, "kind": "surface" | "chromatic" }\`. **The kind decides the shape of the token set.** \`chromatic\` (the default, and what the bare-string form means) is a signal colour: its backgrounds split into tints (\`bg-<role>-x-muted\`/\`-muted\`) and solids (\`bg-<role>-solid[-bold|-x-bold]\`), with deliberately **no bare \`bg-<role>\`** — "how loud?" is a question the author answers. \`surface\` is a page/panel colour: it has a bare \`bg-<role>\` plus the full emphasis range and text scale.\n\nDark mode flips automatically; there is no per-appearance scheme grammar. The solid family and its computed \`text-on-<role>\` foreground stay mode-stable so a filled button keeps its identity. 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.`),
222
240
  utilities: z.optional(desc(z.array(UtilityName), "Which colour utility-class families to emit (`bg-*`, `text-*`, `icon-*`, `border-*`, `outline-*`, `fill-*`, `stroke-*`). Defaults to bg, text, icon, border. `icon` is a separate non-text tier (a glyph may run more vivid than text); `outline`/`fill`/`stroke` have no tokens of their own and alias the border and icon tiers."))
223
241
  }), "The colour system (the only required section): `palette` hues become generated OKLCH scales; `roles` map semantic roles onto those hues, from which all role tokens and dark mode derive.");
224
- const Scale = z.object({
242
+ const Scale = z.strictObject({
225
243
  base: desc(z.string(), "Anchor size (a CSS length, e.g. \"1rem\") — the value at `baseStep`."),
226
244
  ratio: desc(z.number(), "Modular ratio between adjacent steps at large viewports."),
227
245
  steps: desc(z.optional(z.number()), "Token count when `names` is absent (steps are then named 1..steps)."),
228
246
  names: desc(z.optional(z.array(z.string())), "Step names, smallest → largest (e.g. [\"xs\",\"sm\",\"md\",…]); each becomes a token suffix."),
229
247
  baseStep: desc(z.optional(z.number()), "1-based index of the step whose value is `base`."),
230
248
  baseline: desc(z.optional(z.string()), "Named step used as the fluid pivot / GUI scale centre (defaults to `baseStep`)."),
231
- fluid: desc(z.optional(z.object({
249
+ fluid: desc(z.optional(z.strictObject({
232
250
  minVw: desc(z.string(), "Viewport width (CSS length) where fluid scaling bottoms out."),
233
251
  maxVw: desc(z.string(), "Viewport width (CSS length) where fluid scaling tops out."),
234
252
  minRatio: desc(z.number(), "Modular ratio at/below `minVw` (usually < `ratio`).")
235
253
  })), "Makes the scale fluid: each step compiles to a clamp() that interpolates from `minRatio` at `minVw` to `ratio` at `maxVw`.")
236
254
  });
237
- const Pattern = desc(z.object({
255
+ const Pattern = desc(z.strictObject({
238
256
  group: desc(z.optional(z.string()), "Token-cascade group this pattern belongs to (e.g. tag / control / panel); base declarations resolve through `--<prop>-<group>` before `--<prop>-default`."),
239
257
  overrides: z.optional(desc(CssDecls, "Per-pattern token overrides, emitted as `--<prop>-<name>-group` values.")),
240
258
  element: desc(z.optional(z.string()), "Style at element level via zero-specificity `:where(<element>)` (instead of, or alongside, a class)."),
@@ -245,47 +263,47 @@ const Pattern = desc(z.object({
245
263
  states: desc(z.optional(z.record(z.string(), z.record(z.string(), z.unknown()))), "Interaction states (hover / active / focus-visible), each a map of shortcuts: `step` (intensify the fill or text by n rungs — `bg-<role>-solid` → `-solid-bold`, `text-<role>` → `-bold`), `scale` (transform scale), `lift` (translateY + shadow), `shadow` (a shadow name → drop-shadow(var(--shadow-<name>)), or true → lift shadow), `ring` (focus ring), or raw `css` declarations. Hover rules are wrapped in `@media (hover: hover)`."),
246
264
  roles: desc(z.optional(z.array(z.string())), "Semantic colour role variants to emit as `<pattern>-<role>` classes (fills use the role solid / on-solid tokens).")
247
265
  }), "One component pattern (button, link, badge, card, …): base declarations + interaction states + semantic role variants, resolved through the pattern token cascade.");
248
- const Patterns = desc(z.object({
266
+ const Patterns = desc(z.strictObject({
249
267
  defaults: z.optional(desc(CssDecls, "Cascade-wide fallback tokens, emitted as `--<prop>-default`.")),
250
268
  radii: z.optional(desc(CssDecls, "Shape primitives, emitted as `--br-<name>` (referenced by pattern bases).")),
251
269
  groups: desc(z.optional(z.record(z.string(), CssDecls)), "Group-level tokens, emitted as `--<prop>-<group>`; patterns opt in via their `group` key."),
252
270
  z: desc(z.optional(z.record(z.string(), z.number())), "Z-index tiers → `--z-tier-<name>`."),
253
271
  items: desc(z.optional(z.record(z.string(), Pattern)), "The component patterns to emit, keyed by name.")
254
272
  }), "Component patterns and their token cascade: `defaults` → `groups` → per-pattern `overrides`, plus shape (`radii`) and z-index primitives.");
255
- const TypographyRole = desc(z.record(z.string(), Scalar), "An open bag of CSS-ish keys (family / size / weight / line-height / tracking / transform / decoration / text-wrap / …); the generator maps known keys and passes the rest through.");
256
- const Typography = desc(z.object({
273
+ const TypographyRole = desc(z.record(z.string(), Scalar), "A bag of CSS-ish keys, each mapped to a declaration plus a `--<role>-<sfx>` override hook. The recognised set is **closed**: `family`, `size`, `weight`, `style`, `line-height`, `tracking` (→ `letter-spacing`), `text-transform`, `text-decoration`, `text-wrap`, `color`. Note the last four are spelled with their full CSS property names — `transform` and `decoration` are NOT accepted. Anything unrecognised is **ignored**, not passed through, so the generator warns rather than emitting it: a silently-dropped `transform: uppercase` is how title-case navigation reaches production. Note also that `style`, `text-transform`, `text-decoration` and `text-wrap` are emitted on **every** role at their identity value (`normal`/`none`/`none`/`wrap`) whether declared or not, so applying one role class over another fully resets it — which means **omitting `text-wrap` is not \"inherit\"**: it emits `text-wrap: wrap` and cancels the `pretty` the role would otherwise inherit from a `pretty` ancestor such as a `body`-mapped role. Declare it on every role — `balance` for heading-like roles, `pretty` for copy, `wrap` for short single-line labels.");
274
+ const Typography = desc(z.strictObject({
257
275
  families: desc(z.optional(z.record(z.string(), z.string())), "Role-facing family aliases → CSS font values, usually referencing the top-level `fonts` tokens (e.g. \"var(--font-display)\")."),
258
276
  roles: desc(z.optional(z.record(z.string(), TypographyRole)), "Semantic type roles (display, title, heading, body, quote, caption, eyebrow, code, lead, footnote, tag, …), each emitted as a `font-<role>` class."),
259
277
  headings: desc(z.optional(z.record(z.string(), z.string())), "Maps bare elements to type roles so unclassed markup picks up role styling — `{ \"h1\": \"display\", \"h2\": \"heading\" }`. The key is used verbatim as a selector, so it is not limited to h1…h6: **map `\"body\"` to your prose role** to bind base page typography to the role rather than hand-writing it. That binding is what makes the role editable — a stylesheet that re-states `font-family`/`line-height` as literals on `body` shadows `--<role>-ff`/`--<role>-lh`, and the live theme editor then appears to do nothing.")
260
278
  }), "Typography: family aliases, semantic type roles (→ `font-<role>` classes), and the bare-element → role mapping.");
261
- const AnimEffect = desc(z.object({
279
+ const AnimEffect = desc(z.strictObject({
262
280
  kf: desc(z.string(), "Keyframe family driving the effect: composite (transform/opacity), paint, or layout."),
263
281
  css: z.optional(desc(VarMap, "Extra literal declarations merged into the effect class as-is.")),
264
282
  vars: z.optional(desc(VarMap, "Effect endpoint variables (`--<key>: <value>`, e.g. opacity-from, translate-y-to) that override the keyframe defaults."))
265
283
  }), "A named animation effect class — a pure value layer (`--_anim` + `--<prop>-from/-to`) over the static keyframe engine.");
266
- const Animations = desc(z.object({
284
+ const Animations = desc(z.strictObject({
267
285
  effects: desc(z.optional(z.record(z.string(), AnimEffect)), "Effect classes to emit (`.fade-in`, `.reveal-left`, …), keyed by class name."),
268
- journeys: desc(z.optional(z.object({
286
+ journeys: desc(z.optional(z.strictObject({
269
287
  base: desc(z.optional(z.record(z.string(), VarMap)), "Named journey building blocks: part name → var map."),
270
288
  compose: desc(z.optional(z.array(z.array(z.string()))), "Combinations of base parts, each emitted as a `.<parts>-journey` class.")
271
289
  })), "Multi-part journey classes composed from `base` building blocks.")
272
290
  }), "Animation effect + journey classes (pure value layers). The animation engine itself — keyframes, drivers, floats, utilities — is static framework CSS, not configured here.");
273
- const DesignSystemSchema = z.object({
291
+ const DesignSystemSchema = z.strictObject({
274
292
  $schema: desc(z.optional(z.string()), "URL of the published JSON Schema (stamped by `vitops init`) so editors provide autocomplete + validation."),
275
- meta: desc(z.optional(z.object({
293
+ meta: desc(z.optional(z.strictObject({
276
294
  name: desc(z.optional(z.string()), "Brand/system name. Used as the `name` field and `<h1>` of the `design` format's `DESIGN.md`. Defaults to \"Design System\"."),
277
295
  description: desc(z.optional(z.string()), "One or two sentences on the brand personality and the feeling the UI should evoke — what an agent needs when no token answers the question. Becomes the DESIGN.md `description` field and opens its Overview section; if omitted, a generic description of the system's mechanics is used instead.")
278
296
  })), "Brand identity for agent-facing output. Consumed only by the `design` format (`DESIGN.md`); it emits no CSS and no tokens."),
279
297
  colors: Colors,
280
298
  shadows: desc(z.optional(z.record(z.string(), z.string())), "Named shadows → `--shadow-<name>` tokens and `.drop-shadow-<name>` utilities. Values are shadow parameter lists (offset/blur/colour). Each token feeds two consumers with different grammars — `box-shadow` (pattern geometry, via the `--ds-*` group aliases) and `filter: drop-shadow(…)` (the utilities and the `shadow:` state shortcut) — so values must stay in the intersection: **one layer, no spread radius, no `inset`**. `drop-shadow()` rejects all three, and rejecting them invalidates the whole filter, so the shadow vanishes rather than degrading."),
281
- fonts: desc(z.optional(z.record(z.string(), z.string())), "Raw font stacks by name, emitted as `--font-<name>` tokens (referenced by `typography.families`)."),
299
+ fonts: desc(z.optional(z.record(z.string(), z.string())), "Raw font stacks by name, emitted as `--font-<name>` tokens (referenced by `typography.families`). **Stacks only — vitops does not load webfonts.** A value here is a `font-family` list and nothing more: it emits no `@font-face`, no preload, and no metrics-matched fallback. If a family needs loading, declare it in Astro's `fonts:` config (`astro.config`, or the site config's `fonts` array) and point the token at the family's `cssVariable` — `\"display\": \"var(--font-league-spartan), sans-serif\"`. Installing a `@fontsource*` package and importing its CSS also works but gives up subsetting, preload and `size-adjust`/`ascent-override` fallbacks, so it regresses CLS."),
282
300
  typeScale: desc(z.optional(Scale), "Fluid modular TYPE scale → `--text-<name>` tokens, consumed by typography roles and text-size utilities."),
283
301
  spaceScale: desc(z.optional(Scale), "Fluid modular SPACE scale → `--space-<name>` tokens, consumed by spacing/gap utilities and vertical rhythm."),
284
302
  patterns: z.optional(Patterns),
285
303
  typography: z.optional(Typography),
286
304
  animations: z.optional(Animations)
287
305
  });
288
- const ColorsPatch = z.object({
306
+ const ColorsPatch = z.strictObject({
289
307
  palette: z.optional(z.record(z.string(), Ramp)),
290
308
  roles: z.optional(z.record(z.string(), RoleSpecSchema)),
291
309
  utilities: z.optional(z.array(UtilityName))
@@ -338,6 +356,39 @@ const dropShadowIssue = (value) => {
338
356
  const lengths = layer.replaceAll(/[\w-]*\([^()]*\)/g, " ").split(/\s+/).filter((t) => LENGTH.test(t));
339
357
  if (lengths.length > 3) return `a spread radius (${lengths.length} lengths)`;
340
358
  };
359
+ const RAMP_KEYS = new Set([
360
+ "seed",
361
+ "anchors",
362
+ "tones"
363
+ ]);
364
+ /**
365
+ * Replace the bare "Invalid input" a failed union produces with the reason.
366
+ *
367
+ * `Ramp` is `SeededRamp | FixedRamp`, and both branches are closed — so `{ seed,
368
+ * tones }` or a stray key fails *both* and zod reports only that the union didn't
369
+ * match. That message sends you reading the schema rather than looking at the two
370
+ * keys in front of you, and the config that produced it is one whose `tones` were
371
+ * being silently discarded at generate time.
372
+ */
373
+ const explainRamp = (input) => (issue) => {
374
+ if (issue.code === "unrecognized_keys") {
375
+ const keys = issue.keys ?? [];
376
+ if (keys.length) return {
377
+ ...issue,
378
+ message: `unknown key${keys.length > 1 ? "s" : ""} ${keys.map((k) => `"${k}"`).join(", ")} — not part of the schema, so ignored at generate time.`
379
+ };
380
+ }
381
+ if (issue.path.length !== 3 || issue.path[0] !== "colors" || issue.path[1] !== "palette") return issue;
382
+ const hue = input?.colors?.palette?.[String(issue.path[2])];
383
+ if (hue == null || typeof hue !== "object") return issue;
384
+ const keys = Object.keys(hue);
385
+ const unknown = keys.filter((k) => !RAMP_KEYS.has(k));
386
+ const message = "seed" in hue && "tones" in hue ? "a hue is either seeded (`seed`, with optional `anchors`) or fixed (`tones`) — not both. Drop one: with both present the `tones` are ignored at generate time." : unknown.length ? `unknown key${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${k}"`).join(", ")}. A hue takes \`seed\` + optional \`anchors\`, or \`tones\`.` : keys.length === 0 ? "a hue needs `seed` (+ optional `anchors`) or `tones`." : null;
387
+ return message ? {
388
+ ...issue,
389
+ message
390
+ } : issue;
391
+ };
341
392
  /**
342
393
  * Validate an unknown value against the design-system schema.
343
394
  *
@@ -350,7 +401,7 @@ function validate(input) {
350
401
  if (!result.success) return {
351
402
  ok: false,
352
403
  data: void 0,
353
- errors: result.error.issues,
404
+ errors: result.error.issues.map(explainRamp(input)),
354
405
  warnings: []
355
406
  };
356
407
  const { palette, roles } = result.data.colors;
@@ -532,6 +583,50 @@ function ladderWarnings(palette) {
532
583
  return warnings;
533
584
  }
534
585
  /**
586
+ * Gamut mapping nudges lightness by a hair, so an exact `<` comparison would
587
+ * report rounding as an inversion. Well below any deviation a person could see.
588
+ */
589
+ const MONOTONIC_EPSILON = .001;
590
+ /**
591
+ * Ramps whose lightness does not fall monotonically from 50 to 950.
592
+ *
593
+ * A ramp built from the ladder alone cannot invert — only pinned colours (a seed,
594
+ * an `anchor`, a `tones` entry) can, because they are the one thing allowed off
595
+ * it. `ladderWarnings` will not catch it: it compares each pinned step against
596
+ * *its own* ladder rung, never against its neighbours, so it reports the wrong
597
+ * defect ("reads darker than others at 600") while the real problem is that 600
598
+ * is darker than 700. And since `LADDER_TOLERANCE` is 0.03 — the same size as the
599
+ * ladder's own 50→100 gap — two anchors can each sit within tolerance and still
600
+ * invert, with no warning at all.
601
+ *
602
+ * This is an error rather than a warning because the rest of the system assumes
603
+ * the order: `snap` picks the nearest step by ladder lightness, the mode-stable
604
+ * solid family hard-codes 600/700/800 as increasingly dark, and both dark tables
605
+ * re-point steps on the same assumption. An inverted ramp therefore runs hover
606
+ * states backwards — the failure the fixed ladder was introduced to eliminate.
607
+ */
608
+ function monotonicityErrors(palette) {
609
+ const errors = [];
610
+ for (const [hue, expanded] of Object.entries(palette)) {
611
+ const numeric = expanded.numeric ?? {};
612
+ const steps = NUMERIC_STEPS.filter((s) => numeric[s] != null);
613
+ for (let i = 1; i < steps.length; i++) {
614
+ const [prev, step] = [steps[i - 1], steps[i]];
615
+ const lPrev = hexToOklch(numeric[prev]).l;
616
+ const lStep = hexToOklch(numeric[step]).l;
617
+ if (lStep < lPrev + MONOTONIC_EPSILON) continue;
618
+ const pinned = expanded.deviations ?? {};
619
+ const culprits = [prev, step].filter((s) => pinned[s] != null);
620
+ const fix = culprits.length ? culprits.map((s) => {
621
+ const l = hexToOklch(numeric[s]).l;
622
+ return `${numeric[s]} (pinned at ${s}) sits at L ${l.toFixed(3)}, nearest step ${nearestStep(l)}`;
623
+ }).join("; ") : `no step here is pinned, so this is a bug in the generator rather than in your config`;
624
+ errors.push(`colors.palette.${hue}: step ${step} (${numeric[step]}, L ${lStep.toFixed(3)}) is LIGHTER than step ${prev} (${numeric[prev]}, L ${lPrev.toFixed(3)}). A ramp must darken from 50 to 950 — inverted, a hover from \`bg-<role>-solid\` to \`-solid-bold\` gets lighter, and the dark-mode tables re-point steps assuming the order. ${fix}.`);
625
+ }
626
+ }
627
+ return errors;
628
+ }
629
+ /**
535
630
  * Surface roles: a bare `bg` (cards, panels, inputs), `bg-muted` for the page
536
631
  * behind them, `bg-x-muted` for wells, and `bg-bold`/`-x-bold` for the inverse
537
632
  * surface a tooltip sits on. Elevation is expressed by *which* token you reach
@@ -1194,6 +1289,8 @@ function renderCssClasses(ds) {
1194
1289
  const typeRoles = Object.keys(ds.typography?.roles ?? {});
1195
1290
  const shadows = Object.keys(ds.shadows ?? {});
1196
1291
  const effects = Object.keys(ds.animations?.effects ?? {});
1292
+ const stateful = Object.entries(ds.animations?.effects ?? {}).filter(([, e]) => e.kf === "composite" || e.kf === "paint").map(([name]) => name);
1293
+ const keyframeOnly = effects.filter((name) => !stateful.includes(name));
1197
1294
  const items = ds.patterns?.items ?? {};
1198
1295
  const patterns = Object.keys(items);
1199
1296
  const patternRoles = Array.from(new Set(Object.values(items).flatMap((p) => [p.default_role, ...p.roles ?? []].filter(Boolean))));
@@ -1253,9 +1350,29 @@ Every utility accepts a **container-breakpoint prefix**; animation utilities als
1253
1350
  \`--width-breakout\` / \`--width-spotlight\` and \`--gutter\`.
1254
1351
  - **\`rhythm\`** — relationship-based vertical spacing (margins between headings, paragraphs,
1255
1352
  lists, media) driven by the space scale. Usually paired with \`centered\`.
1256
- - **\`split\`** — equal flex columns. Ratio rule: **\`split-<a>-<b>\`** where \`<a>-<b>\` ∈
1257
- \`1-2\`, \`2-1\`, \`1-3\`, \`3-1\`, \`1-4\`, \`4-1\`, \`2-3\`, \`3-2\` (breakpoint-prefixable).
1258
- - **Flex** — \`flex\`, \`flex-row\`, \`flex-col\`; \`g\` for gap (space-scale token).
1353
+ - **\`split\`** — a two-column pair. Ratio rule: **\`split-<a>-<b>\`** where \`<a>-<b>\` ∈
1354
+ \`1-2\`, \`2-1\`, \`1-3\`, \`3-1\`, \`1-4\`, \`4-1\`, \`2-3\`, \`3-2\` (breakpoint-prefixable); equal
1355
+ columns without one. The ratio is a flex **basis**, so a column's padding counts
1356
+ inside its share, and \`min-inline-size: 0\` is built in so long unbreakable content
1357
+ can't stretch a column past it.
1358
+ - **Stacking is \`flex-col\`** — there is no split-specific class for it:
1359
+ \`class="split flex-col md-split-1-2"\` is stacked below 48rem and 1:2 above,
1360
+ because the \`<bp>-\` ratio classes assert the row. While stacked the ratio goes
1361
+ inert on its own (a percentage basis against an auto-height column resolves as
1362
+ \`content\`), unless you give the split a definite \`block-size\`.
1363
+ - **\`split-reverse\`** — swaps the two panels (breakpoint-prefixable). Implemented
1364
+ as \`order\` on the first child, so it reverses on whichever axis the split is
1365
+ currently on: bare, it swaps the columns in a row AND the rows in a stack;
1366
+ scoped (\`md-split-reverse\`) it swaps only once there are two columns — media
1367
+ first in source so it leads on mobile, on the right at width. The ratio stays
1368
+ with the source-first child, not with the visual position.
1369
+ - **Accessibility:** reversing makes visual order disagree with DOM order, and
1370
+ focus order follows the DOM (WCAG 2.4.3 Focus Order). Put focusable content in
1371
+ **only one** of the two panels, or the tab order will not be linear. The
1372
+ pattern declares \`reading-flow: flex-visual\`, which fixes this properly where
1373
+ it is supported; support is not yet broad enough to rely on.
1374
+ - **Flex** — \`flex\`, \`flex-row\`, \`flex-col\`, \`flex-row-reverse\`, \`flex-col-reverse\`
1375
+ (all breakpoint-prefixable).
1259
1376
  - **Alignment** — \`items-{start,center,end,stretch}\`, \`justify-{start,center,end,between}\`,
1260
1377
  text align \`text-{start,center,end}\` (all breakpoint-prefixable).
1261
1378
  - **Display** — \`block\`, \`inline\`, \`inline-block\`, \`flex\`, \`grid\`, \`hidden\`
@@ -1266,15 +1383,27 @@ Every utility accepts a **container-breakpoint prefix**; animation utilities als
1266
1383
  ## Spacing
1267
1384
 
1268
1385
  The space scale is ${code(spaceNames)} exposed as \`--space-<name>\` tokens.
1269
- Gap (\`g\`) and \`rhythm\` margins consume these tokens; prefer \`rhythm\` for vertical flow
1270
- rather than per-element margins.
1386
+ \`rhythm\` margins consume these tokens; prefer \`rhythm\` for vertical flow rather than
1387
+ per-element margins.
1388
+
1389
+ Rule: **\`gap-<name>\`**, **\`gap-x-<name>\`** (column) and **\`gap-y-<name>\`** (row), name ∈
1390
+ ${code(spaceNames)} — all breakpoint-prefixable (\`md-gap-l\`, \`@md:gap-l\`). These are the
1391
+ framework's own utilities in every format: the fluid steps are deliberately kept out of
1392
+ Tailwind's \`--spacing-*\` namespace (named keys there shadow the size scales, so
1393
+ \`max-w-7xl\` would resolve to \`var(--spacing-7xl)\`), which means Tailwind's numeric
1394
+ \`gap-4\` still uses its own multiplier and coexists with these.
1271
1395
 
1272
1396
  ## Typography
1273
1397
 
1274
1398
  Rule: **\`font-<role>\`** — role ∈ ${code(typeRoles)}. Each role carries its own family,
1275
- size (from the type scale ${code(typeNames)}), tracking, transform, and weight.
1399
+ size (from the type scale ${code(typeNames)}), tracking, transform, weight and \`text-wrap\`.
1276
1400
  Families: ${code(families)} (\`--font-*\`).
1277
1401
 
1402
+ Because the role owns \`text-wrap\`, a heading is balanced and copy is \`pretty\` with **no
1403
+ class at all** wherever \`typography.headings\` maps the bare element to a role. Override one
1404
+ element with \`text-{wrap,nowrap,balance,pretty}\` — the per-element escape hatch, for markup
1405
+ that carries no role class. (These four are Tailwind's own in the tailwind format.)
1406
+
1278
1407
  ## Colour
1279
1408
 
1280
1409
  **Functional tokens are the primary vocabulary** — classes name the *job*, not the tone, and
@@ -1360,10 +1489,41 @@ follows non-rectangular shapes).
1360
1489
 
1361
1490
  ## Animation
1362
1491
 
1363
- Rule: **\`<effect>\`** (with the state/flip prefixes above) — effect ∈ ${code(effects)}.
1492
+ An effect carries no motion of its own — it sets \`--<prop>-from\`/\`-to\` and picks a keyframe.
1493
+ A **driver** supplies the motion, and you always compose one of each:
1494
+
1495
+ - \`animate-view\` — plays as the element crosses the viewport
1496
+ - \`animate-scroll\` — scrubs against page scroll
1497
+ - \`animate-trigger\` — time-based; plays once when \`.is-active\` / \`[data-active]\` is set
1498
+ - \`transition\` — transitions the same from/to vars, so it reverses on a state flip
1499
+
1500
+ Rule: **\`<effect>\`** — effect ∈ ${code(effects)}. The state/flip prefixes above pair with
1501
+ \`transition\` and apply to every one of them.
1502
+
1503
+ Each state matches the element **or its direct parent** (\`.hover-<fx>:hover, :hover > .hover-<fx>\`),
1504
+ which is what makes \`reveal-*\` usable: it rests at a zero-area \`clip-path\`, and \`clip-path\` clips
1505
+ hit-testing as well as painting, so the element itself can never be hovered.${keyframeOnly.length ? `
1506
+
1507
+ ${code(keyframeOnly)} animate \`height\`, the only stage that reflows and the only one behind a
1508
+ feature gate: \`0 → auto\` is not interpolable without \`interpolate-size: allow-keywords\`, so
1509
+ \`transition\` declares \`height\` only inside an \`@supports\` for it. The \`layout\` **keyframe**
1510
+ has the same dependency — this is not a limit of the transition driver.` : ""}
1511
+
1512
+ **When it plays.** \`animate-view\` and \`.is-active\` are both timed off the element's **midpoint**:
1513
+ motion starts once that midpoint is 10% of the viewport in, and a one-shot entrance completes at
1514
+ 25%. Both stops are the element's position on screen rather than a fraction of its own height, so a
1515
+ small card and a full-bleed section behave alike. Shift the window with \`--anim-start\` /
1516
+ \`--anim-end\`, or replace it outright with \`--anim-range\`.
1517
+
1364
1518
  Composed **journeys** chain multiple effects into one entrance: rule \`<parts>-journey\`
1365
- (e.g. \`fade-slide-journey\`, \`fade-scale-blur-journey\`). All require \`transition\` on the
1366
- element; scroll-linked entrances resolve within the first portion of the element's scroll.
1519
+ (e.g. \`fade-slide-journey\`, \`fade-scale-blur-journey\`). They need a **keyframe** driver, not
1520
+ \`transition\`. A journey is entry → hold → exit, so it starts on that same 10% pivot but runs to the
1521
+ end of the exit phase — the hold occupies the middle of the crossing instead of the bottom edge of
1522
+ the screen.
1523
+
1524
+ **\`stagger\`** on a parent offsets each child by \`--stagger-amount\` (time-based drivers) and by
1525
+ \`--stagger-range-step\` (scroll-driven ones — \`animation-delay\` is ignored on a progress-based
1526
+ timeline). Journeys set an explicit range and opt out.
1367
1527
 
1368
1528
  ## Component patterns
1369
1529
 
@@ -1467,6 +1627,13 @@ enforces.
1467
1627
 
1468
1628
  - Set \`"$schema": "${SCHEMA_URL}"\` in the config for editor autocomplete + validation.
1469
1629
  - Scaffold a starter config with \`vitops init\`; check one with \`vitops validate\`.
1630
+ - **This can be a standalone file, or live inside a site config.** Anywhere the
1631
+ toolchain takes a config — \`--input\`, the Vite plugin, the Astro integration's
1632
+ \`css.input\` — that file may be a \`design-system.json\` or the larger site config
1633
+ that embeds one under \`designSystem.themes.<name>\`. They are told apart by shape,
1634
+ and the fields below are the same either way. A site config additionally supplies
1635
+ the site-level facts generation reads (its default colour scheme, legal documents,
1636
+ icon sprite), so the path is declared once rather than per option.
1470
1637
  - The *why* behind each section: [colour system](concepts/color.md),
1471
1638
  [type & space scales](concepts/scales.md), [component patterns](concepts/patterns.md).
1472
1639
  - What each output format does with these tokens: [formats.md](formats.md).
@@ -1575,19 +1742,31 @@ docs build.
1575
1742
  @layer vitops.base, vitops.components, vitops.utilities;
1576
1743
  \`\`\`
1577
1744
 
1578
- - \`vitops.base\` — the UA reset and the pure \`:root\` token blocks.
1579
- - \`vitops.components\` — the animation engine, structural layout, and every pattern.
1745
+ - \`vitops.base\` — the reset (\`box-sizing: border-box\` on every element and pseudo-element,
1746
+ a 16px root, no body margin) and the pure \`:root\` token blocks. Lowest of the three, so
1747
+ your own reset overrides it — unlayered, or from a layer you declare before \`vitops.base\`.
1748
+ - \`vitops.components\` — the animation engine, the structural patterns (\`.rhythm\`,
1749
+ \`.centered\`, \`.region\`, \`.split\`, \`.reveal\`) and every UI pattern.
1580
1750
  - \`vitops.utilities\` — \`bg-*\`, \`text-*\`, \`border-*\`, \`drop-shadow-*\`, \`font-*\`,
1581
- animation effects, and the display/\`sr-only\` families.
1582
-
1583
- So a utility overrides a pattern: \`class="card bg-danger-muted"\` tints the card. Your own
1584
- unlayered CSS beats all three — see [concepts/patterns.md](concepts/patterns.md) for the
1585
- override story and the one gotcha (a reset must be layered and ordered first).
1586
-
1587
- Known gap: \`layout.css\` is a single partial mixing structural rules (\`.rhythm\`,
1588
- \`.centered\`) with utilities (\`.m-*\`, \`.flex\`, \`.split-*\`), so it sits in
1589
- \`vitops.components\` whole — its utility half cannot yet override a pattern. Splitting it is
1590
- tracked separately.
1751
+ \`gap-*\`, animation effects, the layout utilities (\`.m-*\`, \`.flex-*\`, \`.items-*\`,
1752
+ \`.split-<a>-<b>\`, track placement) and the display/\`sr-only\` families.
1753
+
1754
+ So a utility overrides a pattern: \`class="card bg-danger-muted"\` tints the card,
1755
+ \`class="split flex-col"\` stacks the split, \`class="table text-center"\` centres the table.
1756
+ Your own unlayered CSS beats all three — see [concepts/patterns.md](concepts/patterns.md) for
1757
+ the override story and the one gotcha (a reset must be layered and ordered first).
1758
+
1759
+ The classification is by RULE, not by file: a partial that mixes patterns and utilities is
1760
+ split in two rather than shelved whole. \`layout.css\` (patterns) and \`layout-utilities.css\`
1761
+ (utilities) are the same family in two files for exactly this reason, and the \`tailwind\`
1762
+ format reaches the same arrangement by its own route — patterns in \`@layer components\`,
1763
+ utilities as \`@utility\`.
1764
+
1765
+ Known gap: the \`typography.headings\` bare-element bindings (\`h1\`, \`h2\`, \`body\`) are
1766
+ emitted alongside \`.font-<role>\` and so sit in \`vitops.utilities\`, where a tag rule
1767
+ outranks every pattern — \`<h2 class="pull-quote">\` keeps the heading's font-size in
1768
+ \`css\`/\`bricks\` and the pattern's in \`tailwind\`, which puts the bindings in \`@layer base\`.
1769
+ Give the element a \`.font-<role>\` class to pin it either way.
1591
1770
 
1592
1771
  ## \`bricks\` — WordPress / Bricks Builder payload
1593
1772
 
@@ -1723,8 +1902,18 @@ ignores the appearance.
1723
1902
 
1724
1903
  Two attributes, one flip: \`data-brx-theme\` is Bricks' own (Bricks sets it on the
1725
1904
  WordPress target), \`data-theme\` is what the shipped \`<color-scheme-toggle>\` writes on
1726
- \`<html>\`, so the toggle works on every other target. Set either. There is deliberately
1727
- no \`prefers-color-scheme\` block — the flip follows an explicit choice only.
1905
+ \`<html>\`, so the toggle works on every other target. Set either.
1906
+
1907
+ The OS preference is a **second, opt-in block**. Set
1908
+ \`designSystem.defaultColorScheme: "system"\` in your site config and the same delta is emitted again inside
1909
+ \`@media (prefers-color-scheme: dark)\`, under
1910
+ \`${SYSTEM_DARK_SEL}\` — i.e. whenever no explicit choice has been made. That is what makes
1911
+ \`<color-scheme-toggle>\`'s "System" position resolve to the OS (it *removes* the attribute,
1912
+ so without this block it fell through to light), and it gives a no-JS page the OS
1913
+ appearance, which the toggle alone never could. An explicit light choice still wins.
1914
+
1915
+ It is opt-in rather than default because turning it on flips a site dark for dark-OS
1916
+ visitors, which is the site's decision and not the design system's.
1728
1917
 
1729
1918
  ## Contrast guarantees
1730
1919
 
@@ -1947,6 +2136,118 @@ variable-driven CSS framework plus progressively-enhanced web components, genera
1947
2136
  * [Bricks Builder](bricks/) - custom elements and how to style them
1948
2137
  `;
1949
2138
  }
2139
+ function renderIconsConcept() {
2140
+ return `${frontmatter({
2141
+ type: "Design Concept",
2142
+ title: "Vitops icons — one semantic vocabulary across icon sets",
2143
+ description: "Semantic icon names that resolve per configured set, how the bundle is derived on a server build, and the three delivery paths (astro-icon, astro-iconset, SVG sprite).",
2144
+ resource: DS_PATH,
2145
+ tags: [
2146
+ "icons",
2147
+ "svg",
2148
+ "sprite",
2149
+ "astro-icon",
2150
+ "design-system"
2151
+ ]
2152
+ })}
2153
+
2154
+ # Icons
2155
+
2156
+ Icons are named by **meaning**, not by set. \`menu\` resolves to \`fa7-solid:bars\`,
2157
+ \`lucide:menu\` or \`ph:list\` depending on which set the site configures, so changing
2158
+ sets is a config edit rather than a find-and-replace across templates.
2159
+
2160
+ The escape hatch is deliberate and always available: **a name containing \`:\` passes
2161
+ through untouched**. \`<Icon name="simple-icons:zoho" />\` renders that exact glyph
2162
+ whether or not the semantic map knows it.
2163
+
2164
+ ## Declaring them
2165
+
2166
+ The \`icons\` block lives in the **site config**, not \`design-system.json\` — it
2167
+ describes what a site uses, not what the design system defines.
2168
+
2169
+ \`\`\`json
2170
+ {
2171
+ "icons": {
2172
+ "ui": "ph",
2173
+ "brand": "simple-icons",
2174
+ "weight": "bold",
2175
+ "semantic": ["menu", "close", "arrow-right"],
2176
+ "simple-icons": ["zoho", "cloudflare"],
2177
+ "sprite": true
2178
+ }
2179
+ }
2180
+ \`\`\`
2181
+
2182
+ \`ui\` and \`brand\` pick which set a semantic name resolves against. Any other key is
2183
+ an iconify collection name with an explicit list, passed through verbatim.
2184
+
2185
+ **\`weight\` is only meaningful for sets that encode weight in the icon NAME.**
2186
+ Phosphor does (\`list\`, \`list-bold\`, \`list-fill\`), so \`ph\` accepts it; Font Awesome
2187
+ splits weights across collections instead (\`fa7-solid\` vs \`fa7-regular\`), so there
2188
+ the weight is part of the prefix and this key is ignored.
2189
+
2190
+ ## Why the bundle list exists
2191
+
2192
+ astro-icon is **zero-config on a static build**. Under \`output: 'server'\` it bundles
2193
+ *every icon in a set* unless given an \`include\` map — which is why projects end up
2194
+ hand-maintaining one.
2195
+
2196
+ The \`icons\` option on \`getvitops()\` derives it by scanning your source, merged with
2197
+ whatever you declare. On a static build **no include is passed at all**: there is
2198
+ nothing to trim there, and a list could only drop a glyph the scan couldn't see.
2199
+
2200
+ Two failure modes, treated differently on purpose:
2201
+
2202
+ - A name you **declared** that doesn't resolve is a config error and **throws**.
2203
+ - A name only the **scan** found and can't resolve just **warns** — a bare unmapped
2204
+ name is more often a local \`src/icons/*.svg\` than a mistake, and a dev server
2205
+ that dies on a template typo is worse than one that tells you.
2206
+
2207
+ Names computed at runtime (\`<Icon name={expr} />\`) can't be read statically. They are
2208
+ reported with file and line, never guessed at — declare them in \`semantic\` or a
2209
+ per-set list. Run \`vitops icons\` to see the whole report.
2210
+
2211
+ ## Delivery
2212
+
2213
+ | Path | Used by | Needs |
2214
+ | --- | --- | --- |
2215
+ | \`astro-icon\` / \`astro-iconset\` | Astro sites | the integration, an optional peer |
2216
+ | **SVG sprite** (\`icons.svg\`) | Bricks/WordPress, EmDash renderers, plain HTML | \`icons.sprite: true\` |
2217
+
2218
+ The sprite is the no-JavaScript path: \`<svg><use href="/vitops/icons.svg#ph--list"></svg>\`.
2219
+ Ids are the qualified name with \`:\` replaced by \`--\` (\`:\` is not valid in a fragment
2220
+ identifier), **plus a set-independent \`icon-<name>\` alias for every semantic name** —
2221
+ so sprite markup survives an icon-set change the same way \`resolveIcon\` protects Astro
2222
+ call sites.
2223
+
2224
+ In WordPress, \`vitops_icon('menu')\` and \`[vitops_icon name="menu"]\` emit that markup,
2225
+ and a **Vitops → Icon** Bricks element wraps the same helper.
2226
+
2227
+ ⚠️ An external-file \`<use>\` is **same-origin only** and dead under \`file://\`. Serving
2228
+ \`dist/\` from a different origin makes every icon vanish with no console error.
2229
+
2230
+ ## Markup
2231
+
2232
+ Always the framework's \`.icon\` box:
2233
+
2234
+ \`\`\`html
2235
+ <span class="icon" aria-hidden="true"><svg>…</svg></span>
2236
+ \`\`\`
2237
+
2238
+ \`.icon\` sizes in \`em\` via \`--icon-size\` (default \`1.25em\`) and sets \`fill: currentColor\`,
2239
+ so an icon inherits the colour of whatever it sits in. Because \`.cta\` and
2240
+ \`:where(button, .btn)\` are already \`inline-flex\` with a \`gap\`, **a start or end icon is
2241
+ child order — not a modifier class**.
2242
+
2243
+ Icons are \`aria-hidden\` by default. Pass a label only when the icon carries meaning on
2244
+ its own; beside a text label it would be announced twice.
2245
+
2246
+ Note \`.icon-mask\` is a different thing: the CSS-only adornment path used by
2247
+ \`.link--icon::before\`, where the glyph is a \`mask-image\` on a pseudo-element and there
2248
+ is no markup to hang an \`<svg>\` on.
2249
+ `;
2250
+ }
1950
2251
  function renderConceptsIndex() {
1951
2252
  return `${INDEX_NOTE}
1952
2253
 
@@ -1957,6 +2258,7 @@ function renderConceptsIndex() {
1957
2258
  * [Colour system](color.md) - seeded OKLCH scales on a shared lightness ladder, target-prefixed tokens, automatic dark mode
1958
2259
  * [Type & space scales](scales.md) - fluid modular scales and the tokens they emit
1959
2260
  * [Component patterns](patterns.md) - token cascade, override hooks, states, role variants
2261
+ * [Icons](icons.md) - semantic names across icon sets, bundle derivation, sprite delivery
1960
2262
  `;
1961
2263
  }
1962
2264
  function renderCssIndex() {
@@ -2004,6 +2306,7 @@ function generateDocs(ds, assetsDir) {
2004
2306
  "concepts/color.md": renderColorConcept(ds),
2005
2307
  "concepts/scales.md": renderScalesConcept(ds),
2006
2308
  "concepts/patterns.md": renderPatternsConcept(ds),
2309
+ "concepts/icons.md": renderIconsConcept(),
2007
2310
  "css/index.md": renderCssIndex(),
2008
2311
  "css/classes.md": renderCssClasses(ds),
2009
2312
  "bricks/index.md": renderBricksIndex(),
@@ -2362,6 +2665,10 @@ Layout is composed from utilities rather than bespoke CSS:
2362
2665
  decision rather than per-element margins.
2363
2666
  - \`.flex\` / \`.split-<a>-<b>\` / \`.grid\` — the structural families, with container-query
2364
2667
  variants prefixed \`sm-\` / \`md-\` / \`lg-\` / \`xl-\` (30 / 48 / 64 / 80rem).
2668
+ \`.split\` is a two-column pair. Stack it with \`.flex-col\` and let a
2669
+ breakpoint-prefixed ratio un-stack it (\`split flex-col md-split-1-2\`);
2670
+ \`.split-reverse\` swaps the two panels. Reversing puts visual order out of step
2671
+ with DOM order, so keep focusable content in only one panel.
2365
2672
 
2366
2673
  Breakpoints are **container** queries, not viewport media queries: a component
2367
2674
  responds to the space it is given, so the same markup works in a sidebar and a full-width
@@ -2442,7 +2749,7 @@ across every pattern. Focus rings are never removed, only restyled.
2442
2749
  * `design-system.json`-compliant `designSystem`.
2443
2750
  *
2444
2751
  * Authored with `zod/mini` (same instance the design-system schema uses), so the
2445
- * two compose: `DesignSystemSchema` is embedded as `designSystem.<theme>` and the
2752
+ * two compose: `DesignSystemSchema` is embedded as `designSystem.themes.<theme>` and the
2446
2753
  * whole thing serialises to one JSON Schema via `z.toJSONSchema`. Everything a
2447
2754
  * generator needs derives from here: the `SiteConfig` type (`z.infer`), the
2448
2755
  * published JSON Schema (`siteJsonSchema`), and runtime validation (`validateSite`).
@@ -2475,9 +2782,32 @@ const DayOfWeek = z.enum([
2475
2782
  "Sunday"
2476
2783
  ]);
2477
2784
  const DesignSystemEntry = z.extend(DesignSystemPatchSchema, {
2478
- displayName: desc(z.optional(LocalizableSchema), "Human-readable theme name (localisable)."),
2479
- extends: desc(z.optional(z.string()), "Another `designSystem` key to inherit from; this entry then only supplies what it overrides.")
2785
+ displayName: desc(z.optional(LocalizableSchema), "Human-readable theme name (localisable). The label a theme picker would show — nothing reads it yet, because nothing builds more than one theme (see `themes`)."),
2786
+ extends: desc(z.optional(z.string()), "Another `themes` key to inherit from; this entry then only supplies what it overrides.")
2480
2787
  });
2788
+ /**
2789
+ * The design system as a whole: the named themes plus the facts that describe the
2790
+ * system rather than any one theme.
2791
+ *
2792
+ * `themes` is nested rather than being the block itself because the block used to
2793
+ * BE the map — every key was a theme name, so there was nowhere to put
2794
+ * system-wide metadata without it colliding with a theme of that name.
2795
+ *
2796
+ * Note the two axes here are independent, and deliberately so. `defaultTheme`
2797
+ * picks a *theme* (an authored design system); `defaultColorScheme` picks an
2798
+ * *appearance* (light/dark), which every theme already has because the flip is
2799
+ * derived from one ramp rather than authored. "Theme `elegant`, appearance
2800
+ * follows the OS" has to be sayable, and a single union could not express it.
2801
+ */
2802
+ const DesignSystemBlock = desc(z.strictObject({
2803
+ themes: desc(z.record(z.string(), DesignSystemEntry), "Named themes. `default` is the base; others may `extends` another and supply a partial patch. Light/dark is NOT a theme — the functional tokens flip per appearance within each one, so `default` already has both. Only the default theme is built today; multi-theme output and a picker are not wired yet."),
2804
+ defaultTheme: desc(z.optional(z.string()), "Which `themes` entry to use (convention: \"default\"). Validated against the map; selecting a non-default theme is not wired yet."),
2805
+ defaultColorScheme: desc(z.optional(z.enum([
2806
+ "light",
2807
+ "dark",
2808
+ "system"
2809
+ ])), "Initial appearance. `\"system\"` follows the OS via `prefers-color-scheme` and is what makes `<color-scheme-toggle>`'s \"System\" position resolve to anything — it removes the theme attribute, so without this the page falls through to light. It also gives a no-JS page the OS appearance. Defaults to `\"light\"`, because switching an existing site to `\"system\"` visibly flips it dark for dark-OS visitors.")
2810
+ }), "The design system: named themes plus the system-wide facts (which theme, which appearance). A bare design system, or a bare theme map, is accepted as shorthand for `{ themes: … }`.");
2481
2811
  const LocaleSchema = z.object({
2482
2812
  name: desc(z.string(), "Display name of the locale (e.g. \"English\")."),
2483
2813
  tagline: desc(z.optional(z.string()), "Site tagline in this locale."),
@@ -2626,7 +2956,7 @@ const FontVariantSchema = z.object({
2626
2956
  unicodeRange: z.optional(z.array(z.string())),
2627
2957
  display: z.optional(FontDisplay)
2628
2958
  });
2629
- const FontSchema = z.object({
2959
+ const SiteFontSchema = z.object({
2630
2960
  name: z.string(),
2631
2961
  provider: FontProvider,
2632
2962
  cssVariable: z.string().check(z.regex(/^--/)),
@@ -2828,11 +3158,8 @@ const SiteConfigSchema = z.object({
2828
3158
  splitRatio: desc(z.optional(z.number().check(z.gte(0), z.lte(1))), "Fraction of traffic sent to the variant (0–1)."),
2829
3159
  variants: desc(z.optional(z.record(z.string(), AbVariantSchema)), "Named variants; each targets an environment and may deep-merge config `overrides`.")
2830
3160
  })), "A/B testing: cookie-based split plus named variants whose `overrides` patch the config per environment."),
2831
- designSystem: desc(z.record(z.string(), DesignSystemEntry), "Named-theme map of design systems. `default` is the base; other entries may `extends` another and supply a partial patch. Light/dark is automatic (functional tokens flip per appearance), not a separate theme entry. A bare design system (with `colors`) is shorthand for `{ default: … }`."),
2832
- defaultTheme: desc(z.optional(z.string()), "Which `designSystem` entry to use by default (convention: \"default\")."),
2833
- defaultColorScheme: desc(z.optional(z.enum(["light", "dark"])), "Initial appearance to render before any user/system preference applies."),
2834
- respectSystemPreference: desc(z.optional(z.boolean()), "Follow the user's OS light/dark preference."),
2835
- fonts: desc(z.optional(z.array(FontSchema)), "Font LOADING (a serialisable projection of the Astro Fonts API: provider, weights, subsets, preload). Font TOKENS live in `designSystem.<theme>.fonts` and reference the same `cssVariable`."),
3161
+ designSystem: DesignSystemBlock,
3162
+ fonts: desc(z.optional(z.array(SiteFontSchema)), "Font LOADING (a serialisable projection of the Astro Fonts API: provider, weights, subsets, preload). Font TOKENS live in `designSystem.themes.<theme>.fonts` and reference the same `cssVariable`."),
2836
3163
  tags: desc(z.optional(z.record(z.string(), z.unknown())), "Free-form content-tag taxonomy (site-specific; not interpreted by the generator)."),
2837
3164
  postTypes: desc(z.optional(z.record(z.string(), z.unknown())), "Free-form content-type definitions (site-specific; not interpreted by the generator)."),
2838
3165
  galleries: desc(z.optional(z.record(z.string(), GallerySchema)), "Named image galleries (title, tags, images with localisable alt/caption)."),
@@ -2854,8 +3181,14 @@ const SiteConfigSchema = z.object({
2854
3181
  analytics: desc(z.optional(z.object({
2855
3182
  googleAnalyticsId: z.optional(z.string()),
2856
3183
  plausibleDomain: z.optional(z.string()),
2857
- googleTagManagerId: z.optional(z.string())
2858
- })), "Analytics provider IDs (gated per environment via `environments.<env>.analytics`)."),
3184
+ googleTagManagerId: z.optional(z.string()),
3185
+ clarityId: desc(z.optional(z.string()), "Microsoft Clarity project ID."),
3186
+ matomo: desc(z.optional(z.object({
3187
+ url: desc(z.string(), "Instance base URL (self-hosted, or `*.matomo.cloud`)."),
3188
+ siteId: desc(z.string(), "Site ID within that instance."),
3189
+ cookies: desc(z.optional(z.boolean()), "Default false — Matomo runs cookieless (`disableCookies`), which is what lets the cookie notice state positively that it sets none.")
3190
+ })), "Matomo instance.")
3191
+ })), "Analytics provider IDs (gated per environment via `environments.<env>.analytics`). Which provider is set is also what the generated privacy policy and cookie notice disclose — see `legal`."),
2859
3192
  notifications: desc(z.optional(z.object({ email: z.optional(z.email()) })), "Where site notifications (e.g. form submissions) are sent."),
2860
3193
  tracking: desc(z.optional(z.object({
2861
3194
  enabled: z.optional(z.boolean()),
@@ -2889,9 +3222,11 @@ const SiteConfigSchema = z.object({
2889
3222
  categories: z.optional(z.array(z.string()))
2890
3223
  }))
2891
3224
  })), "Legal pages: which documents exist, where they live, and the facts the generated prose asserts."),
2892
- icons: desc(z.optional(z.object({
2893
- ui: desc(z.optional(z.string()), "Icon set used for UI chrome (e.g. \"lucide\")."),
3225
+ icons: desc(z.optional(z.looseObject({
3226
+ ui: desc(z.optional(z.string()), "Icon set used for UI chrome (e.g. \"lucide\", \"ph\")."),
2894
3227
  brand: desc(z.optional(z.string()), "Icon set used for brand marks."),
3228
+ weight: desc(z.optional(z.string()), "Weight for suffix-weighted sets like Phosphor (\"regular\" | \"bold\" | \"duotone\" | \"fill\" | \"light\" | \"thin\"). Ignored by sets that split weights across collections, e.g. Font Awesome."),
3229
+ sprite: desc(z.optional(z.boolean()), "Emit an SVG sprite (icons.svg) alongside the stylesheet, for consumers that cannot run an icon integration (Bricks/WordPress, EmDash renderers)."),
2895
3230
  semantic: desc(z.optional(z.array(z.string())), "Named semantic icons to include."),
2896
3231
  "fa7-solid": z.optional(z.array(z.string())),
2897
3232
  "fa7-regular": z.optional(z.array(z.string())),
@@ -2900,7 +3235,8 @@ const SiteConfigSchema = z.object({
2900
3235
  "fa7-brands": z.optional(z.array(z.string())),
2901
3236
  "simple-icons": z.optional(z.array(z.string())),
2902
3237
  "material-symbols": z.optional(z.array(z.string())),
2903
- lucide: z.optional(z.array(z.string()))
3238
+ lucide: z.optional(z.array(z.string())),
3239
+ ph: z.optional(z.array(z.string()))
2904
3240
  })), "Icon sets and the specific icons to bundle from each (keys are iconify collection names)."),
2905
3241
  favicon: desc(z.optional(z.object({
2906
3242
  source: desc(z.string(), "Source image (SVG/PNG) the favicon set is generated from."),
@@ -2944,17 +3280,24 @@ function validateSite(input) {
2944
3280
  const errors = [];
2945
3281
  const has = (m, k) => !!m && k in m;
2946
3282
  if (!has(cfg.locales, cfg.defaultLocale)) errors.push(issue(["defaultLocale"], `defaultLocale "${cfg.defaultLocale}" is not in locales`));
2947
- if (!has(cfg.designSystem, "default") && cfg.defaultTheme == null) errors.push(issue(["designSystem"], "designSystem must include a \"default\" theme"));
2948
- if (cfg.defaultTheme != null && !has(cfg.designSystem, cfg.defaultTheme)) errors.push(issue(["defaultTheme"], `defaultTheme "${cfg.defaultTheme}" is not in designSystem`));
2949
- for (const [key, entry] of Object.entries(cfg.designSystem)) if (entry.extends != null && !has(cfg.designSystem, entry.extends)) errors.push(issue([
3283
+ const themes = cfg.designSystem?.themes ?? {};
3284
+ const defaultTheme = cfg.designSystem?.defaultTheme;
3285
+ if (!has(themes, "default") && defaultTheme == null) errors.push(issue(["designSystem", "themes"], "themes must include a \"default\" entry"));
3286
+ if (defaultTheme != null && !has(themes, defaultTheme)) errors.push(issue(["designSystem", "defaultTheme"], `defaultTheme "${defaultTheme}" is not in designSystem.themes`));
3287
+ for (const [key, entry] of Object.entries(themes)) if (entry.extends != null && !has(themes, entry.extends)) errors.push(issue([
2950
3288
  "designSystem",
3289
+ "themes",
2951
3290
  key,
2952
3291
  "extends"
2953
- ], `extends "${entry.extends}" is not a designSystem key`));
2954
- for (const key of Object.keys(cfg.designSystem)) try {
2955
- resolveTheme(cfg.designSystem, key);
3292
+ ], `extends "${entry.extends}" is not a themes key`));
3293
+ for (const key of Object.keys(themes)) try {
3294
+ resolveTheme(themes, key);
2956
3295
  } catch (e) {
2957
- errors.push(issue(["designSystem", key], e.message));
3296
+ errors.push(issue([
3297
+ "designSystem",
3298
+ "themes",
3299
+ key
3300
+ ], e.message));
2958
3301
  }
2959
3302
  if (cfg.primaryLocation != null && !has(cfg.locations, cfg.primaryLocation)) errors.push(issue(["primaryLocation"], `primaryLocation "${cfg.primaryLocation}" is not in locations`));
2960
3303
  if (typeof cfg.contact === "string" && !has(cfg.locations, cfg.contact)) errors.push(issue(["contact"], `contact "${cfg.contact}" is not a locations key`));
@@ -3061,22 +3404,49 @@ function resolveTheme(designSystem, key) {
3061
3404
  return out;
3062
3405
  }
3063
3406
  /**
3407
+ * Normalise the two `designSystem` shorthands to the canonical
3408
+ * `{ themes: { … } }` shape.
3409
+ *
3410
+ * Both are keyed off what CANNOT be a theme name in the other reading:
3411
+ * - `colors` → a bare `DesignSystem` was written inline.
3412
+ * - `themes` → already canonical.
3413
+ * - neither → the legacy bare theme map, from before the block gained
3414
+ * system-wide fields. Every config authored against the old schema takes this
3415
+ * branch, which is what keeps the change forgiving at runtime even though the
3416
+ * published JSON Schema moved.
3417
+ */
3418
+ function normaliseDesignSystem(raw) {
3419
+ const ds = raw.designSystem;
3420
+ if (ds == null || typeof ds !== "object") return;
3421
+ if ("themes" in ds) return;
3422
+ raw.designSystem = "colors" in ds ? { themes: { default: ds } } : { themes: ds };
3423
+ }
3424
+ /**
3064
3425
  * Resolve a raw (already-parsed) config into a validated `SiteConfig`:
3065
3426
  * 1. strip YAML nulls,
3066
- * 2. apply the active A/B variant's `overrides` (by `siteEnv`),
3067
- * 3. normalise a bare-`DesignSystem` `designSystem` shorthand to `{ default: … }`,
3427
+ * 2. normalise the `designSystem` shorthands to `{ themes: … }`,
3428
+ * 3. apply the active A/B variant's `overrides` (by `siteEnv`),
3068
3429
  * 4. validate (shape + cross-field).
3069
3430
  * Pure — no YAML/env access. A loader supplies `siteEnv`.
3431
+ *
3432
+ * Steps 2 and 3 are in this order deliberately. Normalising first means an
3433
+ * `abTesting` override always addresses the canonical shape
3434
+ * (`designSystem.themes.<name>`), whatever shorthand the base config used. With
3435
+ * the merge first, an override's key path depended on how the base happened to be
3436
+ * written — so the same patch hit a different place in two configs that were
3437
+ * otherwise equivalent.
3070
3438
  */
3071
3439
  function resolveSiteConfig(input, siteEnv = "production") {
3072
3440
  const raw = stripNulls(input);
3441
+ normaliseDesignSystem(raw);
3073
3442
  const envConfig = raw.environments?.[siteEnv];
3074
3443
  if (envConfig?.variant) {
3075
3444
  const overrides = raw.abTesting?.variants?.[envConfig.variant]?.overrides;
3076
- if (overrides) deepMerge(raw, overrides);
3445
+ if (overrides) {
3446
+ normaliseDesignSystem(overrides);
3447
+ deepMerge(raw, overrides);
3448
+ }
3077
3449
  }
3078
- const ds = raw.designSystem;
3079
- if (ds != null && "colors" in ds) raw.designSystem = { default: ds };
3080
3450
  const result = validateSite(raw);
3081
3451
  if (!result.ok) {
3082
3452
  const msg = result.errors.map((e) => ` ${e.path.join(".")}: ${e.message}`).join("\n");
@@ -3084,6 +3454,52 @@ function resolveSiteConfig(input, siteEnv = "production") {
3084
3454
  }
3085
3455
  return result.data;
3086
3456
  }
3457
+ /**
3458
+ * Is this raw config a `SiteConfig` rather than a bare `design-system.json`?
3459
+ *
3460
+ * The discriminator is **total**, not a heuristic, and both halves are load-
3461
+ * bearing: `SiteConfigSchema` cannot validate without a `designSystem` (a config
3462
+ * lacking one fails `validateSite` with `themes must include a "default" entry`),
3463
+ * and `DesignSystemSchema` is strict, so a `designSystem` key in a design system
3464
+ * is an `unrecognized_keys` error. No document can be read as both, and no
3465
+ * document that is neither reaches here without failing validation anyway.
3466
+ *
3467
+ * Deliberately shape-based rather than filename-based: consumers name this file
3468
+ * whatever suits them (`company.json`, `site.json`, `vitops.config.json`), and a
3469
+ * rule keyed to a name would silently stop working the moment someone renamed it.
3470
+ */
3471
+ function isSiteConfig(raw) {
3472
+ return typeof raw === "object" && raw != null && !Array.isArray(raw) && "designSystem" in raw;
3473
+ }
3474
+ /**
3475
+ * Resolve a raw config of either kind into the design system to build from.
3476
+ *
3477
+ * Every entry point that used to take a `design-system.json` goes through this,
3478
+ * so a consumer who keeps their tokens inside the larger site config points the
3479
+ * same option at that file instead of maintaining a second one. The site config
3480
+ * is handed back too, because the parts of generation that depend on site-level
3481
+ * facts (`designSystem.defaultColorScheme`, the legal documents, the icon
3482
+ * sprite) would otherwise need the same path declared a second time.
3483
+ *
3484
+ * The returned `designSystem` is **not** validated here — the caller does that,
3485
+ * so it can say which file and which theme the errors belong to. Validation of
3486
+ * the site config itself has already happened (`resolveSiteConfig` throws).
3487
+ */
3488
+ function resolveInput(raw, opts = {}) {
3489
+ if (!isSiteConfig(raw)) {
3490
+ if (opts.theme != null) throw new Error(`theme "${opts.theme}" was requested, but this is a design-system.json — it holds one design system and no \`themes\` map. Point at a site config to select a theme.`);
3491
+ return { designSystem: raw };
3492
+ }
3493
+ const site = resolveSiteConfig(raw, opts.siteEnv);
3494
+ const themes = site.designSystem?.themes ?? {};
3495
+ const theme = opts.theme ?? site.designSystem?.defaultTheme ?? "default";
3496
+ if (!(theme in themes)) throw new Error(`designSystem.themes has no "${theme}" entry (found: ${Object.keys(themes).join(", ") || "none"})`);
3497
+ return {
3498
+ designSystem: resolveTheme(themes, theme),
3499
+ site,
3500
+ theme
3501
+ };
3502
+ }
3087
3503
  /** Strip nulls (YAML `~`/`null`) → undefined so optional fields validate. */
3088
3504
  function stripNulls(obj) {
3089
3505
  if (obj === null) return void 0;
@@ -3137,6 +3553,30 @@ const KNOWN_PROCESSORS = {
3137
3553
  privacyUrl: "https://plausible.io/privacy",
3138
3554
  cookies: []
3139
3555
  },
3556
+ clarity: {
3557
+ name: "Microsoft Clarity",
3558
+ purpose: "session replay and heatmaps",
3559
+ country: "the United States",
3560
+ privacyUrl: "https://privacy.microsoft.com/privacystatement",
3561
+ cookies: [
3562
+ "_clck",
3563
+ "_clsk",
3564
+ "MUID"
3565
+ ],
3566
+ optOut: "https://privacy.microsoft.com/privacystatement"
3567
+ },
3568
+ matomo: {
3569
+ name: "Matomo",
3570
+ purpose: "website analytics",
3571
+ privacyUrl: "https://matomo.org/privacy-policy/",
3572
+ cookies: ["_pk_id", "_pk_ses"]
3573
+ },
3574
+ matomoCookieless: {
3575
+ name: "Matomo",
3576
+ purpose: "website analytics",
3577
+ privacyUrl: "https://matomo.org/privacy-policy/",
3578
+ cookies: []
3579
+ },
3140
3580
  turnstile: {
3141
3581
  name: "Cloudflare Turnstile",
3142
3582
  purpose: "bot protection on forms",
@@ -3185,6 +3625,8 @@ function detectProcessorKeys(site) {
3185
3625
  if (analytics?.googleAnalyticsId) keys.push("googleAnalytics");
3186
3626
  if (analytics?.googleTagManagerId) keys.push("googleTagManager");
3187
3627
  if (analytics?.plausibleDomain) keys.push("plausible");
3628
+ if (analytics?.clarityId) keys.push("clarity");
3629
+ if (analytics?.matomo) keys.push(analytics.matomo.cookies ? "matomo" : "matomoCookieless");
3188
3630
  if (site.security?.turnstile?.siteKey) keys.push("turnstile");
3189
3631
  const platform = site.deployment?.platform;
3190
3632
  const hosting = platform && HOSTING_PATTERNS.find(([re]) => re.test(platform))?.[1];
@@ -3199,10 +3641,27 @@ function detectProcessorKeys(site) {
3199
3641
  * an explicit declaration is a statement of fact we have no grounds to drop.
3200
3642
  */
3201
3643
  function resolveProcessors(site) {
3202
- const detected = detectProcessorKeys(site).map((k) => KNOWN_PROCESSORS[k]);
3644
+ const detected = detectProcessorKeys(site).map((k) => matomoCountry(KNOWN_PROCESSORS[k], site));
3203
3645
  const declared = site.legal?.privacyPolicy?.processors ?? [];
3204
3646
  return [...detected, ...declared];
3205
3647
  }
3648
+ /**
3649
+ * Matomo is the one provider whose location the config actually knows.
3650
+ *
3651
+ * Matomo Cloud is hosted in the EU; a self-hosted instance is wherever the
3652
+ * consumer put it, which is very often their own infrastructure in their own
3653
+ * country. So the cross-border transfer sentence is asserted only for the case we
3654
+ * can see. Naming a transfer that isn't happening is the same class of error as
3655
+ * omitting one that is — the config records facts, and "we don't know" is a fact.
3656
+ */
3657
+ function matomoCountry(processor, site) {
3658
+ if (processor.name !== "Matomo") return processor;
3659
+ const url = site.analytics?.matomo?.url ?? "";
3660
+ return /\.matomo\.cloud/i.test(url) ? {
3661
+ ...processor,
3662
+ country: "the European Union"
3663
+ } : processor;
3664
+ }
3206
3665
 
3207
3666
  //#endregion
3208
3667
  //#region src/legal/derive.ts
@@ -3321,7 +3780,7 @@ function derivePolicyVars(site) {
3321
3780
  const processors = resolveProcessors(site);
3322
3781
  const fields = formFields(site);
3323
3782
  const hasForms = fields.length > 0;
3324
- const hasAnalytics = !!site.analytics && Object.values(site.analytics).some((v) => typeof v === "string" && v.length > 0);
3783
+ const hasAnalytics = Object.values(site.analytics ?? {}).some((v) => typeof v === "string" ? v.length > 0 : !!v);
3325
3784
  processors.filter((p) => p.cookies?.length);
3326
3785
  const consent = legal?.cookieConsent;
3327
3786
  const contact = resolvePrivacyContact(site);
@@ -3333,15 +3792,18 @@ function derivePolicyVars(site) {
3333
3792
  fields.some((f) => /newsletter|subscribe|mailing/i.test(f.name)) ? "register to receive our newsletter" : void 0
3334
3793
  ]);
3335
3794
  const piiCollected = uniq([...hasForms ? fields.map((f) => fieldPhrase(f.name, f.type)) : ["your full name", "contact information, such as your address, telephone number, or e-mail address"], "any other personal information that you choose to submit to us"]);
3795
+ const hasSessionReplay = !!site.analytics?.clarityId;
3336
3796
  const techInfo = uniq([
3337
3797
  "your domain name",
3338
3798
  "your numerical IP address",
3339
3799
  hasAnalytics ? "the pages you view and the files you request" : void 0,
3340
3800
  hasAnalytics ? "the type of browser and operating system you use" : void 0,
3341
- hasAnalytics ? "the address of the site that referred you" : void 0
3801
+ hasAnalytics ? "the address of the site that referred you" : void 0,
3802
+ hasSessionReplay ? "a recording of how you interacted with a page, including mouse movement, scrolling and clicks" : void 0
3342
3803
  ]);
3343
3804
  const techInfoPurposes = uniq([
3344
3805
  hasAnalytics ? "measure the performance of our Site" : void 0,
3806
+ hasSessionReplay ? "see where visitors encounter difficulty using our Site" : void 0,
3345
3807
  site.security?.turnstile?.siteKey ? "protect our Site and its forms from automated abuse" : void 0,
3346
3808
  "better understand how visitors use our Site",
3347
3809
  "improve our Site to better meet your needs"
@@ -3351,11 +3813,13 @@ function derivePolicyVars(site) {
3351
3813
  "your IP address",
3352
3814
  "the type of web browser and operating system used",
3353
3815
  "the pages of the Site visited",
3354
- ab?.enabled ? "which version of a page you were shown" : void 0
3816
+ ab?.enabled ? "which version of a page you were shown" : void 0,
3817
+ hasSessionReplay ? "how you moved through and interacted with a page" : void 0
3355
3818
  ]);
3356
3819
  const cookiePurposes = uniq([
3357
3820
  "keep the Site functioning as you move between pages",
3358
3821
  hasAnalytics ? "understand how our Site is used, in aggregate" : void 0,
3822
+ hasSessionReplay ? "recognise a returning visit as part of the same session" : void 0,
3359
3823
  ab?.enabled ? "keep you on a consistent version of the Site between visits" : void 0,
3360
3824
  site.security?.turnstile?.siteKey ? "distinguish visitors from automated traffic" : void 0
3361
3825
  ]);
@@ -3980,6 +4444,118 @@ function generateLegal(site, opts = {}) {
3980
4444
  return out;
3981
4445
  }
3982
4446
 
4447
+ //#endregion
4448
+ //#region src/icons-sprite.ts
4449
+ /**
4450
+ * Build an SVG sprite from an icon `include` map.
4451
+ *
4452
+ * This is the delivery path for consumers that cannot run an icon integration:
4453
+ * Bricks/WordPress, EmDash's renderers, anything rendering HTML outside an Astro
4454
+ * build. Markup is `<svg><use href="…/icons.svg#ph--caret-down"/></svg>` — no
4455
+ * runtime JS and no network call, which is what keeps it inside the tier rules
4456
+ * (a runtime-fetching element would be behaviour JS with no fallback to enhance).
4457
+ *
4458
+ * The icon DATA comes from the `@iconify-json/*` collections, which stay optional
4459
+ * peers loaded dynamically: `@iconify-json/ph` alone is ~4.5 MB and the semantic
4460
+ * map spans five sets, so depending on them directly would be indefensible for
4461
+ * every consumer who never asks for a sprite.
4462
+ */
4463
+ /**
4464
+ * `ph:caret-down` → `ph--caret-down`.
4465
+ *
4466
+ * The one place this grammar lives. `Icon.astro` mirrors it and `icon.test.ts`
4467
+ * guards the pair, because a drift here resolves `<use>` to nothing and renders
4468
+ * as an empty box rather than an error.
4469
+ */
4470
+ function spriteId(qualified) {
4471
+ return qualified.replace(":", "--");
4472
+ }
4473
+ /**
4474
+ * Default loader: the collection JSON shipped by `@iconify-json/<prefix>`.
4475
+ *
4476
+ * Resolved through a `createRequire` rooted at the consumer's directory, then
4477
+ * imported by absolute path — a bare specifier would resolve against this
4478
+ * package instead, where the collections deliberately aren't installed.
4479
+ */
4480
+ async function loadCollection(prefix, from) {
4481
+ try {
4482
+ const mod = await import(pathToFileURL(createRequire(pathToFileURL(join(from, "noop.js"))).resolve(`@iconify-json/${prefix}/icons.json`)).href, { with: { type: "json" } });
4483
+ return mod.default ?? mod;
4484
+ } catch {
4485
+ return null;
4486
+ }
4487
+ }
4488
+ async function buildIconSprite(o) {
4489
+ const from = o.resolveFrom ?? process.cwd();
4490
+ const load = o.loadSet ?? ((prefix) => loadCollection(prefix, from));
4491
+ const symbols = [];
4492
+ const ids = [];
4493
+ const missing = [];
4494
+ /** qualified name → its rendered body+viewBox, so an alias can reuse it. */
4495
+ const rendered = /* @__PURE__ */ new Map();
4496
+ for (const [prefix, names] of Object.entries(o.include)) {
4497
+ if (!names?.length) continue;
4498
+ const set = await load(prefix);
4499
+ if (!set) throw new Error(`[vitops icons] Cannot build the sprite: the "${prefix}" icon set is not installed. Run \`pnpm add -D @iconify-json/${prefix}\`, or drop it from your icons config.`);
4500
+ for (const name of names) {
4501
+ const data = getIconData(set, name);
4502
+ if (!data) {
4503
+ missing.push(`${prefix}:${name}`);
4504
+ continue;
4505
+ }
4506
+ const built = iconToSVG(data, { height: "none" });
4507
+ const body = replaceIDs(built.body);
4508
+ const id = spriteId(`${prefix}:${name}`);
4509
+ rendered.set(`${prefix}:${name}`, {
4510
+ viewBox: built.attributes.viewBox,
4511
+ body
4512
+ });
4513
+ ids.push(id);
4514
+ symbols.push(`<symbol id="${id}" viewBox="${built.attributes.viewBox}">${body}</symbol>`);
4515
+ }
4516
+ }
4517
+ for (const [alias, target] of Object.entries(o.aliases ?? {})) {
4518
+ const hit = rendered.get(target);
4519
+ if (!hit) {
4520
+ missing.push(`${target} (aliased as ${alias})`);
4521
+ continue;
4522
+ }
4523
+ ids.push(alias);
4524
+ symbols.push(`<symbol id="${alias}" viewBox="${hit.viewBox}">${hit.body}</symbol>`);
4525
+ }
4526
+ return {
4527
+ svg: `<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">` + symbols.join("") + `</svg>\n`,
4528
+ ids,
4529
+ missing
4530
+ };
4531
+ }
4532
+ /**
4533
+ * One icon's renderable parts, for inlining into markup.
4534
+ *
4535
+ * Exists because `<Icon />` cannot go through astro-icon: its `./components`
4536
+ * entry is a TypeScript file that Vite must transform, so it can only be
4537
+ * reached by a statically-analysable import — and a static import is exactly
4538
+ * what made the optional peer mandatory for anyone rendering Popover, Details
4539
+ * or Drawer. Reading the collection directly sidesteps the peer entirely and
4540
+ * shares this file's loader with the sprite, so both paths draw the same glyph.
4541
+ */
4542
+ async function loadIconSvg(qualified, opts) {
4543
+ const i = qualified.indexOf(":");
4544
+ if (i <= 0) return null;
4545
+ const prefix = qualified.slice(0, i);
4546
+ const name = qualified.slice(i + 1);
4547
+ const from = opts?.resolveFrom ?? process.cwd();
4548
+ const set = await (opts?.loadSet ?? ((p) => loadCollection(p, from)))(prefix);
4549
+ if (!set) return null;
4550
+ const data = getIconData(set, name);
4551
+ if (!data) return null;
4552
+ const built = iconToSVG(data, { height: "none" });
4553
+ return {
4554
+ viewBox: built.attributes.viewBox,
4555
+ body: replaceIDs(built.body)
4556
+ };
4557
+ }
4558
+
3983
4559
  //#endregion
3984
4560
  //#region src/generate.ts
3985
4561
  /**
@@ -4026,6 +4602,47 @@ const targetOf = (key) => {
4026
4602
  return i === -1 ? key : key.slice(0, i);
4027
4603
  };
4028
4604
  /**
4605
+ * The framework's container breakpoints. `sm-`/`md-`/`lg-`/`xl-` prefixes in the
4606
+ * css/bricks formats; `@sm:`/`@md:`/… in tailwind, where they are registered as
4607
+ * `--container-*` in `@theme` and Tailwind expands the variants itself.
4608
+ */
4609
+ const BREAKPOINTS = [
4610
+ ["sm", "30rem"],
4611
+ ["md", "48rem"],
4612
+ ["lg", "64rem"],
4613
+ ["xl", "80rem"]
4614
+ ];
4615
+ /**
4616
+ * Gap utilities over the space scale, emitted from one list into all three
4617
+ * formats — same arrangement as `roleColorUtilities`, for the same reason.
4618
+ *
4619
+ * These did not exist. `vitops docs css` advertised a `g` class that was never
4620
+ * emitted, so the honest answer for a css/bricks consumer was an inline
4621
+ * `style="gap: …"`, which is what this repo's own `index.html` does throughout.
4622
+ * Tailwind does not fill the hole either: the fluid space steps are deliberately
4623
+ * kept OUT of Tailwind's `--spacing-*` namespace (named keys there shadow the
4624
+ * size scales — `max-w-7xl` would resolve to `var(--spacing-7xl)`), so `gap-md`
4625
+ * is not something Tailwind can derive on its own. Measured against
4626
+ * tailwindcss@4.3.3: an explicit `@utility gap-md` is honoured, coexists with
4627
+ * the built-in numeric `gap-4`, and picks up variants (`@md:gap-md`).
4628
+ *
4629
+ * The whole matrix is emitted rather than a useful-looking subset: an undefined
4630
+ * step produces no rule and no error in either format, so a missing
4631
+ * `md-gap-x-2xl` would be indistinguishable from a working one.
4632
+ */
4633
+ function gapUtilities(steps) {
4634
+ const FAMILIES = [
4635
+ ["gap", "gap"],
4636
+ ["gap-x", "column-gap"],
4637
+ ["gap-y", "row-gap"]
4638
+ ];
4639
+ return steps.flatMap(({ name }) => FAMILIES.map(([prefix, prop]) => ({
4640
+ cls: `${prefix}-${name}`,
4641
+ prop,
4642
+ value: `var(--space-${name})`
4643
+ })));
4644
+ }
4645
+ /**
4029
4646
  * Every role colour utility, each class name emitted EXACTLY once.
4030
4647
  *
4031
4648
  * There is now a single axis. The target lives *inside* the token name, so
@@ -4104,6 +4721,23 @@ const TYPO_KEYMAP = {
4104
4721
  "text-wrap": ["text-wrap", "tw"],
4105
4722
  color: ["color", "color"]
4106
4723
  };
4724
+ /**
4725
+ * Wrong spellings worth naming explicitly, because each is a plausible reading of
4726
+ * the key it isn't. `transform`/`decoration` were documented in the schema for a
4727
+ * while and shipped title-case navigation to production; `letter-spacing` and
4728
+ * `font-*` are the natural guesses for keys we chose to shorten.
4729
+ */
4730
+ const ALSO_MEANT = {
4731
+ transform: "text-transform",
4732
+ decoration: "text-decoration",
4733
+ "letter-spacing": "tracking",
4734
+ wrap: "text-wrap",
4735
+ "font-family": "family",
4736
+ "font-size": "size",
4737
+ "font-weight": "weight",
4738
+ "font-style": "style",
4739
+ lineHeight: "line-height"
4740
+ };
4107
4741
  const TYPO_IDENTITY = {
4108
4742
  "font-style": "normal",
4109
4743
  "letter-spacing": "normal",
@@ -4126,10 +4760,11 @@ const STATE_DEFAULT = {
4126
4760
  saturate: "1",
4127
4761
  sepia: "0",
4128
4762
  "hue-rotate": "0deg",
4129
- clip: "none"
4763
+ clip: "none",
4764
+ height: "auto"
4130
4765
  };
4131
4766
  const id = (key) => createHash("sha256").update(key).digest("hex").slice(0, 6);
4132
- function build(ds, format, assetsDir) {
4767
+ function build(ds, format, assetsDir, buildOpts = {}) {
4133
4768
  const BRICKS = format === "bricks";
4134
4769
  const expandedPalette = expandPalette(ds.colors.palette);
4135
4770
  const roleMap = ds.colors.roles;
@@ -4143,6 +4778,10 @@ function build(ds, format, assetsDir) {
4143
4778
  const surfaceRole = scaleRoles.find((r) => r.role === "surface");
4144
4779
  const focusRole = scaleRoles.find((r) => r.role === "ui-primary") ?? scaleRoles[0];
4145
4780
  for (const w of ladderWarnings(expandedPalette)) console.warn(`[vitops] ${w}`);
4781
+ {
4782
+ const errors = monotonicityErrors(expandedPalette);
4783
+ if (errors.length) throw new Error(errors.join("\n"));
4784
+ }
4146
4785
  {
4147
4786
  const numericOf = (hueName) => expandedPalette[hueName]?.numeric ?? {};
4148
4787
  const resolve = (val) => {
@@ -4206,6 +4845,9 @@ function build(ds, format, assetsDir) {
4206
4845
  if (fb) {
4207
4846
  css += `/* Functional role tokens (dark) */\n`;
4208
4847
  css += `${DARK_SEL} {\n color-scheme: dark;\n${fb}}\n\n`;
4848
+ if (buildOpts.systemColorScheme) css += `/* Functional role tokens (dark) — OS preference, no explicit choice */
4849
+ @media (prefers-color-scheme: dark) {
4850
+ ${SYSTEM_DARK_SEL} {\n color-scheme: dark;\n${fb.replace(/^ {2}/gm, " ")} }\n}\n\n`;
4209
4851
  }
4210
4852
  }
4211
4853
  css += `/* Colour utilities — ${UTILITIES.join(", ")} */\n`;
@@ -4231,6 +4873,14 @@ function build(ds, format, assetsDir) {
4231
4873
  const spaceSteps = ds.spaceScale ? buildScale(ds.spaceScale) : [];
4232
4874
  const textMax = Object.fromEntries(typeSteps.map((s) => [`text-${s.name}`, s.max]));
4233
4875
  const typographyFamilies = typography?.families ?? {};
4876
+ {
4877
+ const known = new Set(Object.keys(TYPO_KEYMAP));
4878
+ for (const [role, spec] of Object.entries(typography?.roles ?? {})) for (const key of Object.keys(spec)) {
4879
+ if (known.has(key)) continue;
4880
+ const near = ALSO_MEANT[key];
4881
+ console.warn(`[vitops] typography.roles.${role}: unknown key "${key}" — ignored` + (near ? `. Did you mean "${near}"?` : `. Recognised: ${[...known].join(", ")}.`));
4882
+ }
4883
+ }
4234
4884
  const withScaleFallback = (v) => v.replace(/var\(\s*(--text-[\w-]+)\s*\)/g, (m, name) => textMax[name.slice(2)] ? `var(${name}, ${textMax[name.slice(2)]})` : m);
4235
4885
  const roleDecls = (role, spec) => {
4236
4886
  const out = {};
@@ -4248,10 +4898,26 @@ function build(ds, format, assetsDir) {
4248
4898
  for (const [name, stack] of Object.entries(ds.fonts ?? {})) root[`--font-${name}`] = stack;
4249
4899
  for (const s of typeSteps) root[`--text-${s.name}`] = s.value;
4250
4900
  for (const s of spaceSteps) root[`--space-${s.name}`] = s.value;
4251
- const css = `/* GENERATED font families + fluid type/space scales — do not edit by hand. */\n:root {\n${decls(root)}\n}\n`;
4901
+ const css = `/* GENERATED font families + fluid type/space scales — do not edit by hand.
4902
+ --font-* are STACKS ONLY; no @font-face is emitted here. Load webfonts with
4903
+ Astro's fonts: config and point the token at the family's cssVariable. */
4904
+ :root {\n${decls(root)}\n}\n`;
4252
4905
  twTypeTokensCss = css;
4253
4906
  generated["type-tokens.css"] = css;
4254
4907
  }
4908
+ {
4909
+ const gaps = gapUtilities(spaceSteps);
4910
+ let sp = `/* GENERATED gap utilities — do not edit by hand. */\n`;
4911
+ if (gaps.length) {
4912
+ for (const { cls, prop, value } of gaps) sp += `.${cls} { ${prop}: ${value}; }\n`;
4913
+ for (const [bp, width] of BREAKPOINTS) {
4914
+ sp += `\n@container (min-width: ${width}) {\n`;
4915
+ for (const { cls, prop, value } of gaps) sp += ` .${bp}-${cls} { ${prop}: ${value}; }\n`;
4916
+ sp += `}\n`;
4917
+ }
4918
+ }
4919
+ generated["spacing.css"] = sp;
4920
+ }
4255
4921
  let twPatternTokensCss = "";
4256
4922
  {
4257
4923
  const tDefaults = patterns?.defaults ?? {};
@@ -4286,7 +4952,6 @@ function build(ds, format, assetsDir) {
4286
4952
  generated["typography.css"] = typo;
4287
4953
  }
4288
4954
  const stateRules = (sel, role, states, colorProp = "background-color") => {
4289
- const sels = Array.isArray(sel) ? sel : [sel];
4290
4955
  let out = "";
4291
4956
  for (const [state, spec] of Object.entries(states)) {
4292
4957
  const body = [];
@@ -4304,8 +4969,7 @@ function build(ds, format, assetsDir) {
4304
4969
  }
4305
4970
  if (spec.css && typeof spec.css === "object") for (const [k, v] of Object.entries(spec.css)) body.push(`${k}: ${v};`);
4306
4971
  if (!body.length) continue;
4307
- const pseudo = state === "hover" ? ":hover" : state === "active" ? ":active" : state === "focus-visible" ? ":focus-visible" : `:${state}`;
4308
- const rule = `${sels.map((s) => `${s}${pseudo}`).join(",\n")} {\n ${body.join("\n ")}\n}\n`;
4972
+ const rule = `${sel(state === "hover" ? ":hover" : state === "active" ? ":active" : state === "focus-visible" ? ":focus-visible" : `:${state}`).join(",\n")} {\n ${body.join("\n ")}\n}\n`;
4309
4973
  out += state === "hover" ? `@media (hover: hover) {\n${rule}}\n` : rule;
4310
4974
  }
4311
4975
  return out;
@@ -4316,8 +4980,9 @@ function build(ds, format, assetsDir) {
4316
4980
  if (!p.base) continue;
4317
4981
  const states = p.states ?? {};
4318
4982
  const cls = p.class ?? (p.element ? void 0 : pname);
4319
- const elementSel = (extra = "") => `:where(${[p.element, cls && `.${cls}`].filter(Boolean).join(", ")})${extra}`;
4983
+ const elementSel = (extra = "", pseudo = "") => `:where(${[p.element, cls && `.${cls}`].filter(Boolean).map((s) => `${s}${pseudo}`).join(", ")})${extra}`;
4320
4984
  const defaultSel = p.element ? elementSel() : `.${cls}`;
4985
+ const defaultSelFor = (pseudo) => p.element ? [elementSel("", pseudo)] : [`.${cls}${pseudo}`];
4321
4986
  const defaultRole = p.default_role ?? null;
4322
4987
  const base = { ...p.base };
4323
4988
  const fills = p.fill ?? (pname === "button" || pname === "badge" || base["background-color"] != null || base["background"] != null);
@@ -4336,12 +5001,13 @@ function build(ds, format, assetsDir) {
4336
5001
  wrappedBase[prop] = sfx ? `var(--${sfx}-${pname}, ${val})` : String(val);
4337
5002
  }
4338
5003
  pat += decls(wrappedBase) + "\n}\n";
4339
- pat += stateRules(defaultSel, defaultRole, states, colorProp);
5004
+ pat += stateRules(defaultSelFor, defaultRole, states, colorProp);
4340
5005
  for (const role of p.roles ?? []) {
4341
- const variantSels = p.element ? [elementSel(`.${role}`), ...cls ? [`.${cls}-${role}`] : []] : [`.${cls}-${role}`];
5006
+ const variantSelsFor = (pseudo) => p.element ? [elementSel(`.${role}`, pseudo), ...cls ? [`.${cls}-${role}${pseudo}`] : []] : [`.${cls}-${role}${pseudo}`];
5007
+ const variantSels = variantSelsFor("");
4342
5008
  const variantColorDecl = fills ? `background-color: var(--color-bg-${role}-solid); color: var(--color-text-on-${role})` : `color: var(--color-text-${role})`;
4343
5009
  pat += `${variantSels.join(",\n")} { ${variantColorDecl}; }\n`;
4344
- pat += stateRules(variantSels, role, states, colorProp);
5010
+ pat += stateRules(variantSelsFor, role, states, colorProp);
4345
5011
  }
4346
5012
  pat += "\n";
4347
5013
  }
@@ -4361,25 +5027,25 @@ function build(ds, format, assetsDir) {
4361
5027
  for (const parts of anim.journeys?.compose ?? []) {
4362
5028
  const d = {
4363
5029
  "--_anim": parts.map((p) => `${p}-journey`).join(", "),
4364
- "animation-range": "entry exit"
5030
+ "animation-range": "entry calc(50% + 10vh) exit 100%"
4365
5031
  };
4366
5032
  for (const p of parts) for (const [k, v] of Object.entries(base[p] ?? {})) d[`--${k}`] = asStr(v);
4367
5033
  out += block(`${parts.join("-")}-journey`, d);
4368
5034
  }
4369
5035
  const STATES = [
4370
- ["hover", (s) => `.${s}:hover`],
4371
- ["focus", (s) => `.${s}:focus-visible`],
5036
+ ["hover", (s) => `.${s}:hover, :hover > .${s}`],
5037
+ ["focus", (s) => `.${s}:focus-visible, :focus-within > .${s}`],
4372
5038
  ["active", (s) => `.${s}.is-active, .${s}[data-active]`]
4373
5039
  ];
4374
5040
  out += `\n/* State-flip variants — compose with .transition (hover-/focus-/active-<fx>). */\n`;
4375
5041
  for (const [name, e] of Object.entries(anim.effects ?? {})) {
4376
- if (e.kf !== "composite" && e.kf !== "paint") continue;
4377
5042
  const props = {};
4378
5043
  for (const [k, v] of Object.entries(e.vars ?? {})) {
4379
5044
  const m = /^(.*)-(from|to)$/.exec(k);
4380
5045
  if (m) (props[m[1]] ??= {})[m[2]] = asStr(v);
4381
5046
  }
4382
5047
  const rest = {};
5048
+ for (const [k, v] of Object.entries(e.css ?? {})) rest[k] = asStr(v);
4383
5049
  for (const [p, ft] of Object.entries(props)) {
4384
5050
  if (ft.from != null && ft.from !== STATE_DEFAULT[p]) rest[`--${p}-from`] = ft.from;
4385
5051
  if (ft.to != null && ft.to !== STATE_DEFAULT[p]) rest[`--${p}-to`] = ft.to;
@@ -4602,7 +5268,8 @@ function build(ds, format, assetsDir) {
4602
5268
  twPatternTokensCss,
4603
5269
  patternsCss: pat,
4604
5270
  assetsDir,
4605
- utilities: UTILITIES
5271
+ utilities: UTILITIES,
5272
+ systemColorScheme: buildOpts.systemColorScheme === true
4606
5273
  }),
4607
5274
  designMd: format !== "design" ? "" : emitDesignMd({
4608
5275
  ds,
@@ -4621,24 +5288,19 @@ function emitTailwind(ctx) {
4621
5288
  const { ds, expandedPalette, scaleRoles, shadows, typeSteps, spaceSteps, typography, roleDecls, twTypeTokensCss, twPatternTokensCss, patternsCss, assetsDir, utilities } = ctx;
4622
5289
  const cssDir = join(assetsDir, "css");
4623
5290
  const nl = (arr) => arr.filter(Boolean).join("\n");
4624
- const parts = ["/* GENERATED for Tailwind v4 (Astro) — do not edit by hand.\n Class reference: npx vitops docs classes · All topics: npx vitops docs */"];
5291
+ const parts = ["/* GENERATED for Tailwind v4 (Astro) — do not edit by hand.\n Class reference: npx vitops docs classes · All topics: npx vitops docs\n Fonts below are STACKS ONLY — this file loads no webfonts. Load them with\n Astro's fonts: config and point the token at the family's cssVariable. */"];
4625
5292
  parts.push(`@import "tailwindcss";\n`);
4626
5293
  const theme = [];
4627
5294
  theme.push(` /* Hue scales → bg-<hue>-<step> text-* border-* (numeric, 50…950) */`);
4628
5295
  for (const [name, hue] of Object.entries(expandedPalette)) for (const [n, hex] of Object.entries(hue.numeric)) theme.push(` --color-${name}-${n}: ${hex};`);
4629
- theme.push(`\n /* Fonts → font-* */`);
5296
+ theme.push(`\n /* Fonts → font-* (stacks only; no @font-face is emitted — see header) */`);
4630
5297
  for (const [name, stack] of Object.entries(ds.fonts ?? {})) theme.push(` --font-${name}: ${stack};`);
4631
5298
  theme.push(`\n /* Fluid type scale → text-* */`);
4632
5299
  for (const s of typeSteps) theme.push(` --text-${s.name}: ${s.value};`);
4633
5300
  theme.push(`\n /* Drop-shadows → shadow-* */`);
4634
5301
  for (const [name, value] of Object.entries(shadows ?? {})) theme.push(` --shadow-${name}: ${value};`);
4635
5302
  theme.push(`\n /* Container breakpoints → @sm:/@md:/@lg:/@xl: */`);
4636
- for (const [name, val] of [
4637
- ["sm", "30rem"],
4638
- ["md", "48rem"],
4639
- ["lg", "64rem"],
4640
- ["xl", "80rem"]
4641
- ]) theme.push(` --container-${name}: ${val};`);
5303
+ for (const [name, val] of BREAKPOINTS) theme.push(` --container-${name}: ${val};`);
4642
5304
  parts.push(`@theme {\n${theme.join("\n")}\n}\n`);
4643
5305
  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`);
4644
5306
  if (scaleRoles.length) {
@@ -4660,8 +5322,16 @@ function emitTailwind(ctx) {
4660
5322
  const surface = scaleRoles.find((r) => r.role === "surface");
4661
5323
  if (surface) darkBlock += ` --overlay: color-mix(in oklch, var(--color-${surface.hue}-950) 60%, transparent);\n`;
4662
5324
  }
4663
- if (darkBlock) parts.push(`/* Functional role tokens (dark) */\n${DARK_SEL} {\n color-scheme: dark;\n${darkBlock}}\n`);
5325
+ if (darkBlock) {
5326
+ parts.push(`/* Functional role tokens (dark) */\n${DARK_SEL} {\n color-scheme: dark;\n${darkBlock}}\n`);
5327
+ if (ctx.systemColorScheme) parts.push(`/* Functional role tokens (dark) — OS preference, no explicit choice */\n@media (prefers-color-scheme: dark) {\n ${SYSTEM_DARK_SEL} {\n color-scheme: dark;\n${darkBlock.replace(/^ {2}/gm, " ")} }\n}\n`);
5328
+ }
4664
5329
  parts.push("/* State variant for the active flip (hover/focus-visible are built in). */\n@custom-variant is-active (&.is-active, &[data-active]);\n");
5330
+ {
5331
+ const roles = typography?.roles ?? {};
5332
+ const bindings = Object.entries(typography?.headings ?? {}).filter(([, r]) => roles[r]);
5333
+ if (bindings.length) parts.push(`/* Bare-element type roles (typography.headings) */\n@layer base {\n` + bindings.map(([tag, role]) => ` ${tag} {\n${decls(roleDecls(role, roles[role]), " ")}\n }`).join("\n") + `\n}\n`);
5334
+ }
4665
5335
  const util = [];
4666
5336
  if (scaleRoles.length) {
4667
5337
  util.push(`/* Role colour utilities (the public API) */`);
@@ -4677,7 +5347,6 @@ function emitTailwind(ctx) {
4677
5347
  for (const [k, v] of Object.entries(e.css ?? {})) d[k] = asStr(v);
4678
5348
  for (const [k, v] of Object.entries(e.vars ?? {})) d[`--${k}`] = asStr(v);
4679
5349
  util.push(`@utility ${name} {\n${decls(d)}\n}`);
4680
- if (e.kf !== "composite" && e.kf !== "paint") continue;
4681
5350
  const props = /* @__PURE__ */ new Set();
4682
5351
  for (const k of Object.keys(e.vars ?? {})) {
4683
5352
  const m = /^(.*)-(from|to)$/.exec(k);
@@ -4687,7 +5356,7 @@ function emitTailwind(ctx) {
4687
5356
  util.push(`@utility flip-${name} {\n${decls(flip)}\n}`);
4688
5357
  }
4689
5358
  util.push(`\n/* Split — internal fractions (pair split with a ratio) */`);
4690
- util.push("@utility split {\n display: flex;\n & > * { flex: 1; }\n & > :first-child { flex: var(--_split-a, 1); }\n & > :last-child { flex: var(--_split-b, 1); }\n}");
5359
+ util.push(`@utility split-reverse {\n reading-flow: flex-visual;\n & > :first-child { order: 1; }\n}`);
4691
5360
  for (const [a, b] of [
4692
5361
  [1, 2],
4693
5362
  [2, 1],
@@ -4697,7 +5366,12 @@ function emitTailwind(ctx) {
4697
5366
  [4, 1],
4698
5367
  [2, 3],
4699
5368
  [3, 2]
4700
- ]) util.push(`@utility split-${a}-${b} {\n --_split-a: ${a};\n --_split-b: ${b};\n}`);
5369
+ ]) util.push(`@utility split-${a}-${b} {\n flex-direction: row;\n --_split-a: ${a};\n --_split-b: ${b};\n}`);
5370
+ const gaps = gapUtilities(spaceSteps);
5371
+ if (gaps.length) {
5372
+ util.push(`\n/* Gap — the fluid space scale (var(--space-<name>)) */`);
5373
+ for (const { cls, prop, value } of gaps) util.push(`@utility ${cls} {\n ${prop}: ${value};\n}`);
5374
+ }
4701
5375
  util.push(`\n/* Track placement (inside .centered) */`);
4702
5376
  for (const track of [
4703
5377
  "measure",
@@ -4705,6 +5379,28 @@ function emitTailwind(ctx) {
4705
5379
  "spotlight",
4706
5380
  "fullbleed"
4707
5381
  ]) util.push(`@utility ${track} {\n grid-column: ${track};\n}`);
5382
+ util.push(`\n/* Auto-fit grid — column count is content-driven, unlike .split */`);
5383
+ util.push("@utility grid-auto {\n display: grid;\n gap: var(--grid-gap, 1rem);\n grid-template-columns: repeat(auto-fit, minmax(var(--grid-min, 13rem), 1fr));\n &:is(ul, ol) > li + li { margin-block-start: 0; }\n}");
5384
+ util.push(`\n/* Vertical-rhythm overrides (mirrors layout.css) */`);
5385
+ for (const pair of [
5386
+ "h-p",
5387
+ "p-p",
5388
+ "p-h",
5389
+ "h-h",
5390
+ "p-list",
5391
+ "list-p",
5392
+ "li-li",
5393
+ "text-media",
5394
+ "media-text"
5395
+ ]) util.push(`@utility m-${pair} {\n margin-top: calc(var(--rhythm-base) * var(--rhythm-${pair}));\n}`);
5396
+ for (const [name, mult] of [
5397
+ ["0", null],
5398
+ ["xs", "0.25"],
5399
+ ["s", "0.5"],
5400
+ ["m", "1"],
5401
+ ["l", "1.5"],
5402
+ ["xl", "2"]
5403
+ ]) util.push(`@utility m-${name} {\n margin-top: ${mult == null ? "0" : `calc(var(--rhythm-base) * ${mult})`};\n}`);
4708
5404
  parts.push(util.join("\n") + "\n");
4709
5405
  parts.push(`/* ── Structure (mirrors src/css/layout.css) ── */
4710
5406
  :root {
@@ -4727,6 +5423,11 @@ function emitTailwind(ctx) {
4727
5423
 
4728
5424
  body {
4729
5425
  container-type: inline-size;
5426
+ /* Named so \`patterns/scroll-target.css\`'s \`@container body (…)\` queries
5427
+ resolve — .toc-layout/.toc-sidebar/.toc-inline. Those partials are inlined
5428
+ into this bundle verbatim, so dropping the name (as this literal did) left
5429
+ the TOC permanently in its narrow layout in the tailwind format only. */
5430
+ container-name: body;
4730
5431
  }
4731
5432
 
4732
5433
  /* Class-level structure rules live in \`components\` so track/spacing utilities
@@ -4791,6 +5492,39 @@ body {
4791
5492
  var(--region-space-max, 8rem)
4792
5493
  );
4793
5494
  }
5495
+
5496
+ /* \`.split\` is a PATTERN, so it belongs here rather than in an \`@utility\` — that
5497
+ is what makes \`flex-col\` beat it by layer instead of by a source-order race
5498
+ whose winner depends on Tailwind's property-based sort of custom utilities.
5499
+ Its ratio/reversal modifiers stay utilities. Mirrors layout.css, where each
5500
+ rule's rationale is written out; in short: \`min-inline-size: 0\` because a flex
5501
+ item's automatic minimum is its min-content size; the ratio as a flex BASIS
5502
+ rather than a grow factor, because grow shares out only the free space and a
5503
+ child's padding is not part of it; scoped to a two-child split because a ratio
5504
+ is a pair contract and a middle child with no basis would collapse to zero;
5505
+ \`box-sizing: border-box\` because a basis only sizes the border box on a
5506
+ border-box item (Tailwind's preflight sets it, the other formats' reset does). */
5507
+ .split {
5508
+ display: flex;
5509
+ flex-direction: row;
5510
+ }
5511
+ .split > * {
5512
+ flex: 1;
5513
+ box-sizing: border-box;
5514
+ min-inline-size: 0;
5515
+ }
5516
+ .split > :first-child {
5517
+ flex-grow: var(--_split-a, 1);
5518
+ }
5519
+ .split > :last-child {
5520
+ flex-grow: var(--_split-b, 1);
5521
+ }
5522
+ .split > :first-child:nth-last-child(2) {
5523
+ flex-basis: ${splitBasis("--_split-a")};
5524
+ }
5525
+ .split > :nth-child(2):last-child {
5526
+ flex-basis: ${splitBasis("--_split-b")};
5527
+ }
4794
5528
  }
4795
5529
  `);
4796
5530
  const animPath = join(cssDir, "animation.css");
@@ -4854,9 +5588,9 @@ body {
4854
5588
  if (depth === 0) {
4855
5589
  const raw = css.slice(start, i + 1).trim();
4856
5590
  const prelude = css.slice(start, preludeEnd).replace(/\/\*[\s\S]*?\*\//g, "").trim();
4857
- const clash = /^\.([A-Za-z0-9_-]+)/.exec(prelude);
5591
+ const name = /^\.([A-Za-z0-9_-]+)/.exec(prelude)?.[1];
4858
5592
  if (/^@container\s*\(\s*min-width:/.test(prelude) && isVariantBlock(raw)) droppedVariantBlocks++;
4859
- else if (clash && TW_CLASH.has(clash[1])) droppedClashes.add(clash[1]);
5593
+ else if (name && TW_CLASH.has(name) && !CLASH_KEEP.has(name)) droppedClashes.add(name);
4860
5594
  else if (raw) kept.push(raw);
4861
5595
  start = i + 1;
4862
5596
  }
@@ -4871,25 +5605,60 @@ body {
4871
5605
  return nl(parts) + "\n";
4872
5606
  }
4873
5607
  /**
5608
+ * Partials the tailwind format does NOT inline, because it re-creates them by
5609
+ * hand: the engine (`global.css` — Tailwind's preflight covers the reset;
5610
+ * `animation.css` — read directly and wrapped in `@layer components`), and the
5611
+ * two layout partials, whose utilities are re-emitted as `@utility` and whose
5612
+ * structure is re-emitted in the `@layer components` literal.
5613
+ *
5614
+ * **Every partial mapped to `vitops.utilities` must be listed here.** Inlining a
5615
+ * utility family lands it in tailwind's `@layer components`, the exact inversion
5616
+ * the layer split exists to remove — and it fails SILENTLY, because each of those
5617
+ * classes already exists as an `@utility` that wins on layer anyway. You would get
5618
+ * ~30 dead rules and no test failure. `format-parity.test.ts` asserts the
5619
+ * implication structurally rather than spot-checking the output.
5620
+ *
5621
+ * The implication is one-way: `layout.css` is skipped while sitting in
5622
+ * `vitops.components`, because its structure is re-emitted by hand.
5623
+ */
5624
+ /**
5625
+ * Framework COMPONENTS whose class name collides with a Tailwind utility, and
5626
+ * which must therefore survive the `TW_CLASH` strip.
5627
+ *
5628
+ * The clash test matches a rule's leading `.<name>`, which is right for a
5629
+ * single-purpose utility (Tailwind ships its own) and wrong for a pattern that
5630
+ * merely shares the name. `patterns/sticky.css` opens `.sticky { … }` and was
5631
+ * being deleted from the tailwind bundle WHOLESALE — the `--_sticky-offset`
5632
+ * wiring, the z-index, and every `&.sticky--bottom`/`--inline-start` variant —
5633
+ * so a consumer writing `class="sticky sticky--bottom"` got bare
5634
+ * `position: sticky` and nothing else.
5635
+ *
5636
+ * `table` is here for the same reason but was never actually hit: `table.css`'s
5637
+ * prelude is `table,\n.table`, which doesn't start with `.`, so the regex missed
5638
+ * it by luck. Listing it makes that explicit rather than accidental.
5639
+ */
5640
+ const CLASH_KEEP = new Set(["sticky", "table"]);
5641
+ const TAILWIND_SKIP = new Set([
5642
+ "global.css",
5643
+ "animation.css",
5644
+ "layout.css",
5645
+ "layout-utilities.css",
5646
+ "utilities.css"
5647
+ ]);
5648
+ /**
4874
5649
  * The component/structural CSS partials to inline into the Tailwind bundle, in
4875
- * index.css cascade order. Everything the aggregator imports EXCEPT the engine
4876
- * (global/animation), the layout utilities (Tailwind owns them), and the generated
4877
- * token layer (Tailwind builds its own via @theme).
5650
+ * index.css cascade order. Everything the aggregator imports EXCEPT `TAILWIND_SKIP`
5651
+ * and the generated token layer (Tailwind builds its own via @theme).
4878
5652
  */
4879
5653
  function componentPartialOrder(cssDir) {
4880
5654
  const indexPath = join(cssDir, "index.css");
4881
- const skip = new Set([
4882
- "global.css",
4883
- "animation.css",
4884
- "layout.css"
4885
- ]);
4886
5655
  const order = [];
4887
5656
  const seen = /* @__PURE__ */ new Set();
4888
5657
  const text = existsSync(indexPath) ? readFileSync(indexPath, "utf8") : "";
4889
5658
  for (const m of text.matchAll(/@import\s+['"]\.\/([^'"]+)['"]/g)) {
4890
5659
  const rel = m[1];
4891
5660
  if (rel.startsWith("generated/")) continue;
4892
- if (skip.has(rel)) continue;
5661
+ if (TAILWIND_SKIP.has(rel)) continue;
4893
5662
  if (!seen.has(rel) && existsSync(join(cssDir, rel))) {
4894
5663
  seen.add(rel);
4895
5664
  order.push(rel);
@@ -4939,9 +5708,21 @@ const CHUNK_LAYER = {
4939
5708
  "generated/shadows.css": "vitops.utilities",
4940
5709
  "generated/typography.css": "vitops.utilities",
4941
5710
  "generated/animation-effects.css": "vitops.utilities",
5711
+ "generated/spacing.css": "vitops.utilities",
5712
+ "layout-utilities.css": "vitops.utilities",
4942
5713
  "utilities.css": "vitops.utilities"
4943
5714
  };
4944
5715
  /**
5716
+ * A `.split` child's flex basis. The ratio is a BASIS rather than a grow factor
5717
+ * because grow shares out only the FREE space, which a child's padding is not
5718
+ * part of — so a padded column came out wider than its sibling by exactly its
5719
+ * horizontal padding. Must stay character-identical to layout.css; the parity
5720
+ * suite matches this expression against both.
5721
+ */
5722
+ const splitBasis = (v) => `calc(var(${v}, 1) / (var(--_split-a, 1) + var(--_split-b, 1)) * 100%)`;
5723
+ /** The layer a partial's rules are wrapped in; unmapped partials are components. */
5724
+ const layerForPartial = (rel) => CHUNK_LAYER[rel] ?? "vitops.components";
5725
+ /**
4945
5726
  * Assemble the full stylesheet by resolving index.css's @import order against the
4946
5727
  * static partials (from assets) and the freshly generated token partials (in
4947
5728
  * memory), then minify with lightningcss. No @import survives, so no disk
@@ -4970,7 +5751,7 @@ function bundleCss(generated, assetsDir, minify) {
4970
5751
  const rel = m[1];
4971
5752
  const body = rel.startsWith("generated/") ? generated[rel.slice(10)] ?? "" : readFileSync(join(cssDir, rel), "utf8");
4972
5753
  if (!body.trim()) continue;
4973
- chunks.push(`@layer ${CHUNK_LAYER[rel] ?? "vitops.components"} {\n${body}\n}`);
5754
+ chunks.push(`@layer ${layerForPartial(rel)} {\n${body}\n}`);
4974
5755
  }
4975
5756
  const merged = `/*! GENERATED by @getvitops/generator — do not edit by hand.
4976
5757
  Class reference: npx vitops docs classes · All topics: npx vitops docs */
@@ -4986,21 +5767,39 @@ function bundleCss(generated, assetsDir, minify) {
4986
5767
  map: res.map ? res.map.toString() : void 0
4987
5768
  };
4988
5769
  }
4989
- function loadConfig(input) {
4990
- const result = validate(typeof input === "string" ? JSON.parse(readFileSync(input, "utf8")) : input);
5770
+ /**
5771
+ * Read `input` as whichever config kind it is, and return the design system to
5772
+ * build from plus the site config if that is what it was.
5773
+ *
5774
+ * The design system is validated here rather than in `resolveInput` so the error
5775
+ * can name where it came from: a theme inside a site config reports its `themes`
5776
+ * path, which is the difference between "your config is wrong" and "your config
5777
+ * is wrong *here*" when the file is a few hundred lines of company facts.
5778
+ */
5779
+ function loadInput(input, opts) {
5780
+ const resolved = resolveInput(typeof input === "string" ? JSON.parse(readFileSync(input, "utf8")) : input, opts);
5781
+ const result = validate(resolved.designSystem);
4991
5782
  if (!result.ok) {
5783
+ const where = resolved.theme != null ? `design system at designSystem.themes.${resolved.theme}` : "design-system.json";
4992
5784
  const lines = result.errors.slice(0, 12).map((e) => ` • ${e.path.join(".") || "(root)"}: ${e.message}`).join("\n");
4993
- throw new Error(`Invalid design-system.json:\n${lines}`);
5785
+ throw new Error(`Invalid ${where}:\n${lines}`);
4994
5786
  }
4995
5787
  for (const w of result.warnings) console.warn(`[vitops] ${w}`);
4996
- return result.data;
5788
+ return {
5789
+ ds: result.data,
5790
+ ...resolved.site ? { site: resolved.site } : {}
5791
+ };
4997
5792
  }
4998
5793
  async function generate(options) {
4999
5794
  const format = options.format ?? "bricks";
5000
5795
  const outDir = options.outDir ?? "dist";
5001
5796
  const assetsDir = options.assetsDir ?? DEFAULT_ASSETS;
5002
- const ds = loadConfig(options.input);
5003
- const built = build(ds, format, assetsDir);
5797
+ const { ds, site: embedded } = loadInput(options.input, {
5798
+ ...options.theme != null ? { theme: options.theme } : {},
5799
+ ...options.siteEnv != null ? { siteEnv: options.siteEnv } : {}
5800
+ });
5801
+ const site = options.site != null ? loadSiteConfigFile(options.site, options.siteEnv) : embedded;
5802
+ const built = build(ds, format, assetsDir, { systemColorScheme: options.systemColorScheme ?? site?.designSystem?.defaultColorScheme === "system" });
5004
5803
  mkdirSync(outDir, { recursive: true });
5005
5804
  const written = [];
5006
5805
  const put = (rel, content) => {
@@ -5039,9 +5838,30 @@ async function generate(options) {
5039
5838
  const docs = generateDocs(ds, assetsDir);
5040
5839
  for (const [rel, content] of Object.entries(docs)) put(join("docs", rel), content);
5041
5840
  }
5042
- if (options.site != null && format !== "design") {
5043
- const legal = generateLegal(loadSiteConfigFile(options.site), { output: "html" });
5841
+ if (site != null && format !== "design") {
5842
+ const legal = generateLegal(site, { output: "html" });
5044
5843
  for (const [name, content] of Object.entries(legal)) put(join("legal", name), content);
5844
+ const icons = site.icons;
5845
+ if (icons?.sprite) {
5846
+ const { ui, brand, weight, semantic, sprite: _sprite, ...sets } = icons;
5847
+ const include = generateIconInclude({
5848
+ ...ui ? { ui } : {},
5849
+ ...brand ? { brand } : {},
5850
+ ...weight ? { weight } : {},
5851
+ ...semantic ? { semantic } : {},
5852
+ ...sets
5853
+ });
5854
+ const aliases = {};
5855
+ for (const name of semantic ?? []) try {
5856
+ aliases[`icon-${name}`] = resolveIcon(name, ui ?? "fa7-solid", weight ? { weight } : {});
5857
+ } catch {}
5858
+ const built = await buildIconSprite({
5859
+ include,
5860
+ aliases
5861
+ });
5862
+ put("icons.svg", built.svg);
5863
+ if (built.missing.length) console.warn(`[vitops] icons.sprite: ${built.missing.length} icon(s) were not found and are absent from the sprite: ${built.missing.join(", ")}.`);
5864
+ }
5045
5865
  }
5046
5866
  return {
5047
5867
  format,
@@ -5049,8 +5869,8 @@ async function generate(options) {
5049
5869
  written
5050
5870
  };
5051
5871
  }
5052
- function loadSiteConfigFile(input) {
5053
- return resolveSiteConfig(typeof input === "string" ? JSON.parse(readFileSync(input, "utf8")) : input);
5872
+ function loadSiteConfigFile(input, siteEnv) {
5873
+ return resolveSiteConfig(typeof input === "string" ? JSON.parse(readFileSync(input, "utf8")) : input, siteEnv);
5054
5874
  }
5055
5875
 
5056
5876
  //#endregion
@@ -5280,19 +6100,22 @@ function defaultConfig() {
5280
6100
  size: "var(--text-3xl)",
5281
6101
  weight: 700,
5282
6102
  "line-height": "1.05",
5283
- tracking: "-0.03em"
6103
+ tracking: "-0.03em",
6104
+ "text-wrap": "balance"
5284
6105
  },
5285
6106
  heading: {
5286
6107
  family: "display",
5287
6108
  size: "var(--text-xl)",
5288
6109
  weight: 600,
5289
- "line-height": "1.3"
6110
+ "line-height": "1.3",
6111
+ "text-wrap": "balance"
5290
6112
  },
5291
6113
  body: {
5292
6114
  family: "sans",
5293
6115
  size: "var(--text-m)",
5294
6116
  weight: 400,
5295
- "line-height": "1.55"
6117
+ "line-height": "1.55",
6118
+ "text-wrap": "pretty"
5296
6119
  }
5297
6120
  },
5298
6121
  headings: {
@@ -5316,7 +6139,7 @@ function defaultConfig() {
5316
6139
  journeys: {
5317
6140
  base: {
5318
6141
  fade: { "opacity-from": 0 },
5319
- slide: {}
6142
+ slide: { "translate-y-from": "var(--slide-distance, 2rem)" }
5320
6143
  },
5321
6144
  compose: [
5322
6145
  ["fade"],
@@ -5329,5 +6152,5 @@ function defaultConfig() {
5329
6152
  }
5330
6153
 
5331
6154
  //#endregion
5332
- export { BASE_HOOK, CONTRAST, DARK_SEL, DOC_ORDER, DOC_SLUGS, DesignSystemSchema, JURISDICTIONS, KNOWN_PROCESSORS, NUMERIC_STEPS, REQUIRED_ROLES, ROLE_TOKEN_KEYS, SCHEMA_URL, SITE_SCHEMA_URL, SiteConfigSchema, TEMPLATES, TW_CLASH, deepMerge, defaultConfig, derivePolicyVars, detectProcessorKeys, enabledDocs, expandPalette, functionalRole, generate, generateDocs, generateLegal, jsonSchema, parseMarkdown, renderMarkdown, renderNodes, resolvePrivacyContact, resolveProcessors, resolveSiteConfig, resolveTheme, roleColorUtilities, roleHue, roleKind, siteJsonSchema, stripNulls, toContentNodes, toHtmlFragment, toPortableText, tokenClass, tokenVar, validate, validateSite };
6155
+ export { BASE_HOOK, CONTRAST, DARK_SEL, DOC_ORDER, DOC_SLUGS, DesignSystemSchema, JURISDICTIONS, KNOWN_PROCESSORS, NUMERIC_STEPS, REQUIRED_ROLES, ROLE_TOKEN_KEYS, SCHEMA_URL, SITE_SCHEMA_URL, SiteConfigSchema, SiteFontSchema, TEMPLATES, TW_CLASH, buildIconSprite, deepMerge, defaultConfig, derivePolicyVars, detectProcessorKeys, enabledDocs, expandPalette, functionalRole, generate, generateDocs, generateLegal, isSiteConfig, jsonSchema, loadIconSvg, parseMarkdown, renderMarkdown, renderNodes, resolveInput, resolvePrivacyContact, resolveProcessors, resolveSiteConfig, resolveTheme, roleColorUtilities, roleHue, roleKind, siteJsonSchema, spriteId, stripNulls, toContentNodes, toHtmlFragment, toPortableText, tokenClass, tokenVar, validate, validateSite };
5333
6156
  //# sourceMappingURL=index.mjs.map