@getvitops/generator 1.0.0 → 2.1.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(/^--/)),
@@ -2775,6 +3105,35 @@ const TwitterSchema = z.object({
2775
3105
  image: z.optional(ImageRefSchema)
2776
3106
  });
2777
3107
  /**
3108
+ * What `vitops notify` needs to tell search engines a deploy happened.
3109
+ *
3110
+ * Scoped by what search engines actually accept, which is narrower than it looks:
3111
+ * Google exposes **no** "request indexing" API (the Search Console button is not in
3112
+ * its API, and URL Inspection is read-only), and it removed the sitemap ping
3113
+ * endpoint in 2023. So the sanctioned set is: an accurate sitemap, a Search Console
3114
+ * sitemap resubmit, an IndexNow ping — which Google does *not* honour but Bing,
3115
+ * Yandex, Naver, Seznam and Yep do — and a read-only inspection pass to find out
3116
+ * what Google actually did.
3117
+ *
3118
+ * Facts only, the same rule the legal block follows: this records what the site has
3119
+ * (a key, a property, which pages matter), never what to say about it.
3120
+ */
3121
+ const IndexNowSchema = z.object({
3122
+ key: desc(z.string().check(z.regex(/^[a-zA-Z0-9-]{8,128}$/)), "IndexNow key (8–128 chars, hex is conventional). NOT a secret — it is served publicly at `keyLocation` so the engine can verify you own the host. Generate one with `vitops notify --new-key`."),
3123
+ keyLocation: desc(z.optional(z.url()), "Absolute URL of the key file. Defaults to `<canonical>/<key>.txt`; set it only when the file lives elsewhere."),
3124
+ endpoint: desc(z.optional(z.url()), "IndexNow endpoint (default `https://api.indexnow.org/indexnow`). Any participating engine shares submissions with the rest, so one is normally enough.")
3125
+ });
3126
+ const SearchConsoleSchema = z.object({
3127
+ siteUrl: desc(z.string(), "The property exactly as Search Console identifies it — `sc-domain:acme.ca` for a domain property, or the URL-prefix form `https://acme.ca/`. A mismatch here is a 403, not a \"not found\"."),
3128
+ resubmitSitemap: desc(z.optional(z.boolean()), "Re-submit the sitemap through the Search Console API on each notify (default true when `searchConsole` is set). This is the automated equivalent of the manual resubmit in the UI.")
3129
+ });
3130
+ const IndexingSchema = z.object({
3131
+ sitemapUrl: desc(z.optional(z.url()), "The sitemap to submit and to diff for changed URLs. Defaults to `<canonical>/sitemap-index.xml`."),
3132
+ indexNow: desc(z.optional(IndexNowSchema), "IndexNow submission (Bing, Yandex, Naver, Seznam, Yep — not Google). Omit to skip the channel."),
3133
+ searchConsole: desc(z.optional(SearchConsoleSchema), "Google Search Console property. Needs a service-account credential in `VITOPS_GSC_SERVICE_ACCOUNT` or `GOOGLE_APPLICATION_CREDENTIALS` at run time — never put the key in this file."),
3134
+ priorityUrls: desc(z.optional(z.array(z.url())), "The pages whose indexing actually matters. `vitops notify --check` inspects these and exits non-zero if Google has not indexed one. Kept explicit because URL Inspection is quota-bound (2000/day), so checking every page is neither affordable nor informative.")
3135
+ });
3136
+ /**
2778
3137
  * Which template set the generated legal documents are written against. This is
2779
3138
  * a closed enum rather than a free string because an unrecognised value would
2780
3139
  * otherwise silently fall back to the wrong body of law; `validateSite` rejects
@@ -2828,11 +3187,8 @@ const SiteConfigSchema = z.object({
2828
3187
  splitRatio: desc(z.optional(z.number().check(z.gte(0), z.lte(1))), "Fraction of traffic sent to the variant (0–1)."),
2829
3188
  variants: desc(z.optional(z.record(z.string(), AbVariantSchema)), "Named variants; each targets an environment and may deep-merge config `overrides`.")
2830
3189
  })), "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`."),
3190
+ designSystem: DesignSystemBlock,
3191
+ 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
3192
  tags: desc(z.optional(z.record(z.string(), z.unknown())), "Free-form content-tag taxonomy (site-specific; not interpreted by the generator)."),
2837
3193
  postTypes: desc(z.optional(z.record(z.string(), z.unknown())), "Free-form content-type definitions (site-specific; not interpreted by the generator)."),
2838
3194
  galleries: desc(z.optional(z.record(z.string(), GallerySchema)), "Named image galleries (title, tags, images with localisable alt/caption)."),
@@ -2849,13 +3205,20 @@ const SiteConfigSchema = z.object({
2849
3205
  googleSiteVerification: z.optional(z.string()),
2850
3206
  bingSiteVerification: z.optional(z.string()),
2851
3207
  openGraph: z.optional(OpenGraphSchema),
2852
- twitter: z.optional(TwitterSchema)
2853
- })), "SEO defaults: title/description templates, robots policy, verification tokens, Open Graph + Twitter cards."),
3208
+ twitter: z.optional(TwitterSchema),
3209
+ indexing: desc(z.optional(IndexingSchema), "How `vitops notify` tells search engines about a deploy: which sitemap, IndexNow key, Search Console property, and which pages to verify afterwards.")
3210
+ })), "SEO defaults: title/description templates, robots policy, verification tokens, Open Graph + Twitter cards, and post-deploy indexing notification."),
2854
3211
  analytics: desc(z.optional(z.object({
2855
3212
  googleAnalyticsId: z.optional(z.string()),
2856
3213
  plausibleDomain: z.optional(z.string()),
2857
- googleTagManagerId: z.optional(z.string())
2858
- })), "Analytics provider IDs (gated per environment via `environments.<env>.analytics`)."),
3214
+ googleTagManagerId: z.optional(z.string()),
3215
+ clarityId: desc(z.optional(z.string()), "Microsoft Clarity project ID."),
3216
+ matomo: desc(z.optional(z.object({
3217
+ url: desc(z.string(), "Instance base URL (self-hosted, or `*.matomo.cloud`)."),
3218
+ siteId: desc(z.string(), "Site ID within that instance."),
3219
+ 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.")
3220
+ })), "Matomo instance.")
3221
+ })), "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
3222
  notifications: desc(z.optional(z.object({ email: z.optional(z.email()) })), "Where site notifications (e.g. form submissions) are sent."),
2860
3223
  tracking: desc(z.optional(z.object({
2861
3224
  enabled: z.optional(z.boolean()),
@@ -2889,9 +3252,11 @@ const SiteConfigSchema = z.object({
2889
3252
  categories: z.optional(z.array(z.string()))
2890
3253
  }))
2891
3254
  })), "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\")."),
3255
+ icons: desc(z.optional(z.looseObject({
3256
+ ui: desc(z.optional(z.string()), "Icon set used for UI chrome (e.g. \"lucide\", \"ph\")."),
2894
3257
  brand: desc(z.optional(z.string()), "Icon set used for brand marks."),
3258
+ 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."),
3259
+ 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
3260
  semantic: desc(z.optional(z.array(z.string())), "Named semantic icons to include."),
2896
3261
  "fa7-solid": z.optional(z.array(z.string())),
2897
3262
  "fa7-regular": z.optional(z.array(z.string())),
@@ -2900,7 +3265,8 @@ const SiteConfigSchema = z.object({
2900
3265
  "fa7-brands": z.optional(z.array(z.string())),
2901
3266
  "simple-icons": z.optional(z.array(z.string())),
2902
3267
  "material-symbols": z.optional(z.array(z.string())),
2903
- lucide: z.optional(z.array(z.string()))
3268
+ lucide: z.optional(z.array(z.string())),
3269
+ ph: z.optional(z.array(z.string()))
2904
3270
  })), "Icon sets and the specific icons to bundle from each (keys are iconify collection names)."),
2905
3271
  favicon: desc(z.optional(z.object({
2906
3272
  source: desc(z.string(), "Source image (SVG/PNG) the favicon set is generated from."),
@@ -2944,17 +3310,24 @@ function validateSite(input) {
2944
3310
  const errors = [];
2945
3311
  const has = (m, k) => !!m && k in m;
2946
3312
  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([
3313
+ const themes = cfg.designSystem?.themes ?? {};
3314
+ const defaultTheme = cfg.designSystem?.defaultTheme;
3315
+ if (!has(themes, "default") && defaultTheme == null) errors.push(issue(["designSystem", "themes"], "themes must include a \"default\" entry"));
3316
+ if (defaultTheme != null && !has(themes, defaultTheme)) errors.push(issue(["designSystem", "defaultTheme"], `defaultTheme "${defaultTheme}" is not in designSystem.themes`));
3317
+ for (const [key, entry] of Object.entries(themes)) if (entry.extends != null && !has(themes, entry.extends)) errors.push(issue([
2950
3318
  "designSystem",
3319
+ "themes",
2951
3320
  key,
2952
3321
  "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);
3322
+ ], `extends "${entry.extends}" is not a themes key`));
3323
+ for (const key of Object.keys(themes)) try {
3324
+ resolveTheme(themes, key);
2956
3325
  } catch (e) {
2957
- errors.push(issue(["designSystem", key], e.message));
3326
+ errors.push(issue([
3327
+ "designSystem",
3328
+ "themes",
3329
+ key
3330
+ ], e.message));
2958
3331
  }
2959
3332
  if (cfg.primaryLocation != null && !has(cfg.locations, cfg.primaryLocation)) errors.push(issue(["primaryLocation"], `primaryLocation "${cfg.primaryLocation}" is not in locations`));
2960
3333
  if (typeof cfg.contact === "string" && !has(cfg.locations, cfg.contact)) errors.push(issue(["contact"], `contact "${cfg.contact}" is not a locations key`));
@@ -2993,6 +3366,20 @@ function validateSite(input) {
2993
3366
  if (resolvePrivacyContact(cfg) == null) errors.push(issue(["legal", "privacyPolicy"], "privacyPolicy.enabled requires a contact for privacy requests — set legal.privacyPolicy.privacyOfficer, contact, or organization.email/address"));
2994
3367
  if (cfg.domains?.canonical == null) errors.push(issue(["legal", "privacyPolicy"], "privacyPolicy.enabled requires domains.canonical — the policy states where information is held"));
2995
3368
  }
3369
+ const indexing = cfg.seo?.indexing;
3370
+ if (indexing != null && cfg.domains?.canonical == null) {
3371
+ if (indexing.sitemapUrl == null) errors.push(issue([
3372
+ "seo",
3373
+ "indexing",
3374
+ "sitemapUrl"
3375
+ ], "seo.indexing needs domains.canonical to derive the sitemap URL — set one, or state sitemapUrl explicitly"));
3376
+ if (indexing.indexNow != null && indexing.indexNow.keyLocation == null) errors.push(issue([
3377
+ "seo",
3378
+ "indexing",
3379
+ "indexNow",
3380
+ "keyLocation"
3381
+ ], "seo.indexing.indexNow needs domains.canonical to derive the key location — set one, or state keyLocation explicitly"));
3382
+ }
2996
3383
  if (errors.length) return {
2997
3384
  ok: false,
2998
3385
  data: void 0,
@@ -3061,22 +3448,49 @@ function resolveTheme(designSystem, key) {
3061
3448
  return out;
3062
3449
  }
3063
3450
  /**
3451
+ * Normalise the two `designSystem` shorthands to the canonical
3452
+ * `{ themes: { … } }` shape.
3453
+ *
3454
+ * Both are keyed off what CANNOT be a theme name in the other reading:
3455
+ * - `colors` → a bare `DesignSystem` was written inline.
3456
+ * - `themes` → already canonical.
3457
+ * - neither → the legacy bare theme map, from before the block gained
3458
+ * system-wide fields. Every config authored against the old schema takes this
3459
+ * branch, which is what keeps the change forgiving at runtime even though the
3460
+ * published JSON Schema moved.
3461
+ */
3462
+ function normaliseDesignSystem(raw) {
3463
+ const ds = raw.designSystem;
3464
+ if (ds == null || typeof ds !== "object") return;
3465
+ if ("themes" in ds) return;
3466
+ raw.designSystem = "colors" in ds ? { themes: { default: ds } } : { themes: ds };
3467
+ }
3468
+ /**
3064
3469
  * Resolve a raw (already-parsed) config into a validated `SiteConfig`:
3065
3470
  * 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: … }`,
3471
+ * 2. normalise the `designSystem` shorthands to `{ themes: … }`,
3472
+ * 3. apply the active A/B variant's `overrides` (by `siteEnv`),
3068
3473
  * 4. validate (shape + cross-field).
3069
3474
  * Pure — no YAML/env access. A loader supplies `siteEnv`.
3475
+ *
3476
+ * Steps 2 and 3 are in this order deliberately. Normalising first means an
3477
+ * `abTesting` override always addresses the canonical shape
3478
+ * (`designSystem.themes.<name>`), whatever shorthand the base config used. With
3479
+ * the merge first, an override's key path depended on how the base happened to be
3480
+ * written — so the same patch hit a different place in two configs that were
3481
+ * otherwise equivalent.
3070
3482
  */
3071
3483
  function resolveSiteConfig(input, siteEnv = "production") {
3072
3484
  const raw = stripNulls(input);
3485
+ normaliseDesignSystem(raw);
3073
3486
  const envConfig = raw.environments?.[siteEnv];
3074
3487
  if (envConfig?.variant) {
3075
3488
  const overrides = raw.abTesting?.variants?.[envConfig.variant]?.overrides;
3076
- if (overrides) deepMerge(raw, overrides);
3489
+ if (overrides) {
3490
+ normaliseDesignSystem(overrides);
3491
+ deepMerge(raw, overrides);
3492
+ }
3077
3493
  }
3078
- const ds = raw.designSystem;
3079
- if (ds != null && "colors" in ds) raw.designSystem = { default: ds };
3080
3494
  const result = validateSite(raw);
3081
3495
  if (!result.ok) {
3082
3496
  const msg = result.errors.map((e) => ` ${e.path.join(".")}: ${e.message}`).join("\n");
@@ -3084,6 +3498,52 @@ function resolveSiteConfig(input, siteEnv = "production") {
3084
3498
  }
3085
3499
  return result.data;
3086
3500
  }
3501
+ /**
3502
+ * Is this raw config a `SiteConfig` rather than a bare `design-system.json`?
3503
+ *
3504
+ * The discriminator is **total**, not a heuristic, and both halves are load-
3505
+ * bearing: `SiteConfigSchema` cannot validate without a `designSystem` (a config
3506
+ * lacking one fails `validateSite` with `themes must include a "default" entry`),
3507
+ * and `DesignSystemSchema` is strict, so a `designSystem` key in a design system
3508
+ * is an `unrecognized_keys` error. No document can be read as both, and no
3509
+ * document that is neither reaches here without failing validation anyway.
3510
+ *
3511
+ * Deliberately shape-based rather than filename-based: consumers name this file
3512
+ * whatever suits them (`company.json`, `site.json`, `vitops.config.json`), and a
3513
+ * rule keyed to a name would silently stop working the moment someone renamed it.
3514
+ */
3515
+ function isSiteConfig(raw) {
3516
+ return typeof raw === "object" && raw != null && !Array.isArray(raw) && "designSystem" in raw;
3517
+ }
3518
+ /**
3519
+ * Resolve a raw config of either kind into the design system to build from.
3520
+ *
3521
+ * Every entry point that used to take a `design-system.json` goes through this,
3522
+ * so a consumer who keeps their tokens inside the larger site config points the
3523
+ * same option at that file instead of maintaining a second one. The site config
3524
+ * is handed back too, because the parts of generation that depend on site-level
3525
+ * facts (`designSystem.defaultColorScheme`, the legal documents, the icon
3526
+ * sprite) would otherwise need the same path declared a second time.
3527
+ *
3528
+ * The returned `designSystem` is **not** validated here — the caller does that,
3529
+ * so it can say which file and which theme the errors belong to. Validation of
3530
+ * the site config itself has already happened (`resolveSiteConfig` throws).
3531
+ */
3532
+ function resolveInput(raw, opts = {}) {
3533
+ if (!isSiteConfig(raw)) {
3534
+ 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.`);
3535
+ return { designSystem: raw };
3536
+ }
3537
+ const site = resolveSiteConfig(raw, opts.siteEnv);
3538
+ const themes = site.designSystem?.themes ?? {};
3539
+ const theme = opts.theme ?? site.designSystem?.defaultTheme ?? "default";
3540
+ if (!(theme in themes)) throw new Error(`designSystem.themes has no "${theme}" entry (found: ${Object.keys(themes).join(", ") || "none"})`);
3541
+ return {
3542
+ designSystem: resolveTheme(themes, theme),
3543
+ site,
3544
+ theme
3545
+ };
3546
+ }
3087
3547
  /** Strip nulls (YAML `~`/`null`) → undefined so optional fields validate. */
3088
3548
  function stripNulls(obj) {
3089
3549
  if (obj === null) return void 0;
@@ -3137,6 +3597,30 @@ const KNOWN_PROCESSORS = {
3137
3597
  privacyUrl: "https://plausible.io/privacy",
3138
3598
  cookies: []
3139
3599
  },
3600
+ clarity: {
3601
+ name: "Microsoft Clarity",
3602
+ purpose: "session replay and heatmaps",
3603
+ country: "the United States",
3604
+ privacyUrl: "https://privacy.microsoft.com/privacystatement",
3605
+ cookies: [
3606
+ "_clck",
3607
+ "_clsk",
3608
+ "MUID"
3609
+ ],
3610
+ optOut: "https://privacy.microsoft.com/privacystatement"
3611
+ },
3612
+ matomo: {
3613
+ name: "Matomo",
3614
+ purpose: "website analytics",
3615
+ privacyUrl: "https://matomo.org/privacy-policy/",
3616
+ cookies: ["_pk_id", "_pk_ses"]
3617
+ },
3618
+ matomoCookieless: {
3619
+ name: "Matomo",
3620
+ purpose: "website analytics",
3621
+ privacyUrl: "https://matomo.org/privacy-policy/",
3622
+ cookies: []
3623
+ },
3140
3624
  turnstile: {
3141
3625
  name: "Cloudflare Turnstile",
3142
3626
  purpose: "bot protection on forms",
@@ -3185,6 +3669,8 @@ function detectProcessorKeys(site) {
3185
3669
  if (analytics?.googleAnalyticsId) keys.push("googleAnalytics");
3186
3670
  if (analytics?.googleTagManagerId) keys.push("googleTagManager");
3187
3671
  if (analytics?.plausibleDomain) keys.push("plausible");
3672
+ if (analytics?.clarityId) keys.push("clarity");
3673
+ if (analytics?.matomo) keys.push(analytics.matomo.cookies ? "matomo" : "matomoCookieless");
3188
3674
  if (site.security?.turnstile?.siteKey) keys.push("turnstile");
3189
3675
  const platform = site.deployment?.platform;
3190
3676
  const hosting = platform && HOSTING_PATTERNS.find(([re]) => re.test(platform))?.[1];
@@ -3199,10 +3685,27 @@ function detectProcessorKeys(site) {
3199
3685
  * an explicit declaration is a statement of fact we have no grounds to drop.
3200
3686
  */
3201
3687
  function resolveProcessors(site) {
3202
- const detected = detectProcessorKeys(site).map((k) => KNOWN_PROCESSORS[k]);
3688
+ const detected = detectProcessorKeys(site).map((k) => matomoCountry(KNOWN_PROCESSORS[k], site));
3203
3689
  const declared = site.legal?.privacyPolicy?.processors ?? [];
3204
3690
  return [...detected, ...declared];
3205
3691
  }
3692
+ /**
3693
+ * Matomo is the one provider whose location the config actually knows.
3694
+ *
3695
+ * Matomo Cloud is hosted in the EU; a self-hosted instance is wherever the
3696
+ * consumer put it, which is very often their own infrastructure in their own
3697
+ * country. So the cross-border transfer sentence is asserted only for the case we
3698
+ * can see. Naming a transfer that isn't happening is the same class of error as
3699
+ * omitting one that is — the config records facts, and "we don't know" is a fact.
3700
+ */
3701
+ function matomoCountry(processor, site) {
3702
+ if (processor.name !== "Matomo") return processor;
3703
+ const url = site.analytics?.matomo?.url ?? "";
3704
+ return /\.matomo\.cloud/i.test(url) ? {
3705
+ ...processor,
3706
+ country: "the European Union"
3707
+ } : processor;
3708
+ }
3206
3709
 
3207
3710
  //#endregion
3208
3711
  //#region src/legal/derive.ts
@@ -3321,7 +3824,7 @@ function derivePolicyVars(site) {
3321
3824
  const processors = resolveProcessors(site);
3322
3825
  const fields = formFields(site);
3323
3826
  const hasForms = fields.length > 0;
3324
- const hasAnalytics = !!site.analytics && Object.values(site.analytics).some((v) => typeof v === "string" && v.length > 0);
3827
+ const hasAnalytics = Object.values(site.analytics ?? {}).some((v) => typeof v === "string" ? v.length > 0 : !!v);
3325
3828
  processors.filter((p) => p.cookies?.length);
3326
3829
  const consent = legal?.cookieConsent;
3327
3830
  const contact = resolvePrivacyContact(site);
@@ -3333,15 +3836,18 @@ function derivePolicyVars(site) {
3333
3836
  fields.some((f) => /newsletter|subscribe|mailing/i.test(f.name)) ? "register to receive our newsletter" : void 0
3334
3837
  ]);
3335
3838
  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"]);
3839
+ const hasSessionReplay = !!site.analytics?.clarityId;
3336
3840
  const techInfo = uniq([
3337
3841
  "your domain name",
3338
3842
  "your numerical IP address",
3339
3843
  hasAnalytics ? "the pages you view and the files you request" : void 0,
3340
3844
  hasAnalytics ? "the type of browser and operating system you use" : void 0,
3341
- hasAnalytics ? "the address of the site that referred you" : void 0
3845
+ hasAnalytics ? "the address of the site that referred you" : void 0,
3846
+ hasSessionReplay ? "a recording of how you interacted with a page, including mouse movement, scrolling and clicks" : void 0
3342
3847
  ]);
3343
3848
  const techInfoPurposes = uniq([
3344
3849
  hasAnalytics ? "measure the performance of our Site" : void 0,
3850
+ hasSessionReplay ? "see where visitors encounter difficulty using our Site" : void 0,
3345
3851
  site.security?.turnstile?.siteKey ? "protect our Site and its forms from automated abuse" : void 0,
3346
3852
  "better understand how visitors use our Site",
3347
3853
  "improve our Site to better meet your needs"
@@ -3351,11 +3857,13 @@ function derivePolicyVars(site) {
3351
3857
  "your IP address",
3352
3858
  "the type of web browser and operating system used",
3353
3859
  "the pages of the Site visited",
3354
- ab?.enabled ? "which version of a page you were shown" : void 0
3860
+ ab?.enabled ? "which version of a page you were shown" : void 0,
3861
+ hasSessionReplay ? "how you moved through and interacted with a page" : void 0
3355
3862
  ]);
3356
3863
  const cookiePurposes = uniq([
3357
3864
  "keep the Site functioning as you move between pages",
3358
3865
  hasAnalytics ? "understand how our Site is used, in aggregate" : void 0,
3866
+ hasSessionReplay ? "recognise a returning visit as part of the same session" : void 0,
3359
3867
  ab?.enabled ? "keep you on a consistent version of the Site between visits" : void 0,
3360
3868
  site.security?.turnstile?.siteKey ? "distinguish visitors from automated traffic" : void 0
3361
3869
  ]);
@@ -3980,6 +4488,118 @@ function generateLegal(site, opts = {}) {
3980
4488
  return out;
3981
4489
  }
3982
4490
 
4491
+ //#endregion
4492
+ //#region src/icons-sprite.ts
4493
+ /**
4494
+ * Build an SVG sprite from an icon `include` map.
4495
+ *
4496
+ * This is the delivery path for consumers that cannot run an icon integration:
4497
+ * Bricks/WordPress, EmDash's renderers, anything rendering HTML outside an Astro
4498
+ * build. Markup is `<svg><use href="…/icons.svg#ph--caret-down"/></svg>` — no
4499
+ * runtime JS and no network call, which is what keeps it inside the tier rules
4500
+ * (a runtime-fetching element would be behaviour JS with no fallback to enhance).
4501
+ *
4502
+ * The icon DATA comes from the `@iconify-json/*` collections, which stay optional
4503
+ * peers loaded dynamically: `@iconify-json/ph` alone is ~4.5 MB and the semantic
4504
+ * map spans five sets, so depending on them directly would be indefensible for
4505
+ * every consumer who never asks for a sprite.
4506
+ */
4507
+ /**
4508
+ * `ph:caret-down` → `ph--caret-down`.
4509
+ *
4510
+ * The one place this grammar lives. `Icon.astro` mirrors it and `icon.test.ts`
4511
+ * guards the pair, because a drift here resolves `<use>` to nothing and renders
4512
+ * as an empty box rather than an error.
4513
+ */
4514
+ function spriteId(qualified) {
4515
+ return qualified.replace(":", "--");
4516
+ }
4517
+ /**
4518
+ * Default loader: the collection JSON shipped by `@iconify-json/<prefix>`.
4519
+ *
4520
+ * Resolved through a `createRequire` rooted at the consumer's directory, then
4521
+ * imported by absolute path — a bare specifier would resolve against this
4522
+ * package instead, where the collections deliberately aren't installed.
4523
+ */
4524
+ async function loadCollection(prefix, from) {
4525
+ try {
4526
+ const mod = await import(pathToFileURL(createRequire(pathToFileURL(join(from, "noop.js"))).resolve(`@iconify-json/${prefix}/icons.json`)).href, { with: { type: "json" } });
4527
+ return mod.default ?? mod;
4528
+ } catch {
4529
+ return null;
4530
+ }
4531
+ }
4532
+ async function buildIconSprite(o) {
4533
+ const from = o.resolveFrom ?? process.cwd();
4534
+ const load = o.loadSet ?? ((prefix) => loadCollection(prefix, from));
4535
+ const symbols = [];
4536
+ const ids = [];
4537
+ const missing = [];
4538
+ /** qualified name → its rendered body+viewBox, so an alias can reuse it. */
4539
+ const rendered = /* @__PURE__ */ new Map();
4540
+ for (const [prefix, names] of Object.entries(o.include)) {
4541
+ if (!names?.length) continue;
4542
+ const set = await load(prefix);
4543
+ 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.`);
4544
+ for (const name of names) {
4545
+ const data = getIconData(set, name);
4546
+ if (!data) {
4547
+ missing.push(`${prefix}:${name}`);
4548
+ continue;
4549
+ }
4550
+ const built = iconToSVG(data, { height: "none" });
4551
+ const body = replaceIDs(built.body);
4552
+ const id = spriteId(`${prefix}:${name}`);
4553
+ rendered.set(`${prefix}:${name}`, {
4554
+ viewBox: built.attributes.viewBox,
4555
+ body
4556
+ });
4557
+ ids.push(id);
4558
+ symbols.push(`<symbol id="${id}" viewBox="${built.attributes.viewBox}">${body}</symbol>`);
4559
+ }
4560
+ }
4561
+ for (const [alias, target] of Object.entries(o.aliases ?? {})) {
4562
+ const hit = rendered.get(target);
4563
+ if (!hit) {
4564
+ missing.push(`${target} (aliased as ${alias})`);
4565
+ continue;
4566
+ }
4567
+ ids.push(alias);
4568
+ symbols.push(`<symbol id="${alias}" viewBox="${hit.viewBox}">${hit.body}</symbol>`);
4569
+ }
4570
+ return {
4571
+ svg: `<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">` + symbols.join("") + `</svg>\n`,
4572
+ ids,
4573
+ missing
4574
+ };
4575
+ }
4576
+ /**
4577
+ * One icon's renderable parts, for inlining into markup.
4578
+ *
4579
+ * Exists because `<Icon />` cannot go through astro-icon: its `./components`
4580
+ * entry is a TypeScript file that Vite must transform, so it can only be
4581
+ * reached by a statically-analysable import — and a static import is exactly
4582
+ * what made the optional peer mandatory for anyone rendering Popover, Details
4583
+ * or Drawer. Reading the collection directly sidesteps the peer entirely and
4584
+ * shares this file's loader with the sprite, so both paths draw the same glyph.
4585
+ */
4586
+ async function loadIconSvg(qualified, opts) {
4587
+ const i = qualified.indexOf(":");
4588
+ if (i <= 0) return null;
4589
+ const prefix = qualified.slice(0, i);
4590
+ const name = qualified.slice(i + 1);
4591
+ const from = opts?.resolveFrom ?? process.cwd();
4592
+ const set = await (opts?.loadSet ?? ((p) => loadCollection(p, from)))(prefix);
4593
+ if (!set) return null;
4594
+ const data = getIconData(set, name);
4595
+ if (!data) return null;
4596
+ const built = iconToSVG(data, { height: "none" });
4597
+ return {
4598
+ viewBox: built.attributes.viewBox,
4599
+ body: replaceIDs(built.body)
4600
+ };
4601
+ }
4602
+
3983
4603
  //#endregion
3984
4604
  //#region src/generate.ts
3985
4605
  /**
@@ -4026,6 +4646,47 @@ const targetOf = (key) => {
4026
4646
  return i === -1 ? key : key.slice(0, i);
4027
4647
  };
4028
4648
  /**
4649
+ * The framework's container breakpoints. `sm-`/`md-`/`lg-`/`xl-` prefixes in the
4650
+ * css/bricks formats; `@sm:`/`@md:`/… in tailwind, where they are registered as
4651
+ * `--container-*` in `@theme` and Tailwind expands the variants itself.
4652
+ */
4653
+ const BREAKPOINTS = [
4654
+ ["sm", "30rem"],
4655
+ ["md", "48rem"],
4656
+ ["lg", "64rem"],
4657
+ ["xl", "80rem"]
4658
+ ];
4659
+ /**
4660
+ * Gap utilities over the space scale, emitted from one list into all three
4661
+ * formats — same arrangement as `roleColorUtilities`, for the same reason.
4662
+ *
4663
+ * These did not exist. `vitops docs css` advertised a `g` class that was never
4664
+ * emitted, so the honest answer for a css/bricks consumer was an inline
4665
+ * `style="gap: …"`, which is what this repo's own `index.html` does throughout.
4666
+ * Tailwind does not fill the hole either: the fluid space steps are deliberately
4667
+ * kept OUT of Tailwind's `--spacing-*` namespace (named keys there shadow the
4668
+ * size scales — `max-w-7xl` would resolve to `var(--spacing-7xl)`), so `gap-md`
4669
+ * is not something Tailwind can derive on its own. Measured against
4670
+ * tailwindcss@4.3.3: an explicit `@utility gap-md` is honoured, coexists with
4671
+ * the built-in numeric `gap-4`, and picks up variants (`@md:gap-md`).
4672
+ *
4673
+ * The whole matrix is emitted rather than a useful-looking subset: an undefined
4674
+ * step produces no rule and no error in either format, so a missing
4675
+ * `md-gap-x-2xl` would be indistinguishable from a working one.
4676
+ */
4677
+ function gapUtilities(steps) {
4678
+ const FAMILIES = [
4679
+ ["gap", "gap"],
4680
+ ["gap-x", "column-gap"],
4681
+ ["gap-y", "row-gap"]
4682
+ ];
4683
+ return steps.flatMap(({ name }) => FAMILIES.map(([prefix, prop]) => ({
4684
+ cls: `${prefix}-${name}`,
4685
+ prop,
4686
+ value: `var(--space-${name})`
4687
+ })));
4688
+ }
4689
+ /**
4029
4690
  * Every role colour utility, each class name emitted EXACTLY once.
4030
4691
  *
4031
4692
  * There is now a single axis. The target lives *inside* the token name, so
@@ -4104,6 +4765,23 @@ const TYPO_KEYMAP = {
4104
4765
  "text-wrap": ["text-wrap", "tw"],
4105
4766
  color: ["color", "color"]
4106
4767
  };
4768
+ /**
4769
+ * Wrong spellings worth naming explicitly, because each is a plausible reading of
4770
+ * the key it isn't. `transform`/`decoration` were documented in the schema for a
4771
+ * while and shipped title-case navigation to production; `letter-spacing` and
4772
+ * `font-*` are the natural guesses for keys we chose to shorten.
4773
+ */
4774
+ const ALSO_MEANT = {
4775
+ transform: "text-transform",
4776
+ decoration: "text-decoration",
4777
+ "letter-spacing": "tracking",
4778
+ wrap: "text-wrap",
4779
+ "font-family": "family",
4780
+ "font-size": "size",
4781
+ "font-weight": "weight",
4782
+ "font-style": "style",
4783
+ lineHeight: "line-height"
4784
+ };
4107
4785
  const TYPO_IDENTITY = {
4108
4786
  "font-style": "normal",
4109
4787
  "letter-spacing": "normal",
@@ -4126,10 +4804,11 @@ const STATE_DEFAULT = {
4126
4804
  saturate: "1",
4127
4805
  sepia: "0",
4128
4806
  "hue-rotate": "0deg",
4129
- clip: "none"
4807
+ clip: "none",
4808
+ height: "auto"
4130
4809
  };
4131
4810
  const id = (key) => createHash("sha256").update(key).digest("hex").slice(0, 6);
4132
- function build(ds, format, assetsDir) {
4811
+ function build(ds, format, assetsDir, buildOpts = {}) {
4133
4812
  const BRICKS = format === "bricks";
4134
4813
  const expandedPalette = expandPalette(ds.colors.palette);
4135
4814
  const roleMap = ds.colors.roles;
@@ -4143,6 +4822,10 @@ function build(ds, format, assetsDir) {
4143
4822
  const surfaceRole = scaleRoles.find((r) => r.role === "surface");
4144
4823
  const focusRole = scaleRoles.find((r) => r.role === "ui-primary") ?? scaleRoles[0];
4145
4824
  for (const w of ladderWarnings(expandedPalette)) console.warn(`[vitops] ${w}`);
4825
+ {
4826
+ const errors = monotonicityErrors(expandedPalette);
4827
+ if (errors.length) throw new Error(errors.join("\n"));
4828
+ }
4146
4829
  {
4147
4830
  const numericOf = (hueName) => expandedPalette[hueName]?.numeric ?? {};
4148
4831
  const resolve = (val) => {
@@ -4206,6 +4889,9 @@ function build(ds, format, assetsDir) {
4206
4889
  if (fb) {
4207
4890
  css += `/* Functional role tokens (dark) */\n`;
4208
4891
  css += `${DARK_SEL} {\n color-scheme: dark;\n${fb}}\n\n`;
4892
+ if (buildOpts.systemColorScheme) css += `/* Functional role tokens (dark) — OS preference, no explicit choice */
4893
+ @media (prefers-color-scheme: dark) {
4894
+ ${SYSTEM_DARK_SEL} {\n color-scheme: dark;\n${fb.replace(/^ {2}/gm, " ")} }\n}\n\n`;
4209
4895
  }
4210
4896
  }
4211
4897
  css += `/* Colour utilities — ${UTILITIES.join(", ")} */\n`;
@@ -4231,6 +4917,14 @@ function build(ds, format, assetsDir) {
4231
4917
  const spaceSteps = ds.spaceScale ? buildScale(ds.spaceScale) : [];
4232
4918
  const textMax = Object.fromEntries(typeSteps.map((s) => [`text-${s.name}`, s.max]));
4233
4919
  const typographyFamilies = typography?.families ?? {};
4920
+ {
4921
+ const known = new Set(Object.keys(TYPO_KEYMAP));
4922
+ for (const [role, spec] of Object.entries(typography?.roles ?? {})) for (const key of Object.keys(spec)) {
4923
+ if (known.has(key)) continue;
4924
+ const near = ALSO_MEANT[key];
4925
+ console.warn(`[vitops] typography.roles.${role}: unknown key "${key}" — ignored` + (near ? `. Did you mean "${near}"?` : `. Recognised: ${[...known].join(", ")}.`));
4926
+ }
4927
+ }
4234
4928
  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
4929
  const roleDecls = (role, spec) => {
4236
4930
  const out = {};
@@ -4248,10 +4942,26 @@ function build(ds, format, assetsDir) {
4248
4942
  for (const [name, stack] of Object.entries(ds.fonts ?? {})) root[`--font-${name}`] = stack;
4249
4943
  for (const s of typeSteps) root[`--text-${s.name}`] = s.value;
4250
4944
  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`;
4945
+ const css = `/* GENERATED font families + fluid type/space scales — do not edit by hand.
4946
+ --font-* are STACKS ONLY; no @font-face is emitted here. Load webfonts with
4947
+ Astro's fonts: config and point the token at the family's cssVariable. */
4948
+ :root {\n${decls(root)}\n}\n`;
4252
4949
  twTypeTokensCss = css;
4253
4950
  generated["type-tokens.css"] = css;
4254
4951
  }
4952
+ {
4953
+ const gaps = gapUtilities(spaceSteps);
4954
+ let sp = `/* GENERATED gap utilities — do not edit by hand. */\n`;
4955
+ if (gaps.length) {
4956
+ for (const { cls, prop, value } of gaps) sp += `.${cls} { ${prop}: ${value}; }\n`;
4957
+ for (const [bp, width] of BREAKPOINTS) {
4958
+ sp += `\n@container (min-width: ${width}) {\n`;
4959
+ for (const { cls, prop, value } of gaps) sp += ` .${bp}-${cls} { ${prop}: ${value}; }\n`;
4960
+ sp += `}\n`;
4961
+ }
4962
+ }
4963
+ generated["spacing.css"] = sp;
4964
+ }
4255
4965
  let twPatternTokensCss = "";
4256
4966
  {
4257
4967
  const tDefaults = patterns?.defaults ?? {};
@@ -4286,7 +4996,6 @@ function build(ds, format, assetsDir) {
4286
4996
  generated["typography.css"] = typo;
4287
4997
  }
4288
4998
  const stateRules = (sel, role, states, colorProp = "background-color") => {
4289
- const sels = Array.isArray(sel) ? sel : [sel];
4290
4999
  let out = "";
4291
5000
  for (const [state, spec] of Object.entries(states)) {
4292
5001
  const body = [];
@@ -4304,8 +5013,7 @@ function build(ds, format, assetsDir) {
4304
5013
  }
4305
5014
  if (spec.css && typeof spec.css === "object") for (const [k, v] of Object.entries(spec.css)) body.push(`${k}: ${v};`);
4306
5015
  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`;
5016
+ const rule = `${sel(state === "hover" ? ":hover" : state === "active" ? ":active" : state === "focus-visible" ? ":focus-visible" : `:${state}`).join(",\n")} {\n ${body.join("\n ")}\n}\n`;
4309
5017
  out += state === "hover" ? `@media (hover: hover) {\n${rule}}\n` : rule;
4310
5018
  }
4311
5019
  return out;
@@ -4316,8 +5024,9 @@ function build(ds, format, assetsDir) {
4316
5024
  if (!p.base) continue;
4317
5025
  const states = p.states ?? {};
4318
5026
  const cls = p.class ?? (p.element ? void 0 : pname);
4319
- const elementSel = (extra = "") => `:where(${[p.element, cls && `.${cls}`].filter(Boolean).join(", ")})${extra}`;
5027
+ const elementSel = (extra = "", pseudo = "") => `:where(${[p.element, cls && `.${cls}`].filter(Boolean).map((s) => `${s}${pseudo}`).join(", ")})${extra}`;
4320
5028
  const defaultSel = p.element ? elementSel() : `.${cls}`;
5029
+ const defaultSelFor = (pseudo) => p.element ? [elementSel("", pseudo)] : [`.${cls}${pseudo}`];
4321
5030
  const defaultRole = p.default_role ?? null;
4322
5031
  const base = { ...p.base };
4323
5032
  const fills = p.fill ?? (pname === "button" || pname === "badge" || base["background-color"] != null || base["background"] != null);
@@ -4336,12 +5045,13 @@ function build(ds, format, assetsDir) {
4336
5045
  wrappedBase[prop] = sfx ? `var(--${sfx}-${pname}, ${val})` : String(val);
4337
5046
  }
4338
5047
  pat += decls(wrappedBase) + "\n}\n";
4339
- pat += stateRules(defaultSel, defaultRole, states, colorProp);
5048
+ pat += stateRules(defaultSelFor, defaultRole, states, colorProp);
4340
5049
  for (const role of p.roles ?? []) {
4341
- const variantSels = p.element ? [elementSel(`.${role}`), ...cls ? [`.${cls}-${role}`] : []] : [`.${cls}-${role}`];
5050
+ const variantSelsFor = (pseudo) => p.element ? [elementSel(`.${role}`, pseudo), ...cls ? [`.${cls}-${role}${pseudo}`] : []] : [`.${cls}-${role}${pseudo}`];
5051
+ const variantSels = variantSelsFor("");
4342
5052
  const variantColorDecl = fills ? `background-color: var(--color-bg-${role}-solid); color: var(--color-text-on-${role})` : `color: var(--color-text-${role})`;
4343
5053
  pat += `${variantSels.join(",\n")} { ${variantColorDecl}; }\n`;
4344
- pat += stateRules(variantSels, role, states, colorProp);
5054
+ pat += stateRules(variantSelsFor, role, states, colorProp);
4345
5055
  }
4346
5056
  pat += "\n";
4347
5057
  }
@@ -4361,25 +5071,25 @@ function build(ds, format, assetsDir) {
4361
5071
  for (const parts of anim.journeys?.compose ?? []) {
4362
5072
  const d = {
4363
5073
  "--_anim": parts.map((p) => `${p}-journey`).join(", "),
4364
- "animation-range": "entry exit"
5074
+ "animation-range": "entry calc(50% + 10vh) exit 100%"
4365
5075
  };
4366
5076
  for (const p of parts) for (const [k, v] of Object.entries(base[p] ?? {})) d[`--${k}`] = asStr(v);
4367
5077
  out += block(`${parts.join("-")}-journey`, d);
4368
5078
  }
4369
5079
  const STATES = [
4370
- ["hover", (s) => `.${s}:hover`],
4371
- ["focus", (s) => `.${s}:focus-visible`],
5080
+ ["hover", (s) => `.${s}:hover, :hover > .${s}`],
5081
+ ["focus", (s) => `.${s}:focus-visible, :focus-within > .${s}`],
4372
5082
  ["active", (s) => `.${s}.is-active, .${s}[data-active]`]
4373
5083
  ];
4374
5084
  out += `\n/* State-flip variants — compose with .transition (hover-/focus-/active-<fx>). */\n`;
4375
5085
  for (const [name, e] of Object.entries(anim.effects ?? {})) {
4376
- if (e.kf !== "composite" && e.kf !== "paint") continue;
4377
5086
  const props = {};
4378
5087
  for (const [k, v] of Object.entries(e.vars ?? {})) {
4379
5088
  const m = /^(.*)-(from|to)$/.exec(k);
4380
5089
  if (m) (props[m[1]] ??= {})[m[2]] = asStr(v);
4381
5090
  }
4382
5091
  const rest = {};
5092
+ for (const [k, v] of Object.entries(e.css ?? {})) rest[k] = asStr(v);
4383
5093
  for (const [p, ft] of Object.entries(props)) {
4384
5094
  if (ft.from != null && ft.from !== STATE_DEFAULT[p]) rest[`--${p}-from`] = ft.from;
4385
5095
  if (ft.to != null && ft.to !== STATE_DEFAULT[p]) rest[`--${p}-to`] = ft.to;
@@ -4602,7 +5312,8 @@ function build(ds, format, assetsDir) {
4602
5312
  twPatternTokensCss,
4603
5313
  patternsCss: pat,
4604
5314
  assetsDir,
4605
- utilities: UTILITIES
5315
+ utilities: UTILITIES,
5316
+ systemColorScheme: buildOpts.systemColorScheme === true
4606
5317
  }),
4607
5318
  designMd: format !== "design" ? "" : emitDesignMd({
4608
5319
  ds,
@@ -4621,24 +5332,19 @@ function emitTailwind(ctx) {
4621
5332
  const { ds, expandedPalette, scaleRoles, shadows, typeSteps, spaceSteps, typography, roleDecls, twTypeTokensCss, twPatternTokensCss, patternsCss, assetsDir, utilities } = ctx;
4622
5333
  const cssDir = join(assetsDir, "css");
4623
5334
  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 */"];
5335
+ 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
5336
  parts.push(`@import "tailwindcss";\n`);
4626
5337
  const theme = [];
4627
5338
  theme.push(` /* Hue scales → bg-<hue>-<step> text-* border-* (numeric, 50…950) */`);
4628
5339
  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-* */`);
5340
+ theme.push(`\n /* Fonts → font-* (stacks only; no @font-face is emitted — see header) */`);
4630
5341
  for (const [name, stack] of Object.entries(ds.fonts ?? {})) theme.push(` --font-${name}: ${stack};`);
4631
5342
  theme.push(`\n /* Fluid type scale → text-* */`);
4632
5343
  for (const s of typeSteps) theme.push(` --text-${s.name}: ${s.value};`);
4633
5344
  theme.push(`\n /* Drop-shadows → shadow-* */`);
4634
5345
  for (const [name, value] of Object.entries(shadows ?? {})) theme.push(` --shadow-${name}: ${value};`);
4635
5346
  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};`);
5347
+ for (const [name, val] of BREAKPOINTS) theme.push(` --container-${name}: ${val};`);
4642
5348
  parts.push(`@theme {\n${theme.join("\n")}\n}\n`);
4643
5349
  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
5350
  if (scaleRoles.length) {
@@ -4660,8 +5366,16 @@ function emitTailwind(ctx) {
4660
5366
  const surface = scaleRoles.find((r) => r.role === "surface");
4661
5367
  if (surface) darkBlock += ` --overlay: color-mix(in oklch, var(--color-${surface.hue}-950) 60%, transparent);\n`;
4662
5368
  }
4663
- if (darkBlock) parts.push(`/* Functional role tokens (dark) */\n${DARK_SEL} {\n color-scheme: dark;\n${darkBlock}}\n`);
5369
+ if (darkBlock) {
5370
+ parts.push(`/* Functional role tokens (dark) */\n${DARK_SEL} {\n color-scheme: dark;\n${darkBlock}}\n`);
5371
+ 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`);
5372
+ }
4664
5373
  parts.push("/* State variant for the active flip (hover/focus-visible are built in). */\n@custom-variant is-active (&.is-active, &[data-active]);\n");
5374
+ {
5375
+ const roles = typography?.roles ?? {};
5376
+ const bindings = Object.entries(typography?.headings ?? {}).filter(([, r]) => roles[r]);
5377
+ 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`);
5378
+ }
4665
5379
  const util = [];
4666
5380
  if (scaleRoles.length) {
4667
5381
  util.push(`/* Role colour utilities (the public API) */`);
@@ -4677,7 +5391,6 @@ function emitTailwind(ctx) {
4677
5391
  for (const [k, v] of Object.entries(e.css ?? {})) d[k] = asStr(v);
4678
5392
  for (const [k, v] of Object.entries(e.vars ?? {})) d[`--${k}`] = asStr(v);
4679
5393
  util.push(`@utility ${name} {\n${decls(d)}\n}`);
4680
- if (e.kf !== "composite" && e.kf !== "paint") continue;
4681
5394
  const props = /* @__PURE__ */ new Set();
4682
5395
  for (const k of Object.keys(e.vars ?? {})) {
4683
5396
  const m = /^(.*)-(from|to)$/.exec(k);
@@ -4687,7 +5400,7 @@ function emitTailwind(ctx) {
4687
5400
  util.push(`@utility flip-${name} {\n${decls(flip)}\n}`);
4688
5401
  }
4689
5402
  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}");
5403
+ util.push(`@utility split-reverse {\n reading-flow: flex-visual;\n & > :first-child { order: 1; }\n}`);
4691
5404
  for (const [a, b] of [
4692
5405
  [1, 2],
4693
5406
  [2, 1],
@@ -4697,7 +5410,12 @@ function emitTailwind(ctx) {
4697
5410
  [4, 1],
4698
5411
  [2, 3],
4699
5412
  [3, 2]
4700
- ]) util.push(`@utility split-${a}-${b} {\n --_split-a: ${a};\n --_split-b: ${b};\n}`);
5413
+ ]) util.push(`@utility split-${a}-${b} {\n flex-direction: row;\n --_split-a: ${a};\n --_split-b: ${b};\n}`);
5414
+ const gaps = gapUtilities(spaceSteps);
5415
+ if (gaps.length) {
5416
+ util.push(`\n/* Gap — the fluid space scale (var(--space-<name>)) */`);
5417
+ for (const { cls, prop, value } of gaps) util.push(`@utility ${cls} {\n ${prop}: ${value};\n}`);
5418
+ }
4701
5419
  util.push(`\n/* Track placement (inside .centered) */`);
4702
5420
  for (const track of [
4703
5421
  "measure",
@@ -4705,6 +5423,28 @@ function emitTailwind(ctx) {
4705
5423
  "spotlight",
4706
5424
  "fullbleed"
4707
5425
  ]) util.push(`@utility ${track} {\n grid-column: ${track};\n}`);
5426
+ util.push(`\n/* Auto-fit grid — column count is content-driven, unlike .split */`);
5427
+ 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}");
5428
+ util.push(`\n/* Vertical-rhythm overrides (mirrors layout.css) */`);
5429
+ for (const pair of [
5430
+ "h-p",
5431
+ "p-p",
5432
+ "p-h",
5433
+ "h-h",
5434
+ "p-list",
5435
+ "list-p",
5436
+ "li-li",
5437
+ "text-media",
5438
+ "media-text"
5439
+ ]) util.push(`@utility m-${pair} {\n margin-top: calc(var(--rhythm-base) * var(--rhythm-${pair}));\n}`);
5440
+ for (const [name, mult] of [
5441
+ ["0", null],
5442
+ ["xs", "0.25"],
5443
+ ["s", "0.5"],
5444
+ ["m", "1"],
5445
+ ["l", "1.5"],
5446
+ ["xl", "2"]
5447
+ ]) util.push(`@utility m-${name} {\n margin-top: ${mult == null ? "0" : `calc(var(--rhythm-base) * ${mult})`};\n}`);
4708
5448
  parts.push(util.join("\n") + "\n");
4709
5449
  parts.push(`/* ── Structure (mirrors src/css/layout.css) ── */
4710
5450
  :root {
@@ -4727,6 +5467,11 @@ function emitTailwind(ctx) {
4727
5467
 
4728
5468
  body {
4729
5469
  container-type: inline-size;
5470
+ /* Named so \`patterns/scroll-target.css\`'s \`@container body (…)\` queries
5471
+ resolve — .toc-layout/.toc-sidebar/.toc-inline. Those partials are inlined
5472
+ into this bundle verbatim, so dropping the name (as this literal did) left
5473
+ the TOC permanently in its narrow layout in the tailwind format only. */
5474
+ container-name: body;
4730
5475
  }
4731
5476
 
4732
5477
  /* Class-level structure rules live in \`components\` so track/spacing utilities
@@ -4791,6 +5536,39 @@ body {
4791
5536
  var(--region-space-max, 8rem)
4792
5537
  );
4793
5538
  }
5539
+
5540
+ /* \`.split\` is a PATTERN, so it belongs here rather than in an \`@utility\` — that
5541
+ is what makes \`flex-col\` beat it by layer instead of by a source-order race
5542
+ whose winner depends on Tailwind's property-based sort of custom utilities.
5543
+ Its ratio/reversal modifiers stay utilities. Mirrors layout.css, where each
5544
+ rule's rationale is written out; in short: \`min-inline-size: 0\` because a flex
5545
+ item's automatic minimum is its min-content size; the ratio as a flex BASIS
5546
+ rather than a grow factor, because grow shares out only the free space and a
5547
+ child's padding is not part of it; scoped to a two-child split because a ratio
5548
+ is a pair contract and a middle child with no basis would collapse to zero;
5549
+ \`box-sizing: border-box\` because a basis only sizes the border box on a
5550
+ border-box item (Tailwind's preflight sets it, the other formats' reset does). */
5551
+ .split {
5552
+ display: flex;
5553
+ flex-direction: row;
5554
+ }
5555
+ .split > * {
5556
+ flex: 1;
5557
+ box-sizing: border-box;
5558
+ min-inline-size: 0;
5559
+ }
5560
+ .split > :first-child {
5561
+ flex-grow: var(--_split-a, 1);
5562
+ }
5563
+ .split > :last-child {
5564
+ flex-grow: var(--_split-b, 1);
5565
+ }
5566
+ .split > :first-child:nth-last-child(2) {
5567
+ flex-basis: ${splitBasis("--_split-a")};
5568
+ }
5569
+ .split > :nth-child(2):last-child {
5570
+ flex-basis: ${splitBasis("--_split-b")};
5571
+ }
4794
5572
  }
4795
5573
  `);
4796
5574
  const animPath = join(cssDir, "animation.css");
@@ -4854,9 +5632,9 @@ body {
4854
5632
  if (depth === 0) {
4855
5633
  const raw = css.slice(start, i + 1).trim();
4856
5634
  const prelude = css.slice(start, preludeEnd).replace(/\/\*[\s\S]*?\*\//g, "").trim();
4857
- const clash = /^\.([A-Za-z0-9_-]+)/.exec(prelude);
5635
+ const name = /^\.([A-Za-z0-9_-]+)/.exec(prelude)?.[1];
4858
5636
  if (/^@container\s*\(\s*min-width:/.test(prelude) && isVariantBlock(raw)) droppedVariantBlocks++;
4859
- else if (clash && TW_CLASH.has(clash[1])) droppedClashes.add(clash[1]);
5637
+ else if (name && TW_CLASH.has(name) && !CLASH_KEEP.has(name)) droppedClashes.add(name);
4860
5638
  else if (raw) kept.push(raw);
4861
5639
  start = i + 1;
4862
5640
  }
@@ -4871,25 +5649,60 @@ body {
4871
5649
  return nl(parts) + "\n";
4872
5650
  }
4873
5651
  /**
5652
+ * Partials the tailwind format does NOT inline, because it re-creates them by
5653
+ * hand: the engine (`global.css` — Tailwind's preflight covers the reset;
5654
+ * `animation.css` — read directly and wrapped in `@layer components`), and the
5655
+ * two layout partials, whose utilities are re-emitted as `@utility` and whose
5656
+ * structure is re-emitted in the `@layer components` literal.
5657
+ *
5658
+ * **Every partial mapped to `vitops.utilities` must be listed here.** Inlining a
5659
+ * utility family lands it in tailwind's `@layer components`, the exact inversion
5660
+ * the layer split exists to remove — and it fails SILENTLY, because each of those
5661
+ * classes already exists as an `@utility` that wins on layer anyway. You would get
5662
+ * ~30 dead rules and no test failure. `format-parity.test.ts` asserts the
5663
+ * implication structurally rather than spot-checking the output.
5664
+ *
5665
+ * The implication is one-way: `layout.css` is skipped while sitting in
5666
+ * `vitops.components`, because its structure is re-emitted by hand.
5667
+ */
5668
+ /**
5669
+ * Framework COMPONENTS whose class name collides with a Tailwind utility, and
5670
+ * which must therefore survive the `TW_CLASH` strip.
5671
+ *
5672
+ * The clash test matches a rule's leading `.<name>`, which is right for a
5673
+ * single-purpose utility (Tailwind ships its own) and wrong for a pattern that
5674
+ * merely shares the name. `patterns/sticky.css` opens `.sticky { … }` and was
5675
+ * being deleted from the tailwind bundle WHOLESALE — the `--_sticky-offset`
5676
+ * wiring, the z-index, and every `&.sticky--bottom`/`--inline-start` variant —
5677
+ * so a consumer writing `class="sticky sticky--bottom"` got bare
5678
+ * `position: sticky` and nothing else.
5679
+ *
5680
+ * `table` is here for the same reason but was never actually hit: `table.css`'s
5681
+ * prelude is `table,\n.table`, which doesn't start with `.`, so the regex missed
5682
+ * it by luck. Listing it makes that explicit rather than accidental.
5683
+ */
5684
+ const CLASH_KEEP = new Set(["sticky", "table"]);
5685
+ const TAILWIND_SKIP = new Set([
5686
+ "global.css",
5687
+ "animation.css",
5688
+ "layout.css",
5689
+ "layout-utilities.css",
5690
+ "utilities.css"
5691
+ ]);
5692
+ /**
4874
5693
  * 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).
5694
+ * index.css cascade order. Everything the aggregator imports EXCEPT `TAILWIND_SKIP`
5695
+ * and the generated token layer (Tailwind builds its own via @theme).
4878
5696
  */
4879
5697
  function componentPartialOrder(cssDir) {
4880
5698
  const indexPath = join(cssDir, "index.css");
4881
- const skip = new Set([
4882
- "global.css",
4883
- "animation.css",
4884
- "layout.css"
4885
- ]);
4886
5699
  const order = [];
4887
5700
  const seen = /* @__PURE__ */ new Set();
4888
5701
  const text = existsSync(indexPath) ? readFileSync(indexPath, "utf8") : "";
4889
5702
  for (const m of text.matchAll(/@import\s+['"]\.\/([^'"]+)['"]/g)) {
4890
5703
  const rel = m[1];
4891
5704
  if (rel.startsWith("generated/")) continue;
4892
- if (skip.has(rel)) continue;
5705
+ if (TAILWIND_SKIP.has(rel)) continue;
4893
5706
  if (!seen.has(rel) && existsSync(join(cssDir, rel))) {
4894
5707
  seen.add(rel);
4895
5708
  order.push(rel);
@@ -4939,9 +5752,21 @@ const CHUNK_LAYER = {
4939
5752
  "generated/shadows.css": "vitops.utilities",
4940
5753
  "generated/typography.css": "vitops.utilities",
4941
5754
  "generated/animation-effects.css": "vitops.utilities",
5755
+ "generated/spacing.css": "vitops.utilities",
5756
+ "layout-utilities.css": "vitops.utilities",
4942
5757
  "utilities.css": "vitops.utilities"
4943
5758
  };
4944
5759
  /**
5760
+ * A `.split` child's flex basis. The ratio is a BASIS rather than a grow factor
5761
+ * because grow shares out only the FREE space, which a child's padding is not
5762
+ * part of — so a padded column came out wider than its sibling by exactly its
5763
+ * horizontal padding. Must stay character-identical to layout.css; the parity
5764
+ * suite matches this expression against both.
5765
+ */
5766
+ const splitBasis = (v) => `calc(var(${v}, 1) / (var(--_split-a, 1) + var(--_split-b, 1)) * 100%)`;
5767
+ /** The layer a partial's rules are wrapped in; unmapped partials are components. */
5768
+ const layerForPartial = (rel) => CHUNK_LAYER[rel] ?? "vitops.components";
5769
+ /**
4945
5770
  * Assemble the full stylesheet by resolving index.css's @import order against the
4946
5771
  * static partials (from assets) and the freshly generated token partials (in
4947
5772
  * memory), then minify with lightningcss. No @import survives, so no disk
@@ -4970,7 +5795,7 @@ function bundleCss(generated, assetsDir, minify) {
4970
5795
  const rel = m[1];
4971
5796
  const body = rel.startsWith("generated/") ? generated[rel.slice(10)] ?? "" : readFileSync(join(cssDir, rel), "utf8");
4972
5797
  if (!body.trim()) continue;
4973
- chunks.push(`@layer ${CHUNK_LAYER[rel] ?? "vitops.components"} {\n${body}\n}`);
5798
+ chunks.push(`@layer ${layerForPartial(rel)} {\n${body}\n}`);
4974
5799
  }
4975
5800
  const merged = `/*! GENERATED by @getvitops/generator — do not edit by hand.
4976
5801
  Class reference: npx vitops docs classes · All topics: npx vitops docs */
@@ -4986,21 +5811,39 @@ function bundleCss(generated, assetsDir, minify) {
4986
5811
  map: res.map ? res.map.toString() : void 0
4987
5812
  };
4988
5813
  }
4989
- function loadConfig(input) {
4990
- const result = validate(typeof input === "string" ? JSON.parse(readFileSync(input, "utf8")) : input);
5814
+ /**
5815
+ * Read `input` as whichever config kind it is, and return the design system to
5816
+ * build from plus the site config if that is what it was.
5817
+ *
5818
+ * The design system is validated here rather than in `resolveInput` so the error
5819
+ * can name where it came from: a theme inside a site config reports its `themes`
5820
+ * path, which is the difference between "your config is wrong" and "your config
5821
+ * is wrong *here*" when the file is a few hundred lines of company facts.
5822
+ */
5823
+ function loadInput(input, opts) {
5824
+ const resolved = resolveInput(typeof input === "string" ? JSON.parse(readFileSync(input, "utf8")) : input, opts);
5825
+ const result = validate(resolved.designSystem);
4991
5826
  if (!result.ok) {
5827
+ const where = resolved.theme != null ? `design system at designSystem.themes.${resolved.theme}` : "design-system.json";
4992
5828
  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}`);
5829
+ throw new Error(`Invalid ${where}:\n${lines}`);
4994
5830
  }
4995
5831
  for (const w of result.warnings) console.warn(`[vitops] ${w}`);
4996
- return result.data;
5832
+ return {
5833
+ ds: result.data,
5834
+ ...resolved.site ? { site: resolved.site } : {}
5835
+ };
4997
5836
  }
4998
5837
  async function generate(options) {
4999
5838
  const format = options.format ?? "bricks";
5000
5839
  const outDir = options.outDir ?? "dist";
5001
5840
  const assetsDir = options.assetsDir ?? DEFAULT_ASSETS;
5002
- const ds = loadConfig(options.input);
5003
- const built = build(ds, format, assetsDir);
5841
+ const { ds, site: embedded } = loadInput(options.input, {
5842
+ ...options.theme != null ? { theme: options.theme } : {},
5843
+ ...options.siteEnv != null ? { siteEnv: options.siteEnv } : {}
5844
+ });
5845
+ const site = options.site != null ? loadSiteConfigFile(options.site, options.siteEnv) : embedded;
5846
+ const built = build(ds, format, assetsDir, { systemColorScheme: options.systemColorScheme ?? site?.designSystem?.defaultColorScheme === "system" });
5004
5847
  mkdirSync(outDir, { recursive: true });
5005
5848
  const written = [];
5006
5849
  const put = (rel, content) => {
@@ -5039,9 +5882,30 @@ async function generate(options) {
5039
5882
  const docs = generateDocs(ds, assetsDir);
5040
5883
  for (const [rel, content] of Object.entries(docs)) put(join("docs", rel), content);
5041
5884
  }
5042
- if (options.site != null && format !== "design") {
5043
- const legal = generateLegal(loadSiteConfigFile(options.site), { output: "html" });
5885
+ if (site != null && format !== "design") {
5886
+ const legal = generateLegal(site, { output: "html" });
5044
5887
  for (const [name, content] of Object.entries(legal)) put(join("legal", name), content);
5888
+ const icons = site.icons;
5889
+ if (icons?.sprite) {
5890
+ const { ui, brand, weight, semantic, sprite: _sprite, ...sets } = icons;
5891
+ const include = generateIconInclude({
5892
+ ...ui ? { ui } : {},
5893
+ ...brand ? { brand } : {},
5894
+ ...weight ? { weight } : {},
5895
+ ...semantic ? { semantic } : {},
5896
+ ...sets
5897
+ });
5898
+ const aliases = {};
5899
+ for (const name of semantic ?? []) try {
5900
+ aliases[`icon-${name}`] = resolveIcon(name, ui ?? "fa7-solid", weight ? { weight } : {});
5901
+ } catch {}
5902
+ const built = await buildIconSprite({
5903
+ include,
5904
+ aliases
5905
+ });
5906
+ put("icons.svg", built.svg);
5907
+ 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(", ")}.`);
5908
+ }
5045
5909
  }
5046
5910
  return {
5047
5911
  format,
@@ -5049,8 +5913,8 @@ async function generate(options) {
5049
5913
  written
5050
5914
  };
5051
5915
  }
5052
- function loadSiteConfigFile(input) {
5053
- return resolveSiteConfig(typeof input === "string" ? JSON.parse(readFileSync(input, "utf8")) : input);
5916
+ function loadSiteConfigFile(input, siteEnv) {
5917
+ return resolveSiteConfig(typeof input === "string" ? JSON.parse(readFileSync(input, "utf8")) : input, siteEnv);
5054
5918
  }
5055
5919
 
5056
5920
  //#endregion
@@ -5280,19 +6144,22 @@ function defaultConfig() {
5280
6144
  size: "var(--text-3xl)",
5281
6145
  weight: 700,
5282
6146
  "line-height": "1.05",
5283
- tracking: "-0.03em"
6147
+ tracking: "-0.03em",
6148
+ "text-wrap": "balance"
5284
6149
  },
5285
6150
  heading: {
5286
6151
  family: "display",
5287
6152
  size: "var(--text-xl)",
5288
6153
  weight: 600,
5289
- "line-height": "1.3"
6154
+ "line-height": "1.3",
6155
+ "text-wrap": "balance"
5290
6156
  },
5291
6157
  body: {
5292
6158
  family: "sans",
5293
6159
  size: "var(--text-m)",
5294
6160
  weight: 400,
5295
- "line-height": "1.55"
6161
+ "line-height": "1.55",
6162
+ "text-wrap": "pretty"
5296
6163
  }
5297
6164
  },
5298
6165
  headings: {
@@ -5316,7 +6183,7 @@ function defaultConfig() {
5316
6183
  journeys: {
5317
6184
  base: {
5318
6185
  fade: { "opacity-from": 0 },
5319
- slide: {}
6186
+ slide: { "translate-y-from": "var(--slide-distance, 2rem)" }
5320
6187
  },
5321
6188
  compose: [
5322
6189
  ["fade"],
@@ -5329,5 +6196,5 @@ function defaultConfig() {
5329
6196
  }
5330
6197
 
5331
6198
  //#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 };
6199
+ 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
6200
  //# sourceMappingURL=index.mjs.map