@getvitops/generator 4.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -6,6 +6,7 @@ import { createHash } from "node:crypto";
6
6
  import { transform } from "lightningcss";
7
7
  import * as z from "zod/mini";
8
8
  import { LIGHTNESS_LADDER, contrastLc, generateOklchPalette, hexToOklch, oklchStringToHex, pickOn, tintedEndpoints } from "@getvitops/utils/color";
9
+ import { AD_PLATFORMS, AD_PROVIDER_KEYS } from "@getvitops/utils/ads";
9
10
  import { generateIconInclude, nodesToHtml, resolveIcon, t } from "@getvitops/utils";
10
11
  import { getIconData, iconToSVG, replaceIDs } from "@iconify/utils";
11
12
 
@@ -190,1587 +191,1999 @@ const TW_CLASH = new Set([
190
191
  ]);
191
192
 
192
193
  //#endregion
193
- //#region src/schema.ts
194
+ //#region src/tokens.ts
194
195
  /**
195
- * Canonical `design-system.json` schema — the single source of truth.
196
+ * Palette expansion + semantic colour tokens.
196
197
  *
197
- * Authored with `zod/mini` (tree-shakeable Zod v4). Everything else derives from it:
198
- * • the `DesignSystem` TS type (`z.infer`), used throughout the generator,
199
- * • the published JSON Schema (`toJSONSchema`), emitted to `schema.json` for `$schema` refs,
200
- * • runtime validation (`validate`) for the CLI and to gate `generate`,
201
- * • the generated `authoring.md` reference doc (walks `jsonSchema`'s descriptions).
202
- */
203
- /**
204
- * Attach a JSON-Schema `description`. zod/mini has no `.describe()` method; the
205
- * mini-API equivalent is `.check(z.describe(…))`, and `.check()` clones — so a
206
- * shared sub-schema (`Scale`, `CssDecls`, …) can carry a different description
207
- * per use-site without mutating the original.
208
- */
209
- const desc = (schema, text) => schema.check(z.describe(text));
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.");
211
- const Scalar = z.union([z.string(), z.number()]);
212
- const VarMap = z.record(z.string(), Scalar);
213
- const SeededRamp = desc(z.strictObject({
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."),
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."))
216
- }), "Seeded hue: the 11-step scale is GENERATED in OKLCH from `seed` (anchors pin specific steps).");
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.");
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.");
219
- /**
220
- * A role points at a hue, and optionally declares its kind. The bare-string form
221
- * is the shorthand for a chromatic role — signal colours are the common case,
222
- * and the two or three surfaces are worth naming explicitly.
223
- */
224
- const RoleSpecSchema = z.union([desc(z.string(), "A palette hue name. Shorthand for `{ hue, kind: \"chromatic\" }`."), z.strictObject({
225
- hue: desc(z.string(), "The palette hue this role resolves to."),
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>`."))
227
- })]);
228
- const UtilityName = z.enum([
229
- "bg",
230
- "text",
231
- "icon",
232
- "border",
233
- "outline",
234
- "fill",
235
- "stroke"
236
- ]);
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."),
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.`),
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."))
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.");
242
- const Scale = z.strictObject({
243
- base: desc(z.string(), "Anchor size (a CSS length, e.g. \"1rem\") — the value at `baseStep`."),
244
- ratio: desc(z.number(), "Modular ratio between adjacent steps at large viewports."),
245
- steps: desc(z.optional(z.number()), "Token count when `names` is absent (steps are then named 1..steps)."),
246
- names: desc(z.optional(z.array(z.string())), "Step names, smallest → largest (e.g. [\"xs\",\"sm\",\"md\",…]); each becomes a token suffix."),
247
- baseStep: desc(z.optional(z.number()), "1-based index of the step whose value is `base`."),
248
- baseline: desc(z.optional(z.string()), "Named step used as the fluid pivot / GUI scale centre (defaults to `baseStep`)."),
249
- fluid: desc(z.optional(z.strictObject({
250
- minVw: desc(z.string(), "Viewport width (CSS length) where fluid scaling bottoms out."),
251
- maxVw: desc(z.string(), "Viewport width (CSS length) where fluid scaling tops out."),
252
- minRatio: desc(z.number(), "Modular ratio at/below `minVw` (usually < `ratio`).")
253
- })), "Makes the scale fluid: each step compiles to a clamp() that interpolates from `minRatio` at `minVw` to `ratio` at `maxVw`.")
254
- });
255
- const Pattern = desc(z.strictObject({
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`."),
257
- overrides: z.optional(desc(CssDecls, "Per-pattern token overrides, emitted as `--<prop>-<name>-group` values.")),
258
- element: desc(z.optional(z.string()), "Style at element level via zero-specificity `:where(<element>)` (instead of, or alongside, a class)."),
259
- class: desc(z.optional(z.string()), "Class name to emit (defaults to the pattern's key when no `element` is set). Combined with `element`, the pattern emits one zero-specificity `:where(<element>, .<class>)` rule so the class works on any tag and any explicit class overrides it."),
260
- fill: desc(z.optional(z.boolean()), "Whether this pattern is colour-filled (states/roles drive `background-color` + `on-solid` text) or text-coloured (they drive `color`). Defaults to true when `base` declares a background."),
261
- default_role: desc(z.optional(z.string()), "Semantic colour role applied to the bare/default variant."),
262
- base: z.optional(desc(CssDecls, "Base CSS declarations. Geometry properties (padding, border-radius, border, box-shadow, font-size) are wrapped in per-pattern override hooks (`--p-<name>`, `--br-<name>`, `--b-<name>`, `--ds-<name>`, `--fs-<name>`) so consumers can restyle one pattern by setting one variable.")),
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)`."),
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).")
265
- }), "One component pattern (button, link, badge, card, …): base declarations + interaction states + semantic role variants, resolved through the pattern token cascade.");
266
- const Patterns = desc(z.strictObject({
267
- defaults: z.optional(desc(CssDecls, "Cascade-wide fallback tokens, emitted as `--<prop>-default`.")),
268
- radii: z.optional(desc(CssDecls, "Shape primitives, emitted as `--br-<name>` (referenced by pattern bases).")),
269
- groups: desc(z.optional(z.record(z.string(), CssDecls)), "Group-level tokens, emitted as `--<prop>-<group>`; patterns opt in via their `group` key."),
270
- z: desc(z.optional(z.record(z.string(), z.number())), "Z-index tiers → `--z-tier-<name>`."),
271
- items: desc(z.optional(z.record(z.string(), Pattern)), "The component patterns to emit, keyed by name.")
272
- }), "Component patterns and their token cascade: `defaults` → `groups` → per-pattern `overrides`, plus shape (`radii`) and z-index primitives.");
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({
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)\")."),
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."),
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.")
278
- }), "Typography: family aliases, semantic type roles (→ `font-<role>` classes), and the bare-element → role mapping.");
279
- const AnimEffect = desc(z.strictObject({
280
- kf: desc(z.string(), "Keyframe family driving the effect: composite (transform/opacity), paint, or layout."),
281
- css: z.optional(desc(VarMap, "Extra literal declarations merged into the effect class as-is.")),
282
- vars: z.optional(desc(VarMap, "Effect endpoint variables (`--<key>: <value>`, e.g. opacity-from, translate-y-to) that override the keyframe defaults."))
283
- }), "A named animation effect class — a pure value layer (`--_anim` + `--<prop>-from/-to`) over the static keyframe engine.");
284
- const Animations = desc(z.strictObject({
285
- effects: desc(z.optional(z.record(z.string(), AnimEffect)), "Effect classes to emit (`.fade-in`, `.reveal-left`, …), keyed by class name."),
286
- journeys: desc(z.optional(z.strictObject({
287
- base: desc(z.optional(z.record(z.string(), VarMap)), "Named journey building blocks: part name → var map."),
288
- compose: desc(z.optional(z.array(z.array(z.string()))), "Combinations of base parts, each emitted as a `.<parts>-journey` class.")
289
- })), "Multi-part journey classes composed from `base` building blocks.")
290
- }), "Animation effect + journey classes (pure value layers). The animation engine itself — keyframes, drivers, floats, utilities — is static framework CSS, not configured here.");
291
- const DesignSystemSchema = z.strictObject({
292
- $schema: desc(z.optional(z.string()), "URL of the published JSON Schema (stamped by `vitops init`) so editors provide autocomplete + validation."),
293
- meta: desc(z.optional(z.strictObject({
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\"."),
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.")
296
- })), "Brand identity for agent-facing output. Consumed only by the `design` format (`DESIGN.md`); it emits no CSS and no tokens."),
297
- colors: Colors,
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."),
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."),
300
- typeScale: desc(z.optional(Scale), "Fluid modular TYPE scale → `--text-<name>` tokens, consumed by typography roles and text-size utilities."),
301
- spaceScale: desc(z.optional(Scale), "Fluid modular SPACE scale → `--space-<name>` tokens, consumed by spacing/gap utilities and vertical rhythm."),
302
- patterns: z.optional(Patterns),
303
- typography: z.optional(Typography),
304
- animations: z.optional(Animations)
305
- });
306
- const ColorsPatch = z.strictObject({
307
- palette: z.optional(z.record(z.string(), Ramp)),
308
- roles: z.optional(z.record(z.string(), RoleSpecSchema)),
309
- utilities: z.optional(z.array(UtilityName))
310
- });
311
- const DesignSystemPatchSchema = z.extend(z.partial(DesignSystemSchema), { colors: z.optional(ColorsPatch) });
312
- /**
313
- * Stable URL of the published JSON Schema. Resolves once `@getvitops/generator` is on
314
- * npm (unpkg serves the packaged `schema.json`); `vitops init` stamps this into a
315
- * scaffolded config's `$schema` so editors light up autocomplete + validation.
198
+ * A palette hue is authored one of two ways:
199
+ * • `{ seed, anchors? }` — an 11-step numeric scale (50…950) generated in
200
+ * OKLCH via @getvitops/utils/color. Lightness comes from the shared
201
+ * `LIGHTNESS_LADDER`; the seed (and any anchors) are reproduced verbatim at
202
+ * their steps.
203
+ * • `{ tones }` — a fixed brand kit: authored tones placed verbatim at their
204
+ * nearest steps + tinted endpoints; nothing else is invented (strict kits).
205
+ *
206
+ * **The grammar.** Every role gets tokens named `--color-<target>-<role>[-<variant>]`,
207
+ * where target ∈ bg | text | icon | border. Putting the target *inside* the name is
208
+ * the point: `--color-bg-danger-muted` and `--color-text-danger-muted` are distinct
209
+ * tokens, so the two axes that used to collide over one `<family>-<role>-<modifier>
210
+ * ` namespace cannot any more. The utility class is the token name minus `--color-`.
211
+ *
212
+ * Variants are ordinal — xx-muted < x-muted < muted < (bare) < bold < x-bold —
213
+ * and the tables are **sparse**: a target only emits the cells that are actually
214
+ * viable. `bold` means "more emphatic than the role's default in the current
215
+ * appearance", not "darker"; the appearance decides polarity.
216
+ *
217
+ * Roles come in two kinds (declared in `colors.roles`):
218
+ * • `surface` — a page/panel colour. Has a bare `bg-<role>` plus the full
219
+ * emphasis range and the complete text scale.
220
+ * • `chromatic` (default) — a signal colour. Backgrounds split into *tints*
221
+ * (`bg-<role>-x-muted` / `-muted`) and *solids* (`bg-<role>-solid[-bold|-x-bold]`);
222
+ * there is deliberately **no bare `bg-<role>`**, because "how loud?" is a
223
+ * question the author has to answer.
224
+ *
225
+ * Dark mode re-points which step each token reads; the solid family and the
226
+ * `text-on-<role>` foreground computed against it stay mode-stable, so a filled
227
+ * button keeps its identity across appearances.
316
228
  */
317
- const SCHEMA_URL = "https://unpkg.com/@getvitops/generator/schema.json";
318
- /** The published JSON Schema (draft-2020-12), derived from the zod schema. */
319
- const jsonSchema = {
320
- $id: SCHEMA_URL,
321
- ...z.toJSONSchema(DesignSystemSchema, { target: "draft-2020-12" })
322
- };
323
- /** Split a CSS value on top-level commas, ignoring commas nested in `rgba(…)` etc. */
324
- const splitLayers = (value) => {
325
- const out = [];
326
- let depth = 0;
327
- let start = 0;
328
- for (let i = 0; i < value.length; i++) {
329
- const c = value[i];
330
- if (c === "(") depth++;
331
- else if (c === ")") depth--;
332
- else if (c === "," && depth === 0) {
333
- out.push(value.slice(start, i));
334
- start = i + 1;
335
- }
336
- }
337
- out.push(value.slice(start));
338
- return out.map((s) => s.trim()).filter(Boolean);
339
- };
340
- const LENGTH = /^[+-]?(\d+\.?\d*|\.\d+)(px|rem|em|ch|ex|vh|vw|vmin|vmax|cm|mm|in|pt|pc|q)?$/i;
229
+ const NUMERIC_STEPS = [
230
+ "50",
231
+ "100",
232
+ "200",
233
+ "300",
234
+ "400",
235
+ "500",
236
+ "600",
237
+ "700",
238
+ "800",
239
+ "900",
240
+ "950"
241
+ ];
341
242
  /**
342
- * Why a shadow can be *valid CSS* and still render nothing: a `--shadow-<name>`
343
- * token is consumed both as a `box-shadow` (pattern geometry) and inside
344
- * `filter: drop-shadow(…)` (the `.drop-shadow-<name>` utilities, the `shadow:`
345
- * state shortcut). `drop-shadow()` accepts a *single* layer of at most three
346
- * lengths and no `inset` — a spread radius, a second comma-separated layer or an
347
- * inset keyword makes the function invalid, which drops the entire `filter`
348
- * declaration. `box-shadow` accepts all three happily, so the value looks fine
349
- * everywhere it is authored and only the utility silently goes blank.
243
+ * Lightness of each numeric step. Re-exported from the engine's ladder rather
244
+ * than restated — there used to be three copies of this curve (here, the engine,
245
+ * and the linter) and they were free to drift.
350
246
  */
351
- const dropShadowIssue = (value) => {
352
- const layers = splitLayers(value);
353
- if (layers.length > 1) return `${layers.length} comma-separated layers`;
354
- const layer = layers[0] ?? "";
355
- if (/(^|\s)inset(\s|$)/i.test(layer)) return "an `inset` keyword";
356
- const lengths = layer.replaceAll(/[\w-]*\([^()]*\)/g, " ").split(/\s+/).filter((t) => LENGTH.test(t));
357
- if (lengths.length > 3) return `a spread radius (${lengths.length} lengths)`;
247
+ const STEP_L = Object.fromEntries(NUMERIC_STEPS.map((s) => [s, LIGHTNESS_LADDER[Number(s)]]));
248
+ const isHex = (v) => v.startsWith("#");
249
+ const toOklchStr = (v) => {
250
+ if (!isHex(v)) return v;
251
+ const { l, c, h } = hexToOklch(v);
252
+ return `oklch(${(l * 100).toFixed(2)}% ${c.toFixed(4)} ${h.toFixed(2)})`;
358
253
  };
359
- const RAMP_KEYS = new Set([
360
- "seed",
361
- "anchors",
362
- "tones"
363
- ]);
254
+ const nearestStep = (l) => NUMERIC_STEPS.reduce((best, s) => Math.abs(l - STEP_L[s]) < Math.abs(l - STEP_L[best]) ? s : best);
364
255
  /**
365
- * Replace the bare "Invalid input" a failed union produces with the reason.
256
+ * Claim `step` for `value`, refusing to overwrite a step another colour already
257
+ * took.
366
258
  *
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.
259
+ * Silently dropping one of two colliding tones is the worst outcome: the palette
260
+ * still builds, and the tone the author cared about is simply gone. The record
261
+ * form (`tones: { "600": "#…", "700": "#…" }`) is the escape hatch, so the error
262
+ * points at it.
372
263
  */
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
- };
392
- /**
393
- * Validate an unknown value against the design-system schema.
394
- *
395
- * `warnings` are configurations that parse and generate, but produce output that
396
- * won't behave as authored — currently only the token-namespace collisions the
397
- * flat `--<prop>-<name>` grammar makes possible.
398
- */
399
- function validate(input) {
400
- const result = z.safeParse(DesignSystemSchema, input);
401
- if (!result.success) return {
402
- ok: false,
403
- data: void 0,
404
- errors: result.error.issues.map(explainRamp(input)),
405
- warnings: []
264
+ function claim(into, step, value, hueLabel, what) {
265
+ const taken = into[step];
266
+ if (taken != null && taken !== value) throw new Error(`palette hue "${hueLabel}": ${what} ${value} and ${taken} both map to step ${step}. Key them explicitly, e.g. { "${step}": "${taken}", "${Number(step) + 100}": "${value}" }.`);
267
+ into[step] = value;
268
+ }
269
+ function expandSeeded(input, hueLabel) {
270
+ const anchors = {};
271
+ for (const [key, value] of Object.entries(input.anchors ?? {})) claim(anchors, NUMERIC_STEPS.includes(key) ? key : nearestStep(hexToOklch(value).l), toOklchStr(value), hueLabel, "anchors");
272
+ const seedStep = nearestStep(hexToOklch(input.seed).l);
273
+ anchors[seedStep] ??= toOklchStr(input.seed);
274
+ const scale = generateOklchPalette(anchors);
275
+ const numeric = {};
276
+ for (const s of NUMERIC_STEPS) if (scale[s] != null) numeric[s] = oklchStringToHex(scale[s]);
277
+ return {
278
+ numeric,
279
+ deviations: deviationsFor(anchors)
406
280
  };
407
- const { palette, roles } = result.data.colors;
408
- for (const [role, spec] of Object.entries(roles)) {
409
- const hue = roleHue(spec);
410
- if (palette[hue] == null) return {
411
- ok: false,
412
- data: void 0,
413
- errors: [{
414
- code: "custom",
415
- path: [
416
- "colors",
417
- "roles",
418
- role
419
- ],
420
- message: `role "${role}" references unknown palette hue "${hue}"`
421
- }],
422
- warnings: []
423
- };
281
+ }
282
+ function expandTones(input, hueLabel) {
283
+ const entries = Array.isArray(input.tones) ? input.tones.map((hex) => [null, hex]) : Object.entries(input.tones).map(([k, hex]) => [k, hex]);
284
+ const placed = {};
285
+ for (const [key, hex] of entries) claim(placed, key != null && NUMERIC_STEPS.includes(key) ? key : nearestStep(hexToOklch(hex).l), hex, hueLabel, "tones");
286
+ const numeric = {};
287
+ for (const [step, hex] of Object.entries(placed)) numeric[step] = hex;
288
+ const ends = tintedEndpoints(hexToOklch(entries[0]?.[1] ?? "#808080").h);
289
+ numeric["50"] ??= ends.light;
290
+ numeric["950"] ??= ends.dark;
291
+ return {
292
+ numeric,
293
+ sparse: true,
294
+ deviations: deviationsFor(placed)
295
+ };
296
+ }
297
+ /** How far each pinned colour sits from its step's ladder lightness. */
298
+ function deviationsFor(pinned) {
299
+ const out = {};
300
+ for (const [step, value] of Object.entries(pinned)) out[step] = (isHex(value) ? hexToOklch(value).l : hexToOklch(oklchStringToHex(value)).l) - STEP_L[step];
301
+ return out;
302
+ }
303
+ /** Snap to the nearest AVAILABLE numeric step (sparse kits). */
304
+ function snap(numeric, want) {
305
+ if (numeric[want] != null) return want;
306
+ return NUMERIC_STEPS.filter((s) => numeric[s] != null).reduce((best, s) => Math.abs(STEP_L[s] - STEP_L[want]) < Math.abs(STEP_L[best] - STEP_L[want]) ? s : best);
307
+ }
308
+ /** Expand every palette hue into its numeric scale. */
309
+ function expandPalette(raw) {
310
+ const out = {};
311
+ for (const [name, entry] of Object.entries(raw)) {
312
+ const e = entry;
313
+ if (typeof e?.seed === "string") out[name] = expandSeeded(e, name);
314
+ else if (e?.tones != null) out[name] = expandTones(e, name);
315
+ else throw new Error(`palette hue "${name}" must have a "seed" or "tones"`);
424
316
  }
317
+ return out;
318
+ }
319
+ /**
320
+ * Warn about pinned colours that sit noticeably off the ladder. Not an error:
321
+ * the author asked for that exact colour and gets it. But at ~0.03 L the hue
322
+ * starts reading visibly heavier or lighter than its siblings at that step, and
323
+ * that is the class of drift the fixed ladder exists to prevent.
324
+ */
325
+ const LADDER_TOLERANCE = .03;
326
+ function ladderWarnings(palette) {
425
327
  const warnings = [];
426
- const missing = REQUIRED_ROLES.filter((r) => roles[r] == null);
427
- if (missing.length) warnings.push(`colors.roles is missing ${missing.map((r) => `"${r}"`).join(", ")} — the framework's own component CSS references ${missing.length === 1 ? "it" : "them"} with no fallback, so those components will render uncoloured. Add any role you like, but keep these defined.`);
428
- const radii = Object.keys(result.data.patterns?.radii ?? {});
429
- const items = new Set(Object.keys(result.data.patterns?.items ?? {}));
430
- for (const name of radii) if (items.has(name)) warnings.push(`patterns.radii.${name} collides with the "${name}" pattern on --br-${name}; the pattern's override hook wins — rename the radius`);
431
- for (const [name, value] of Object.entries(result.data.shadows ?? {})) {
432
- const issue = dropShadowIssue(value);
433
- if (issue) warnings.push(`shadows.${name} carries ${issue}, which \`filter: drop-shadow()\` rejects — \`.drop-shadow-${name}\` will render no shadow at all (the token still works as a \`box-shadow\`). Use one layer of at most three lengths.`);
328
+ for (const [hue, expanded] of Object.entries(palette)) for (const [step, deviation] of Object.entries(expanded.deviations ?? {})) {
329
+ if (Math.abs(deviation) <= .03) continue;
330
+ const dir = deviation > 0 ? "lighter" : "darker";
331
+ warnings.push(`colors.palette.${hue}: the colour pinned at step ${step} is ${Math.abs(deviation).toFixed(3)} ${dir} than the shared lightness ladder — this hue will read ${dir} than others at ${step}. Legal, but move it to a neighbouring step if that wasn't intended.`);
434
332
  }
435
- return {
436
- ok: true,
437
- data: result.data,
438
- errors: [],
439
- warnings
440
- };
333
+ return warnings;
441
334
  }
442
-
443
- //#endregion
444
- //#region src/config.ts
445
335
  /**
446
- * The top-level configuration schema — the umbrella around a
447
- * `design-system.json`-compliant `designSystem`.
448
- *
449
- * **Three sections, one document.** A config is `{ designSystem, organization,
450
- * site }`:
451
- *
452
- * - `designSystem` — the token set (named themes + which theme/appearance).
453
- * - `organization` — the company: who it is, where it is, how to reach it, what
454
- * it sells. Facts that stay true if the site is rebuilt from scratch.
455
- * - `site` — this published site: locales, domains, environments, SEO,
456
- * analytics, legal, icons, deployment. Facts about *a* presentation of the
457
- * organization.
458
- *
459
- * The split is not cosmetic. The flat predecessor (`SiteConfig`) held the company
460
- * and the deployment as peers, so no single noun described it — and the
461
- * multi-site case was inexpressible. With three sections, several sites can share
462
- * one `organization` and override only `site`.
463
- *
464
- * Authored with `zod/mini` (same instance the design-system schema uses), so the
465
- * two compose: `DesignSystemSchema` is embedded as `designSystem.themes.<theme>` and the
466
- * whole thing serialises to one JSON Schema via `z.toJSONSchema`. Everything a
467
- * generator needs derives from here: the `Config` type (`z.infer`), the
468
- * published JSON Schema (`configJsonSchema`), and runtime validation (`validateConfig`).
469
- *
470
- * Pure: no YAML/env/fs. `resolveConfig` takes an already-parsed object; a
471
- * thin loader (reading YAML, injecting `SITE_ENV`) lives outside this module.
336
+ * Gamut mapping nudges lightness by a hair, so an exact `<` comparison would
337
+ * report rounding as an inversion. Well below any deviation a person could see.
472
338
  */
473
- const LocalizableSchema = desc(z.union([z.string(), z.record(z.string(), z.string())]), "A string, or a per-locale map of strings (`{ en: \"…\", fr: \"…\" }`).");
474
- const ImageRefSchema = z.union([z.string(), z.object({
475
- src: z.string(),
476
- alt: z.string(),
477
- width: z.optional(z.number()),
478
- height: z.optional(z.number())
479
- })]);
480
- const AttrValue = z.union([
481
- z.string(),
482
- z.number(),
483
- z.boolean()
484
- ]);
485
- const AttrsSchema = z.optional(z.record(z.string(), AttrValue));
486
- const IsoDate = z.string().check(z.regex(/^\d{4}-\d{2}-\d{2}$/));
487
- const Iso4217 = z.string().check(z.regex(/^[A-Z]{3}$/));
488
- const DayOfWeek = z.enum([
489
- "Monday",
490
- "Tuesday",
491
- "Wednesday",
492
- "Thursday",
493
- "Friday",
494
- "Saturday",
495
- "Sunday"
496
- ]);
497
- const DesignSystemEntry = z.extend(DesignSystemPatchSchema, {
498
- 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`)."),
499
- extends: desc(z.optional(z.string()), "Another `themes` key to inherit from; this entry then only supplies what it overrides.")
500
- });
339
+ const MONOTONIC_EPSILON = .001;
501
340
  /**
502
- * The design system as a whole: the named themes plus the facts that describe the
503
- * system rather than any one theme.
341
+ * Ramps whose lightness does not fall monotonically from 50 to 950.
504
342
  *
505
- * `themes` is nested rather than being the block itself because the block used to
506
- * BE the map — every key was a theme name, so there was nowhere to put
507
- * system-wide metadata without it colliding with a theme of that name.
343
+ * A ramp built from the ladder alone cannot invert — only pinned colours (a seed,
344
+ * an `anchor`, a `tones` entry) can, because they are the one thing allowed off
345
+ * it. `ladderWarnings` will not catch it: it compares each pinned step against
346
+ * *its own* ladder rung, never against its neighbours, so it reports the wrong
347
+ * defect ("reads darker than others at 600") while the real problem is that 600
348
+ * is darker than 700. And since `LADDER_TOLERANCE` is 0.03 — the same size as the
349
+ * ladder's own 50→100 gap — two anchors can each sit within tolerance and still
350
+ * invert, with no warning at all.
508
351
  *
509
- * Note the two axes here are independent, and deliberately so. `defaultTheme`
510
- * picks a *theme* (an authored design system); `defaultColorScheme` picks an
511
- * *appearance* (light/dark), which every theme already has because the flip is
512
- * derived from one ramp rather than authored. "Theme `elegant`, appearance
513
- * follows the OS" has to be sayable, and a single union could not express it.
352
+ * This is an error rather than a warning because the rest of the system assumes
353
+ * the order: `snap` picks the nearest step by ladder lightness, the mode-stable
354
+ * solid family hard-codes 600/700/800 as increasingly dark, and both dark tables
355
+ * re-point steps on the same assumption. An inverted ramp therefore runs hover
356
+ * states backwards — the failure the fixed ladder was introduced to eliminate.
514
357
  */
515
- const DesignSystemBlock = desc(z.strictObject({
516
- 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."),
517
- 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."),
518
- defaultColorScheme: desc(z.optional(z.enum([
519
- "light",
520
- "dark",
521
- "system"
522
- ])), "Initial appearance. `\"system\"` follows the OS via `prefers-color-scheme` and is what makes `<wc-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.")
523
- }), "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: … }`.");
524
- const LocaleSchema = z.object({
525
- name: desc(z.string(), "Display name of the locale (e.g. \"English\")."),
526
- tagline: desc(z.optional(z.string()), "Site tagline in this locale."),
527
- basePath: desc(z.optional(z.string()), "URL prefix for this locale (e.g. \"/fr\").")
528
- });
529
- const HttpRedirect = z.union([
530
- z.literal(301),
531
- z.literal(302),
532
- z.literal(307),
533
- z.literal(308)
534
- ]);
535
- const DomainAliasSchema = z.object({
536
- domain: z.string(),
537
- redirectType: HttpRedirect,
538
- redirectTo: z.string(),
539
- environment: z.optional(z.string())
540
- });
541
- const PostalAddressSchema = desc(z.object({
542
- streetAddress: z.string(),
543
- addressLocality: desc(z.string(), "City."),
544
- addressRegion: desc(z.optional(z.string()), "State / province."),
545
- postalCode: z.optional(z.string()),
546
- addressCountry: desc(z.string(), "Country (ISO 3166-1 alpha-2 preferred).")
547
- }), "schema.org `PostalAddress` field names, so JSON-LD generation is lossless.");
548
- const ContactObjectSchema = z.object({
549
- name: z.optional(z.string()),
550
- email: z.optional(z.email()),
551
- phone: z.optional(z.string()),
552
- address: z.optional(PostalAddressSchema)
553
- });
554
- const ContactConfigSchema = z.union([z.string(), ContactObjectSchema]);
555
- const GeoSchema = z.object({
556
- latitude: z.number().check(z.gte(-90), z.lte(90)),
557
- longitude: z.number().check(z.gte(-180), z.lte(180))
558
- });
559
- /** schema.org `OpeningHoursSpecification` — per-day open/close (24h `HH:MM`). */
560
- const OpeningHoursSchema = z.object({
561
- dayOfWeek: z.array(DayOfWeek),
562
- opens: z.string().check(z.regex(/^\d{2}:\d{2}$/)),
563
- closes: z.string().check(z.regex(/^\d{2}:\d{2}$/))
564
- });
565
- const LocalBusinessType = z.enum([
566
- "LocalBusiness",
567
- "Store",
568
- "Restaurant",
569
- "CafeOrCoffeeShop",
570
- "ProfessionalService",
571
- "MedicalBusiness",
572
- "HealthAndBeautyBusiness",
573
- "HomeAndConstructionBusiness",
574
- "AutomotiveBusiness",
575
- "FinancialService",
576
- "LegalService",
577
- "RealEstateAgent",
578
- "Organization"
579
- ]);
580
- const LocationSchema = z.object({
581
- slug: z.optional(LocalizableSchema),
582
- name: LocalizableSchema,
583
- email: z.optional(z.email()),
584
- phone: z.optional(z.string()),
585
- address: z.optional(PostalAddressSchema),
586
- geo: z.optional(GeoSchema),
587
- hours: z.optional(z.array(OpeningHoursSchema)),
588
- type: z.optional(LocalBusinessType),
589
- description: z.optional(LocalizableSchema),
590
- areaServed: z.optional(z.array(z.string())),
591
- priceRange: z.optional(z.string()),
592
- paymentAccepted: z.optional(z.array(z.string())),
593
- currenciesAccepted: z.optional(Iso4217),
594
- knowsLanguage: z.optional(z.array(z.string()))
595
- });
596
- const OrganizationSchema = z.object({
597
- name: desc(z.optional(LocalizableSchema), "Trading name, as a reader would recognise it (e.g. \"Acme\"). Localisable."),
598
- legalName: desc(z.optional(z.string()), "Registered legal name (e.g. \"Acme Widgets Inc.\"). The generated legal documents prefer this over `name` — a policy is a statement by the legal entity."),
599
- foundingDate: desc(z.optional(IsoDate), "Date the organization was founded (YYYY-MM-DD)."),
600
- logo: desc(z.optional(ImageRefSchema), "Organization logo → JSON-LD `logo`."),
601
- email: desc(z.optional(z.email()), "General contact address. Also the last fallback for privacy requests — see `contact`."),
602
- phone: desc(z.optional(z.string()), "General contact telephone number."),
603
- address: desc(z.optional(PostalAddressSchema), "Registered/mailing address. Its `addressRegion` and `addressCountry` are what the governing-law clause of the generated terms of service defaults to."),
604
- taxID: desc(z.optional(z.string()), "Tax identification number."),
605
- vatID: desc(z.optional(z.string()), "VAT identification number."),
606
- sameAs: desc(z.optional(z.array(z.url())), "Profile URLs → JSON-LD `sameAs` (mirrors `links`).")
607
- });
608
- const DnsRecordType = z.enum([
609
- "A",
610
- "AAAA",
611
- "CNAME",
612
- "TXT",
613
- "MX",
614
- "NS",
615
- "SRV",
616
- "CAA",
617
- "PTR",
618
- "SOA"
358
+ function monotonicityErrors(palette) {
359
+ const errors = [];
360
+ for (const [hue, expanded] of Object.entries(palette)) {
361
+ const numeric = expanded.numeric ?? {};
362
+ const steps = NUMERIC_STEPS.filter((s) => numeric[s] != null);
363
+ for (let i = 1; i < steps.length; i++) {
364
+ const [prev, step] = [steps[i - 1], steps[i]];
365
+ const lPrev = hexToOklch(numeric[prev]).l;
366
+ const lStep = hexToOklch(numeric[step]).l;
367
+ if (lStep < lPrev + MONOTONIC_EPSILON) continue;
368
+ const pinned = expanded.deviations ?? {};
369
+ const culprits = [prev, step].filter((s) => pinned[s] != null);
370
+ const fix = culprits.length ? culprits.map((s) => {
371
+ const l = hexToOklch(numeric[s]).l;
372
+ return `${numeric[s]} (pinned at ${s}) sits at L ${l.toFixed(3)}, nearest step ${nearestStep(l)}`;
373
+ }).join("; ") : `no step here is pinned, so this is a bug in the generator rather than in your config`;
374
+ 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}.`);
375
+ }
376
+ }
377
+ return errors;
378
+ }
379
+ /**
380
+ * Surface roles: a bare `bg` (cards, panels, inputs), `bg-muted` for the page
381
+ * behind them, `bg-x-muted` for wells, and `bg-bold`/`-x-bold` for the inverse
382
+ * surface a tooltip sits on. Elevation is expressed by *which* token you reach
383
+ * for — page `bg-muted`, card `bg` — rather than by a raised/sunken pair. That
384
+ * is what lets a future `data-surfaces` axis flatten it without touching markup.
385
+ */
386
+ const SURFACE_LIGHT = {
387
+ bg: "50",
388
+ "bg-muted": "100",
389
+ "bg-x-muted": "200",
390
+ "bg-bold": "800",
391
+ "bg-x-bold": "950",
392
+ text: "900",
393
+ "text-bold": "950",
394
+ "text-muted": "700",
395
+ "text-x-muted": "500",
396
+ "text-xx-muted": "400",
397
+ "text-on-bold": "50",
398
+ icon: "600",
399
+ "icon-muted": "500",
400
+ "border-muted": "200",
401
+ border: "300",
402
+ "border-bold": "600"
403
+ };
404
+ const SURFACE_DARK = {
405
+ bg: "900",
406
+ "bg-muted": "950",
407
+ "bg-x-muted": "950",
408
+ "bg-bold": "200",
409
+ "bg-x-bold": "50",
410
+ text: "100",
411
+ "text-bold": "50",
412
+ "text-muted": "300",
413
+ "text-x-muted": "500",
414
+ "text-xx-muted": "600",
415
+ "text-on-bold": "950",
416
+ icon: "400",
417
+ "icon-muted": "500",
418
+ "border-muted": "800",
419
+ border: "700",
420
+ "border-bold": "400"
421
+ };
422
+ /**
423
+ * Chromatic roles: tints for alert/badge backgrounds, solids for fills. No bare
424
+ * `bg` — see the module docblock. No `text-muted` either: a de-emphasised
425
+ * coloured text could not hold its contrast target off a white surface, so the
426
+ * way to soften coloured text is weight/size, not another colour.
427
+ */
428
+ const CHROMATIC_LIGHT = {
429
+ "bg-x-muted": "50",
430
+ "bg-muted": "100",
431
+ text: "700",
432
+ "text-bold": "900",
433
+ icon: "600",
434
+ border: "200",
435
+ "border-bold": "300"
436
+ };
437
+ const CHROMATIC_DARK = {
438
+ "bg-x-muted": "950",
439
+ "bg-muted": "900",
440
+ text: "300",
441
+ "text-bold": "200",
442
+ icon: "400",
443
+ border: "800",
444
+ "border-bold": "700"
445
+ };
446
+ /**
447
+ * The solid family — mode-stable, so a filled button keeps its identity when the
448
+ * appearance flips. Fixed steps rather than the old scan-and-clamp: with a shared
449
+ * lightness ladder every hue's 600 already sits at the same lightness, so there
450
+ * is nothing left to search for. `text-on-<role>` is computed against `bg-solid`.
451
+ */
452
+ const SOLID = {
453
+ "bg-solid": "600",
454
+ "bg-solid-bold": "700",
455
+ "bg-solid-x-bold": "800"
456
+ };
457
+ /**
458
+ * Map a token key to its CSS custom property for a given role.
459
+ *
460
+ * `on` is the one irregular case: the foreground for a filled surface reads
461
+ * `--color-text-on-<role>`, with the role *after* `on`, because it names what it
462
+ * sits on rather than what it is.
463
+ *
464
+ * The utility class name is this minus the `--color-` prefix, which is why the
465
+ * emitter and the linter can both derive their vocabulary from one function.
466
+ */
467
+ function tokenVar(role, key) {
468
+ const dash = key.indexOf("-");
469
+ const target = dash === -1 ? key : key.slice(0, dash);
470
+ const variant = dash === -1 ? "" : key.slice(dash + 1);
471
+ if (variant === "on") return `--color-${target}-on-${role}`;
472
+ if (variant.startsWith("on-")) return `--color-${target}-on-${role}-${variant.slice(3)}`;
473
+ return variant ? `--color-${target}-${role}-${variant}` : `--color-${target}-${role}`;
474
+ }
475
+ /** The class name a token key produces for a role (`bg-danger-solid`, …). */
476
+ const tokenClass = (role, key) => tokenVar(role, key).slice(8);
477
+ /** Resolve the token set for one role over its hue. */
478
+ function functionalRole(role, hueName, hue, kind = "chromatic") {
479
+ const numeric = hue.numeric;
480
+ const v = (n) => `var(--color-${hueName}-${snap(numeric, n)})`;
481
+ const hex = (n) => numeric[snap(numeric, n)];
482
+ const map = (table) => {
483
+ const out = {};
484
+ for (const [token, step] of Object.entries(table)) out[token] = v(step);
485
+ return out;
486
+ };
487
+ const solids = map(SOLID);
488
+ const onSolid = pickOn(hex(SOLID["bg-solid"]));
489
+ return {
490
+ role,
491
+ hue: hueName,
492
+ kind,
493
+ light: {
494
+ ...map(kind === "surface" ? SURFACE_LIGHT : CHROMATIC_LIGHT),
495
+ ...solids,
496
+ "text-on": onSolid
497
+ },
498
+ dark: {
499
+ ...map(kind === "surface" ? SURFACE_DARK : CHROMATIC_DARK),
500
+ ...solids,
501
+ "text-on": onSolid
502
+ }
503
+ };
504
+ }
505
+ /**
506
+ * APCA targets. `TEXT` is the body-text bar; `SECONDARY` covers text on a
507
+ * non-primary surface and de-emphasised text. `NON_TEXT` is the icon/boundary
508
+ * tier — the APCA analogue of WCAG's 3:1, where a glyph may run more vivid than
509
+ * text because shape carries meaning alongside contrast.
510
+ */
511
+ const CONTRAST = {
512
+ TEXT: 75,
513
+ SECONDARY: 60,
514
+ NON_TEXT: 45
515
+ };
516
+ /**
517
+ * Text tokens deliberately exempt from the contract. Placeholders and disabled
518
+ * text are required to look unavailable; holding them to the body-text bar would
519
+ * defeat the affordance. Nothing else may be exempt.
520
+ */
521
+ const EXEMPT_TEXT = new Set(["text-x-muted", "text-xx-muted"]);
522
+ /**
523
+ * Verify a role's contrast targets. Returns human-readable failures.
524
+ *
525
+ * Chromatic roles are checked against the surface planes they actually sit on
526
+ * (`surfaceBg`) as well as their own tints — coloured text almost never appears
527
+ * over its own solid, it appears over the page. Checking only within the role
528
+ * would have missed the pairing that matters most.
529
+ */
530
+ function checkContrast(fr, numericOf, surfaceBg) {
531
+ const failures = [];
532
+ const resolve = (val) => {
533
+ const m = /^var\(--color-([a-z0-9-]+)-(\d+)\)$/.exec(val);
534
+ if (!m) return val;
535
+ return numericOf(m[1])[m[2]] ?? val;
536
+ };
537
+ const fail = (appearance, msg) => failures.push(`${fr.role}/${appearance}: ${msg}`);
538
+ for (const [appearance, tokens] of [["light", fr.light], ["dark", fr.dark]]) {
539
+ const ownBg = Object.keys(tokens).filter((k) => k === "bg" || k.startsWith("bg-")).filter((k) => !k.startsWith("bg-solid")).filter((k) => !k.startsWith("bg-bold") && !k.startsWith("bg-x-bold"));
540
+ const planes = fr.kind === "surface" ? ownBg.map((k) => [k, resolve(tokens[k])]) : [...(surfaceBg?.[appearance] ?? []).map((h, i) => [`surface#${i}`, h]), ...ownBg.map((k) => [k, resolve(tokens[k])])];
541
+ for (const [planeName, bg] of planes) {
542
+ const textMin = planeName === "bg" ? CONTRAST.TEXT : CONTRAST.SECONDARY;
543
+ for (const key of [
544
+ "text",
545
+ "text-bold",
546
+ "text-muted"
547
+ ]) {
548
+ if (tokens[key] == null || EXEMPT_TEXT.has(key)) continue;
549
+ const min = key === "text-muted" ? CONTRAST.SECONDARY : textMin;
550
+ const lc = contrastLc(resolve(tokens[key]), bg);
551
+ if (lc < min) fail(appearance, `${key} Lc ${lc.toFixed(1)} < ${min} on ${planeName} ${bg}`);
552
+ }
553
+ const nonText = fr.kind === "surface" ? ["icon", "border-bold"] : ["icon"];
554
+ for (const key of nonText) {
555
+ if (tokens[key] == null) continue;
556
+ const lc = contrastLc(resolve(tokens[key]), bg);
557
+ if (lc < CONTRAST.NON_TEXT) fail(appearance, `${key} Lc ${lc.toFixed(1)} < ${CONTRAST.NON_TEXT} on ${planeName} ${bg}`);
558
+ }
559
+ }
560
+ const solid = resolve(tokens["bg-solid"]);
561
+ const onLc = contrastLc(tokens["text-on"], solid);
562
+ if (onLc < CONTRAST.SECONDARY) fail(appearance, `text-on Lc ${onLc.toFixed(1)} < ${CONTRAST.SECONDARY} on bg-solid`);
563
+ if (tokens["bg-bold"] != null && tokens["text-on-bold"] != null) {
564
+ const lc = contrastLc(resolve(tokens["text-on-bold"]), resolve(tokens["bg-bold"]));
565
+ if (lc < CONTRAST.TEXT) fail(appearance, `text-on-bold Lc ${lc.toFixed(1)} < ${CONTRAST.TEXT} on bg-bold`);
566
+ }
567
+ }
568
+ return failures;
569
+ }
570
+
571
+ //#endregion
572
+ //#region src/token-refs.ts
573
+ /**
574
+ * Cross-layer check: does every `var(--…)` a config authors resolve to a token
575
+ * the generator actually emits?
576
+ *
577
+ * A `patterns` value is a string the schema deliberately does not interpret
578
+ * (see `CssDecls` in `schema.ts`), so a reference to a token that does not exist
579
+ * parses, generates, minifies and ships. The declaration then resolves to
580
+ * `inherit`/`unset` in the browser and nothing anywhere says so. Downstream this
581
+ * shipped a `.cta` whose `color` fell back to `inherit` on a brand fill —
582
+ * unreadable text on every filled button — and two of the four dead references
583
+ * in that config had been dead for several versions.
584
+ *
585
+ * **The check is anchored to namespaces the generator owns**, exactly as
586
+ * `vitops lint` is anchored to classes derived from the consumer's own config.
587
+ * A pattern may legitimately reference a framework token defined in
588
+ * `@getvitops/core`'s hand-written CSS (`--icon-size`, `--animation-duration`,
589
+ * a consumer's own hook), and `validate()` is a pure function that must not read
590
+ * the filesystem to find out. Enumerating "every valid token" would therefore
591
+ * turn every such reference into a false positive. Enumerating only what the
592
+ * generator emits — the colour layer, shadows, z-tiers — is a set it can compute
593
+ * from the config alone, and it is where the dangling references actually occur.
594
+ *
595
+ * `FRAMEWORK_OWNED` is the one leak in that reasoning: a token that sits inside
596
+ * an owned prefix but is defined by core's CSS instead. `token-refs.test.ts`
597
+ * greps the shipped partials and fails if a new one appears, so the allowlist
598
+ * cannot silently go stale.
599
+ *
600
+ * A reference carrying a fallback (`var(--x, 0.25rem)`) is **never** flagged: the
601
+ * fallback is the author stating what happens when the token is absent, which is
602
+ * the whole difference between a considered default and an accident.
603
+ */
604
+ /**
605
+ * Prefixes whose entire contents the generator emits. A `var()` inside one of
606
+ * these either resolves to something the colour/shadow/z layer defines, or it
607
+ * resolves to nothing — there is no third possibility, which is what makes an
608
+ * error (rather than a warning) the right severity.
609
+ */
610
+ const OWNED_PREFIXES = [
611
+ "--color-",
612
+ "--shadow-",
613
+ "--z-tier-"
614
+ ];
615
+ /** Singleton tokens the colour layer emits, checked by exact name. */
616
+ const OWNED_EXACT = new Set(["--surface-glass", "--overlay"]);
617
+ /**
618
+ * Tokens inside an owned prefix that `@getvitops/core`'s hand-written CSS
619
+ * defines rather than the generator. Drift-guarded by `token-refs.test.ts`.
620
+ */
621
+ const FRAMEWORK_OWNED = new Set(["--color-swatch-size"]);
622
+ /**
623
+ * Roles whose name says "this is a surface" while the bare-string shorthand
624
+ * says `chromatic`. The two kinds emit *different token sets* — a chromatic role
625
+ * has no bare `bg`, so `--color-bg-surface` and every `.bg-surface` utility
626
+ * simply cease to exist, taking 36 references in the framework's own CSS with
627
+ * them. Naming one of these and getting the chromatic set is almost always a
628
+ * mistake, and it is the exact one a downstream config made.
629
+ */
630
+ const SURFACE_SHAPED = new Set([
631
+ "surface",
632
+ "neutral",
633
+ "background",
634
+ "page",
635
+ "canvas"
619
636
  ]);
620
- const DnsRecordSchema = z.object({
621
- type: DnsRecordType,
622
- name: z.optional(z.string()),
623
- value: z.string(),
624
- ttl: z.optional(z.number().check(z.int(), z.positive())),
625
- proxied: z.optional(z.boolean())
626
- });
627
- const DnsDomainSchema = z.object({
628
- provider: z.optional(z.string()),
629
- nameservers: z.optional(z.array(z.string())),
630
- records: z.optional(z.array(DnsRecordSchema))
631
- });
632
637
  /**
633
- * One domain to onboard into Google Search Console via `vitops search setup`.
638
+ * The 1.0 colour-grammar rename, as data.
639
+ *
640
+ * Old suffix (on `--<role>-<suffix>`) → new token key (fed through `tokenVar`).
641
+ * Transcribed from the migration table in the 1.0 changelog; `surface` rotates
642
+ * separately below because its rename is value-preserving rather than
643
+ * mechanical. `--color-<role>-<variant>` "stops" are deliberately absent — the
644
+ * changelog says to judge those by use, and they already land in the owned
645
+ * `--color-` namespace, so the generic check catches them with a near-miss hint.
646
+ */
647
+ const MOVED_SUFFIXES = {
648
+ bg: "bg-x-muted",
649
+ "bg-muted": "bg-muted",
650
+ solid: "bg-solid",
651
+ "solid-bold": "bg-solid-bold",
652
+ "solid-x-bold": "bg-solid-x-bold",
653
+ "on-solid": "text-on",
654
+ "on-solid-bold": "text-on-bold",
655
+ text: "text",
656
+ "text-muted": "text-muted",
657
+ "text-x-muted": "text-x-muted",
658
+ "text-xx-muted": "text-xx-muted",
659
+ "text-bold": "text-bold",
660
+ icon: "icon",
661
+ "icon-muted": "icon-muted",
662
+ border: "border",
663
+ "border-muted": "border-muted",
664
+ "border-bold": "border-bold"
665
+ };
666
+ /**
667
+ * `surface`'s background names rotate rather than map: what was `--surface-bg`
668
+ * (the page) is now `--color-bg-surface-muted`, and what was `--surface-bg-bold`
669
+ * (raised) is now `--color-bg-surface`. Applying the chromatic rule to it would
670
+ * silently move the page background two rungs.
671
+ */
672
+ const SURFACE_BG_ROTATION = {
673
+ bg: "bg-muted",
674
+ "bg-bold": "bg",
675
+ "bg-muted": "bg-x-muted"
676
+ };
677
+ /**
678
+ * Old-grammar token name → its 1.0 replacement, for the roles this config
679
+ * declares.
680
+ *
681
+ * Built per-config rather than as a static table because the old names embed
682
+ * the *consumer's own* role names — there is no fixed list to hard-code, which
683
+ * is why nothing detected these before.
684
+ */
685
+ function movedTokens(roles) {
686
+ const out = {};
687
+ for (const [role, spec] of Object.entries(roles)) {
688
+ const rotate = roleKind(spec) === "surface";
689
+ for (const [old, key] of Object.entries(MOVED_SUFFIXES)) out[`--${role}-${old}`] = tokenVar(role, rotate && SURFACE_BG_ROTATION[old] || key);
690
+ if (rotate) for (const [old, key] of Object.entries(SURFACE_BG_ROTATION)) out[`--${role}-${old}`] = tokenVar(role, key);
691
+ }
692
+ return out;
693
+ }
694
+ /**
695
+ * Extract every `var(--name[, fallback])` from a CSS value.
634
696
  *
635
- * The verification token is fetched live from the Site Verification API at run
636
- * time and written to DNS — it is never stored here. What the config records is
637
- * the *intent*: which domains to onboard, and who should co-own them.
697
+ * Hand-rolled rather than a regex because the fallback has to be detected at the
698
+ * reference's *own* paren depth: in `var(--a, var(--b))` the outer reference has
699
+ * a fallback and the inner one does not, and a regex that stops at the first `)`
700
+ * gets both wrong.
638
701
  */
639
- const SearchConsoleSetupSchema = z.object({
640
- delegatedOwners: desc(z.optional(z.array(z.email())), "Emails added as verification owners on the Site Verification web resource. Automated via the API — additive and idempotent, never removes an existing owner."),
641
- fullUserGroup: desc(z.optional(z.email()), "A Google Group to grant Full-User access in Search Console. SURFACED AS A REMINDER only: Search Console exposes no user/permission API, so this step stays manual by design.")
642
- });
643
- const RobotsSchema = z.string();
644
- const EnvironmentSchema = z.object({
645
- url: desc(z.url(), "Public origin of this environment."),
646
- api: desc(z.optional(z.url()), "API origin, when different from `url`."),
647
- analytics: desc(z.optional(z.boolean()), "Whether analytics fire in this environment."),
648
- robots: desc(z.optional(RobotsSchema), "Robots policy (e.g. \"noindex,nofollow\" for dev)."),
649
- variant: desc(z.optional(z.string()), "Active `abTesting.variants` key for this environment.")
650
- });
651
- const AbVariantSchema = z.object({
652
- environment: z.string(),
653
- description: z.optional(z.string()),
654
- overrides: z.optional(z.record(z.string(), z.unknown()))
655
- });
656
- const FontProvider = z.enum([
657
- "google",
658
- "fontsource",
659
- "adobe",
660
- "bunny",
661
- "fontshare",
662
- "googleicons",
663
- "npm",
664
- "local"
665
- ]);
666
- const FontStyle = z.enum(["normal", "italic"]);
667
- const FontDisplay = z.enum([
668
- "auto",
669
- "block",
670
- "swap",
671
- "fallback",
672
- "optional"
673
- ]);
674
- const FontWeight = z.union([z.number(), z.string()]);
675
- /** A local-font face (`provider: 'local'`). */
676
- const FontVariantSchema = z.object({
677
- src: z.array(z.string()),
678
- weight: z.optional(FontWeight),
679
- style: z.optional(FontStyle),
680
- unicodeRange: z.optional(z.array(z.string())),
681
- display: z.optional(FontDisplay)
682
- });
683
- const SiteFontSchema = z.object({
684
- name: z.string(),
685
- provider: FontProvider,
686
- cssVariable: z.string().check(z.regex(/^--/)),
687
- weights: z.optional(z.array(FontWeight)),
688
- styles: z.optional(z.array(FontStyle)),
689
- subsets: z.optional(z.array(z.string())),
690
- fallbacks: z.optional(z.array(z.string())),
691
- optimizedFallbacks: z.optional(z.boolean()),
692
- display: z.optional(FontDisplay),
693
- formats: z.optional(z.array(z.string())),
694
- unicodeRange: z.optional(z.array(z.string())),
695
- variants: z.optional(z.array(FontVariantSchema)),
696
- preload: desc(z.optional(z.union([z.boolean(), z.array(z.object({
697
- weight: FontWeight,
698
- style: z.optional(FontStyle)
699
- }))])), "Emit `<Font preload />` for this family (true, or a list of weight/style faces).")
700
- });
701
- const PresentSchema = z.optional(z.record(z.string(), z.unknown()));
702
- const NavItemSchema = z.object({
703
- label: z.optional(LocalizableSchema),
704
- image: z.optional(ImageRefSchema),
705
- href: z.optional(z.string()),
706
- icon: z.optional(z.string()),
707
- attrs: AttrsSchema,
708
- itemAttrs: AttrsSchema,
709
- submenu: z.optional(z.object({
710
- present: PresentSchema,
711
- get items() {
712
- return z.optional(z.array(NavItemSchema));
702
+ function extractVarRefs(value, path) {
703
+ const out = [];
704
+ for (let i = value.indexOf("var("); i !== -1; i = value.indexOf("var(", i + 1)) {
705
+ let j = i + 4;
706
+ while (j < value.length && /\s/.test(value[j])) j++;
707
+ const start = j;
708
+ while (j < value.length && /[\w-]/.test(value[j])) j++;
709
+ const name = value.slice(start, j);
710
+ if (!name.startsWith("--")) continue;
711
+ let depth = 0;
712
+ let fallback = false;
713
+ for (let k = j; k < value.length; k++) {
714
+ const c = value[k];
715
+ if (c === "(") depth++;
716
+ else if (c === ")") {
717
+ if (depth === 0) break;
718
+ depth--;
719
+ } else if (c === "," && depth === 0) fallback = true;
713
720
  }
714
- }))
715
- });
716
- const FormFieldType = z.enum([
717
- "text",
718
- "email",
719
- "tel",
720
- "url",
721
- "number",
722
- "password",
723
- "textarea",
724
- "select",
725
- "checkbox",
726
- "radio",
727
- "date",
728
- "file",
729
- "hidden"
730
- ]);
731
- const FormFieldSchema = z.object({
732
- name: z.string(),
733
- type: FormFieldType,
734
- label: z.optional(LocalizableSchema),
735
- required: z.optional(z.boolean()),
736
- validation: z.optional(z.string()),
737
- options: z.optional(z.union([z.string(), z.array(z.object({
738
- label: z.string(),
739
- value: z.string()
740
- }))])),
741
- attrs: AttrsSchema
742
- });
743
- const ContentNodeSchema = z.object({
744
- tag: z.string(),
745
- attrs: AttrsSchema,
746
- text: z.optional(z.string()),
747
- get children() {
748
- return z.optional(z.array(ContentNodeSchema));
721
+ out.push({
722
+ path,
723
+ name,
724
+ fallback
725
+ });
749
726
  }
750
- });
751
- const TemplateSchema = z.union([
752
- z.object({
753
- type: z.literal("nav"),
754
- tags: z.optional(z.array(z.string())),
755
- present: PresentSchema,
756
- items: z.optional(z.array(NavItemSchema))
757
- }),
758
- z.object({
759
- type: z.literal("form"),
760
- tags: z.optional(z.array(z.string())),
761
- present: PresentSchema,
762
- action: z.optional(z.string()),
763
- method: z.optional(z.enum(["get", "post"])),
764
- fields: z.optional(z.array(FormFieldSchema)),
765
- submit: z.optional(z.record(z.string(), AttrValue)),
766
- honeypot: z.optional(z.boolean())
767
- }),
768
- z.object({
769
- type: z.literal("nodes"),
770
- tags: z.optional(z.array(z.string())),
771
- nodes: z.optional(z.array(ContentNodeSchema))
772
- })
773
- ]);
774
- const GalleryImageSchema = z.object({
775
- src: z.string(),
776
- alt: LocalizableSchema,
777
- caption: z.optional(LocalizableSchema)
778
- });
779
- const GallerySchema = z.object({
780
- title: z.optional(LocalizableSchema),
781
- tags: z.optional(z.array(z.string())),
782
- images: z.array(GalleryImageSchema)
783
- });
784
- const TestimonialItemSchema = z.object({
785
- quote: LocalizableSchema,
786
- name: z.string(),
787
- role: z.optional(z.string()),
788
- rating: z.optional(z.number().check(z.gte(0), z.lte(5))),
789
- date: z.optional(IsoDate)
790
- });
791
- const TestimonialGroupSchema = z.object({
792
- title: z.optional(LocalizableSchema),
793
- items: z.array(TestimonialItemSchema)
794
- });
795
- const OfferSchema = z.object({
796
- name: z.optional(LocalizableSchema),
797
- price: z.optional(z.union([z.string(), z.number()])),
798
- priceCurrency: z.optional(Iso4217),
799
- availability: z.optional(z.string()),
800
- url: z.optional(z.url()),
801
- validFrom: z.optional(IsoDate),
802
- priceValidUntil: z.optional(IsoDate)
803
- });
804
- const ServiceSchema = z.object({
805
- name: LocalizableSchema,
806
- description: z.optional(LocalizableSchema),
807
- slug: z.optional(LocalizableSchema),
808
- offers: z.optional(z.array(OfferSchema))
809
- });
810
- const OpenGraphSchema = z.object({
811
- type: z.optional(z.string()),
812
- siteName: z.optional(z.string()),
813
- title: z.optional(LocalizableSchema),
814
- description: z.optional(LocalizableSchema),
815
- image: z.optional(ImageRefSchema),
816
- locale: z.optional(z.string())
817
- });
818
- const TwitterSchema = z.object({
819
- card: z.optional(z.enum([
820
- "summary",
821
- "summary_large_image",
822
- "app",
823
- "player"
824
- ])),
825
- site: z.optional(z.string()),
826
- creator: z.optional(z.string()),
827
- title: z.optional(LocalizableSchema),
828
- description: z.optional(LocalizableSchema),
829
- image: z.optional(ImageRefSchema)
830
- });
727
+ return out;
728
+ }
729
+ /** Recursively collect refs from a value that may be a string or a nested map. */
730
+ const walkValue = (value, path, out) => {
731
+ if (typeof value === "string") out.push(...extractVarRefs(value, path));
732
+ else if (value != null && typeof value === "object" && !Array.isArray(value)) for (const [k, v] of Object.entries(value)) walkValue(v, `${path}.${k}`, out);
733
+ };
831
734
  /**
832
- * What `vitops search notify` needs to tell search engines a deploy happened.
735
+ * Every authored declaration that can hold a `var()`.
833
736
  *
834
- * Scoped by what search engines actually accept, which is narrower than it looks:
835
- * Google exposes **no** "request indexing" API (the Search Console button is not in
836
- * its API, and URL Inspection is read-only), and it removed the sitemap ping
837
- * endpoint in 2023. So the sanctioned set is: an accurate sitemap, a Search Console
838
- * sitemap resubmit, an IndexNow ping — which Google does *not* honour but Bing,
839
- * Yandex, Naver, Seznam and Yep do — and a read-only inspection pass to find out
840
- * what Google actually did.
737
+ * `patterns` is where the reported failures were, but `typography.roles.*.color`
738
+ * and a `shadows` entry are authored the same way and fail the same way, so the
739
+ * same walk covers them. `patterns.items.*.states` is walked recursively — a
740
+ * state's `css` shortcut is a nested declaration map.
741
+ */
742
+ function collectVarRefs(ds) {
743
+ const out = [];
744
+ const p = ds.patterns ?? {};
745
+ walkValue(p["defaults"], "patterns.defaults", out);
746
+ walkValue(p["radii"], "patterns.radii", out);
747
+ walkValue(p["groups"], "patterns.groups", out);
748
+ for (const [name, item] of Object.entries(p["items"] ?? {})) {
749
+ const it = item ?? {};
750
+ walkValue(it["base"], `patterns.items.${name}.base`, out);
751
+ walkValue(it["overrides"], `patterns.items.${name}.overrides`, out);
752
+ walkValue(it["states"], `patterns.items.${name}.states`, out);
753
+ }
754
+ walkValue(ds.typography?.["roles"], "typography.roles", out);
755
+ walkValue(ds.shadows, "shadows", out);
756
+ return out;
757
+ }
758
+ /**
759
+ * The custom properties the generator emits, as names.
760
+ *
761
+ * Derived from the same functions the emitter uses — `expandPalette` for the
762
+ * ramps and `functionalRole` + `tokenVar` for the role set — so the check cannot
763
+ * disagree with the output about what exists. Both appearances are unioned: a
764
+ * token that only appears in the dark block is still defined.
765
+ */
766
+ function emittedTokens(ds) {
767
+ const defined = /* @__PURE__ */ new Set();
768
+ const palette = expandPalette(ds.colors.palette);
769
+ for (const [hueName, hue] of Object.entries(palette)) for (const n of Object.keys(hue.numeric)) defined.add(`--color-${hueName}-${n}`);
770
+ let hasSurface = false;
771
+ for (const [role, spec] of Object.entries(ds.colors.roles)) {
772
+ const hueName = roleHue(spec);
773
+ const hue = palette[hueName];
774
+ if (hue == null) continue;
775
+ const kind = roleKind(spec);
776
+ if (role === "surface" && kind === "surface") hasSurface = true;
777
+ const fr = functionalRole(role, hueName, hue, kind);
778
+ for (const key of new Set([...Object.keys(fr.light), ...Object.keys(fr.dark)])) defined.add(tokenVar(role, key));
779
+ }
780
+ if (Object.keys(ds.colors.roles).length) defined.add("--color-border-focus");
781
+ if (hasSurface) {
782
+ defined.add("--surface-glass");
783
+ defined.add("--overlay");
784
+ }
785
+ for (const name of Object.keys(ds.shadows ?? {})) defined.add(`--shadow-${name}`);
786
+ for (const name of Object.keys(ds.patterns?.z ?? {})) defined.add(`--z-tier-${name}`);
787
+ return defined;
788
+ }
789
+ /** Whether this check has an opinion about a given custom property. */
790
+ const isOwned = (name) => !FRAMEWORK_OWNED.has(name) && (OWNED_EXACT.has(name) || OWNED_PREFIXES.some((p) => name.startsWith(p)));
791
+ /**
792
+ * Suggest a near-miss from the emitted set, so the message names the fix rather
793
+ * than only the failure. Cheap edit-distance-ish heuristic: same prefix, or a
794
+ * one-segment difference.
795
+ */
796
+ const nearest = (name, defined) => {
797
+ const segs = name.split("-").filter(Boolean);
798
+ let best;
799
+ let bestScore = 0;
800
+ for (const cand of defined) {
801
+ const cs = cand.split("-").filter(Boolean);
802
+ const score = cs.filter((s) => segs.includes(s)).length - Math.abs(cs.length - segs.length) * .5;
803
+ if (score > bestScore) {
804
+ bestScore = score;
805
+ best = cand;
806
+ }
807
+ }
808
+ return bestScore >= Math.max(2, segs.length - 1) ? best : void 0;
809
+ };
810
+ /**
811
+ * Check a parsed design system's authored `var()` references, plus the
812
+ * role-kind heuristic that produces most of them.
813
+ *
814
+ * Returns messages rather than issues so `validate()` owns the `$ZodIssue`
815
+ * shaping, and so the same check can be reported by `generate()` without
816
+ * inventing a second vocabulary.
817
+ */
818
+ function tokenRefIssues(ds) {
819
+ const errors = [];
820
+ const warnings = [];
821
+ for (const [role, spec] of Object.entries(ds.colors.roles)) if (SURFACE_SHAPED.has(role) && roleKind(spec) === "chromatic") warnings.push(`colors.roles.${role} is declared chromatic (a bare hue string is shorthand for \`kind: "chromatic"\`), but the name reads as a surface. A chromatic role emits no bare \`--color-bg-${role}\` and no \`.bg-${role}\` utility` + (REQUIRED_ROLES.includes(role) ? `, and the framework's own component CSS references those with no fallback` : "") + `. Write \`"${role}": { "hue": "${roleHue(spec)}", "kind": "surface" }\` if you meant a surface.`);
822
+ let defined;
823
+ try {
824
+ defined = emittedTokens(ds);
825
+ } catch {
826
+ return {
827
+ errors,
828
+ warnings
829
+ };
830
+ }
831
+ const seen = /* @__PURE__ */ new Set();
832
+ const renames = movedTokens(ds.colors.roles);
833
+ const moved = /* @__PURE__ */ new Map();
834
+ const excused = /* @__PURE__ */ new Set();
835
+ for (const role of REQUIRED_ROLES) if (ds.colors.roles[role] == null) for (const key of ROLE_TOKEN_KEYS) excused.add(tokenVar(role, key));
836
+ for (const ref of collectVarRefs(ds)) {
837
+ if (ref.fallback || defined.has(ref.name) || excused.has(ref.name)) continue;
838
+ const moveTo = renames[ref.name];
839
+ if (moveTo != null) {
840
+ moved.set(ref.name, moveTo);
841
+ continue;
842
+ }
843
+ if (!isOwned(ref.name)) continue;
844
+ const key = `${ref.name} ${ref.path}`;
845
+ if (seen.has(key)) continue;
846
+ seen.add(key);
847
+ const hint = nearest(ref.name, defined);
848
+ errors.push({
849
+ path: ref.path.split("."),
850
+ message: `${ref.path} references \`var(${ref.name})\`, which this config emits no token for — the declaration will resolve to nothing at runtime.` + (hint ? ` Did you mean \`var(${hint})\`?` : "") + ` (Give it a fallback — \`var(${ref.name}, <value>)\` — if the absence is intentional.)`
851
+ });
852
+ }
853
+ if (moved.size) errors.unshift({
854
+ path: ["colors"],
855
+ message: `${moved.size} token reference${moved.size === 1 ? " uses" : "s use"} the pre-1.0 colour grammar. The role moved into the middle of the property name, after its target (\`--color-<target>-<role>[-<variant>]\`):\n` + [...moved].map(([old, next]) => ` ${old} → ${next}`).join("\n") + "\n\nApply these simultaneously, not one at a time — several renames are cyclic (a surface role's backgrounds rotate), so sequential replacement compounds them. `vitops lint <src> --fix` does this for markup and CSS."
856
+ });
857
+ return {
858
+ errors,
859
+ warnings
860
+ };
861
+ }
862
+
863
+ //#endregion
864
+ //#region src/schema.ts
865
+ /**
866
+ * Canonical `design-system.json` schema — the single source of truth.
841
867
  *
842
- * Facts only, the same rule the legal block follows: this records what the site has
843
- * (a key, a property, which pages matter), never what to say about it.
868
+ * Authored with `zod/mini` (tree-shakeable Zod v4). Everything else derives from it:
869
+ * • the `DesignSystem` TS type (`z.infer`), used throughout the generator,
870
+ * • the published JSON Schema (`toJSONSchema`), emitted to `schema.json` for `$schema` refs,
871
+ * • runtime validation (`validate`) for the CLI and to gate `generate`,
872
+ * • the generated `authoring.md` reference doc (walks `jsonSchema`'s descriptions).
844
873
  */
845
- const IndexNowSchema = z.object({
846
- 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 search notify --new-key`."),
847
- 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."),
848
- 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.")
849
- });
850
- const SearchConsoleSchema = z.object({
851
- 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\"."),
852
- 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.")
853
- });
854
- const IndexingSchema = z.object({
855
- sitemapUrl: desc(z.optional(z.url()), "The sitemap to submit and to diff for changed URLs. Defaults to `<canonical>/sitemap-index.xml`."),
856
- indexNow: desc(z.optional(IndexNowSchema), "IndexNow submission (Bing, Yandex, Naver, Seznam, Yep — not Google). Omit to skip the channel."),
857
- searchConsole: desc(z.optional(SearchConsoleSchema), "Google Search Console property. Needs a credential at run time — either a service account in `VITOPS_GSC_SERVICE_ACCOUNT` / `GOOGLE_APPLICATION_CREDENTIALS`, or the user OAuth credential `vitops search setup` uses (`VITOPS_GOOGLE_CLIENT_ID` / `_CLIENT_SECRET` / `_REFRESH_TOKEN`). The service account wins when both are set. Never put a key in this file."),
858
- priorityUrls: desc(z.optional(z.array(z.url())), "The pages whose indexing actually matters. `vitops search 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.")
859
- });
860
874
  /**
861
- * Which template set the generated legal documents are written against. This is
862
- * a closed enum rather than a free string because an unrecognised value would
863
- * otherwise silently fall back to the wrong body of law; `validateConfig` rejects
864
- * anything without a registered template. Add a member here and a matching entry
865
- * in `legal/templates/index.ts` — the two are checked against each other.
875
+ * Attach a JSON-Schema `description`. zod/mini has no `.describe()` method; the
876
+ * mini-API equivalent is `.check(z.describe(…))`, and `.check()` clones — so a
877
+ * shared sub-schema (`Scale`, `CssDecls`, …) can carry a different description
878
+ * per use-site without mutating the original.
866
879
  */
867
- const JURISDICTIONS = ["ca"];
868
- const JurisdictionSchema = z.enum(JURISDICTIONS);
880
+ const desc = (schema, text) => schema.check(z.describe(text));
881
+ 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.");
882
+ const Scalar = z.union([z.string(), z.number()]);
883
+ const VarMap = z.record(z.string(), Scalar);
884
+ const SeededRamp = desc(z.strictObject({
885
+ 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."),
886
+ 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."))
887
+ }), "Seeded hue: the 11-step scale is GENERATED in OKLCH from `seed` (anchors pin specific steps).");
888
+ 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.");
889
+ 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.");
869
890
  /**
870
- * A third party that receives personal information. The generator derives the
871
- * ones it can see (analytics IDs, Turnstile, the deploy platform); this is for
872
- * the rest, which no other part of the config implies.
873
- *
874
- * Deliberately facts, not prose: the config records *what is true*, the template
875
- * owns *how it is said*. That keeps a policy correct when the wording changes.
891
+ * A role points at a hue, and optionally declares its kind. The bare-string form
892
+ * is the shorthand for a chromatic role — signal colours are the common case,
893
+ * and the two or three surfaces are worth naming explicitly.
876
894
  */
877
- const ProcessorSchema = z.object({
878
- name: desc(z.string(), "The provider, as a reader would recognise it (e.g. \"Stripe\")."),
879
- purpose: desc(z.string(), "Why they receive it, as a noun phrase that reads after \"for\" (e.g. \"payment processing\")."),
880
- country: desc(z.optional(z.string()), "Where they process it, as it should read in a sentence (e.g. \"the United States\"). Feeds the cross-border-transfer disclosure."),
881
- privacyUrl: z.optional(z.url())
882
- });
883
- const SiteSectionSchema = z.object({
884
- defaultLocale: desc(z.string(), "The locale used when none is specified; must be a `locales` key."),
885
- locales: desc(z.record(z.string(), LocaleSchema), "Locales the site is published in, keyed by BCP 47 tag (e.g. \"en\", \"fr\")."),
886
- domains: desc(z.optional(z.object({
887
- canonical: desc(z.url(), "The canonical origin used for absolute URLs and SEO."),
888
- aliases: desc(z.optional(z.array(DomainAliasSchema)), "Alias domains and how they redirect to the canonical one.")
889
- })), "Canonical domain + redirecting aliases."),
890
- dns: desc(z.optional(z.record(z.string(), DnsDomainSchema)), "Desired DNS state per domain (provider, nameservers, records) — declarative reference, not applied automatically."),
891
- cloudflare: desc(z.optional(z.record(z.string(), z.unknown())), "Deliberate escape hatch for provider-specific Cloudflare settings — out of scope to model here."),
892
- searchConsole: desc(z.optional(z.record(z.string(), SearchConsoleSetupSchema)), "Domains to onboard as Google Search Console *domain properties* via `vitops search setup`, keyed by bare hostname (mirrors `dns`). Credentials come from the environment (CLOUDFLARE_API_TOKEN + the Google OAuth vars) — never in this file."),
893
- environments: desc(z.record(z.string(), EnvironmentSchema), "Deploy environments (production, dev, …): URL, API origin, analytics toggle, robots policy, active A/B variant."),
894
- abTesting: desc(z.optional(z.object({
895
- enabled: z.boolean(),
896
- cookieName: z.optional(z.string()),
897
- cookieMaxAge: desc(z.optional(z.number().check(z.int(), z.positive())), "Seconds."),
898
- splitRatio: desc(z.optional(z.number().check(z.gte(0), z.lte(1))), "Fraction of traffic sent to the variant (0–1)."),
899
- variants: desc(z.optional(z.record(z.string(), AbVariantSchema)), "Named variants; each targets an environment and may deep-merge config `overrides`.")
900
- })), "A/B testing: cookie-based split plus named variants whose `overrides` patch the config per environment."),
901
- 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`."),
902
- tags: desc(z.optional(z.record(z.string(), z.unknown())), "Free-form content-tag taxonomy (site-specific; not interpreted by the generator)."),
903
- postTypes: desc(z.optional(z.record(z.string(), z.unknown())), "Free-form content-type definitions (site-specific; not interpreted by the generator)."),
904
- galleries: desc(z.optional(z.record(z.string(), GallerySchema)), "Named image galleries (title, tags, images with localisable alt/caption)."),
905
- testimonials: desc(z.optional(z.record(z.string(), TestimonialGroupSchema)), "Named testimonial groups (quote, name, role, rating, date)."),
906
- templates: desc(z.optional(z.record(z.string(), TemplateSchema)), "Named content templates rendered at SSR time: `nav` (menu trees), `form` (field lists + submit), or `nodes` (raw element trees)."),
907
- navigation: desc(z.optional(z.object({ activeTemplate: desc(z.optional(z.object({
908
- default: desc(z.optional(z.string()), "A `templates` key."),
909
- breakpoints: desc(z.optional(z.record(z.string(), z.string())), "Breakpoint name → `templates` key overrides.")
910
- })), "Which nav template is active, optionally per breakpoint.") })), "Site navigation settings (which nav template renders where)."),
911
- seo: desc(z.optional(z.object({
912
- titleTemplate: desc(z.optional(z.string()), "Page-title pattern (e.g. \"%s · Acme\")."),
913
- descriptionTemplate: z.optional(z.string()),
914
- robots: desc(z.optional(RobotsSchema), "e.g. \"index,follow\" / \"noindex,nofollow\"."),
915
- googleSiteVerification: z.optional(z.string()),
916
- bingSiteVerification: z.optional(z.string()),
917
- openGraph: z.optional(OpenGraphSchema),
918
- twitter: z.optional(TwitterSchema),
919
- indexing: desc(z.optional(IndexingSchema), "How `vitops search notify` tells search engines about a deploy: which sitemap, IndexNow key, Search Console property, and which pages to verify afterwards.")
920
- })), "SEO defaults: title/description templates, robots policy, verification tokens, Open Graph + Twitter cards, and post-deploy indexing notification."),
921
- analytics: desc(z.optional(z.object({
922
- googleAnalyticsId: z.optional(z.string()),
923
- plausibleDomain: z.optional(z.string()),
924
- googleTagManagerId: z.optional(z.string()),
925
- clarityId: desc(z.optional(z.string()), "Microsoft Clarity project ID."),
926
- matomo: desc(z.optional(z.object({
927
- url: desc(z.string(), "Instance base URL (self-hosted, or `*.matomo.cloud`)."),
928
- siteId: desc(z.string(), "Site ID within that instance."),
929
- 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.")
930
- })), "Matomo instance.")
931
- })), "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`."),
932
- notifications: desc(z.optional(z.object({ email: desc(z.optional(z.union([z.email(), z.object({
933
- provider: desc(z.literal("cloudflare"), "Delivery provider. Only Cloudflare Email Sending is implemented; the field is stated so adding another is additive."),
934
- to: desc(z.optional(z.email()), "Recipient. Defaults to the primary location’s `email`, then the first location that has one."),
935
- from: desc(z.optional(z.email()), "Sender. Defaults to `noreply@<domains.canonical>`. Its domain must be onboarded (`wrangler email sending enable <domain>`) or every send fails."),
936
- fromName: desc(z.optional(z.string()), "Display name on the From header. Defaults to `organization.name`."),
937
- replyTo: desc(z.optional(z.email()), "Reply-To header."),
938
- binding: desc(z.optional(z.string()), "Workers `send_email` binding name (default `EMAIL`). Set it when using a restricted binding.")
939
- })])), "Where conversion notifications are e-mailed. A bare address is shorthand for `{ provider: \"cloudflare\", to }`.") })), "Where site notifications (form submissions, tracked calls) are sent. TODO: `sms` and `persist` channels — only `email` is implemented."),
940
- tracking: desc(z.optional(z.object({
941
- enabled: desc(z.optional(z.boolean()), "Capture ad click IDs and UTMs from the landing URL into the `_ac` cookie, for attributing conversions."),
942
- category: desc(z.optional(z.enum(["marketing", "analytics"])), "Consent category the `_ac` cookie waits on (default `marketing`). It is a 90-day identifier tying a visitor to the ad that brought them, so it is only written once this category is granted — and asking for it is what raises the banner."),
943
- platforms: desc(z.optional(z.array(z.string())), "Informational list of ad platforms in use. Capture recognises every known click-ID parameter regardless.")
944
- })), "Ad-click attribution for conversion tracking."),
945
- security: desc(z.optional(z.object({ turnstile: z.optional(z.object({ siteKey: desc(z.optional(z.string()), "Cloudflare Turnstile site key (public).") })) })), "Security integrations (bot protection)."),
946
- legal: desc(z.optional(z.object({
947
- jurisdiction: desc(z.optional(JurisdictionSchema), "Which legal template set the generated documents use. Default `ca`."),
948
- privacyPolicy: z.optional(z.object({
949
- enabled: z.boolean(),
950
- url: z.optional(z.url()),
951
- lastUpdated: z.optional(IsoDate),
952
- privacyOfficer: desc(z.optional(ContactConfigSchema), "Who privacy requests go to. Falls back to `contact`, then `primaryLocation`."),
953
- retention: desc(z.optional(z.string()), "How long personal information is kept, in prose (e.g. \"24 months after last contact\")."),
954
- processors: desc(z.optional(z.array(ProcessorSchema)), "Third parties that receive personal information and cannot be inferred from the rest of the config (payment processors, CRMs, mail senders). Known providers implied by `analytics`, `security` and `deployment` are added automatically — list only the rest.")
955
- })),
956
- termsOfService: z.optional(z.object({
957
- enabled: z.boolean(),
958
- url: z.optional(z.url()),
959
- lastUpdated: z.optional(z.nullable(IsoDate)),
960
- governingLaw: desc(z.optional(z.string()), "Governing-law clause, in prose (e.g. \"the Province of Ontario\"). Defaults from the contact address.")
961
- })),
962
- cookieConsent: z.optional(z.object({
963
- enabled: z.boolean(),
964
- type: desc(z.optional(z.enum(["opt-in", "opt-out"])), "Consent model."),
965
- position: z.optional(z.enum([
966
- "top",
967
- "bottom",
968
- "center"
969
- ])),
970
- categories: z.optional(z.array(z.string()))
971
- }))
972
- })), "Legal pages: which documents exist, where they live, and the facts the generated prose asserts."),
973
- icons: desc(z.optional(z.looseObject({
974
- ui: desc(z.optional(z.string()), "Icon set used for UI chrome (e.g. \"lucide\", \"ph\")."),
975
- brand: desc(z.optional(z.string()), "Icon set used for brand marks."),
976
- 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."),
977
- 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)."),
978
- semantic: desc(z.optional(z.array(z.string())), "Named semantic icons to include."),
979
- "fa7-solid": z.optional(z.array(z.string())),
980
- "fa7-regular": z.optional(z.array(z.string())),
981
- "fa7-light": z.optional(z.array(z.string())),
982
- "fa7-thin": z.optional(z.array(z.string())),
983
- "fa7-brands": z.optional(z.array(z.string())),
984
- "simple-icons": z.optional(z.array(z.string())),
985
- "material-symbols": z.optional(z.array(z.string())),
986
- lucide: z.optional(z.array(z.string())),
987
- ph: z.optional(z.array(z.string()))
988
- })), "Icon sets and the specific icons to bundle from each (keys are iconify collection names)."),
989
- favicon: desc(z.optional(z.object({
990
- source: desc(z.string(), "Source image (SVG/PNG) the favicon set is generated from."),
991
- lowResSource: desc(z.optional(z.string()), "Alternate source for small raster sizes (16/32px) when the main source scales down poorly."),
992
- name: desc(z.optional(z.string()), "App name for the generated web manifest / PWA."),
993
- themeColor: desc(z.optional(z.string()), "PWA theme color (also `<meta name=\"theme-color\">`)."),
994
- backgroundColor: desc(z.optional(z.string()), "PWA background color.")
995
- })), "Favicon/PWA asset generation (consumed by `@getvitops/utils` favicon tooling)."),
996
- deployment: desc(z.optional(z.object({
997
- platform: z.optional(z.string()),
998
- buildCommand: z.optional(z.string()),
999
- deployCommand: z.optional(z.string()),
1000
- outputDirectory: z.optional(z.string()),
1001
- type: z.optional(z.string())
1002
- })), "How the site is built and deployed (platform, commands, output directory) — informational for tooling.")
895
+ const RoleSpecSchema = z.union([desc(z.string(), "A palette hue name. Shorthand for `{ hue, kind: \"chromatic\" }`."), z.strictObject({
896
+ hue: desc(z.string(), "The palette hue this role resolves to."),
897
+ 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>`."))
898
+ })]);
899
+ const UtilityName = z.enum([
900
+ "bg",
901
+ "text",
902
+ "icon",
903
+ "border",
904
+ "outline",
905
+ "fill",
906
+ "stroke"
907
+ ]);
908
+ const Colors = desc(z.strictObject({
909
+ 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."),
910
+ 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.`),
911
+ 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."))
912
+ }), "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.");
913
+ const Scale = z.strictObject({
914
+ base: desc(z.string(), "Anchor size (a CSS length, e.g. \"1rem\") — the value at `baseStep`."),
915
+ ratio: desc(z.number(), "Modular ratio between adjacent steps at large viewports."),
916
+ steps: desc(z.optional(z.number()), "Token count when `names` is absent (steps are then named 1..steps)."),
917
+ names: desc(z.optional(z.array(z.string())), "Step names, smallest → largest (e.g. [\"xs\",\"sm\",\"md\",…]); each becomes a token suffix."),
918
+ baseStep: desc(z.optional(z.number()), "1-based index of the step whose value is `base`."),
919
+ baseline: desc(z.optional(z.string()), "Named step used as the fluid pivot / GUI scale centre (defaults to `baseStep`)."),
920
+ fluid: desc(z.optional(z.strictObject({
921
+ minVw: desc(z.string(), "Viewport width (CSS length) where fluid scaling bottoms out."),
922
+ maxVw: desc(z.string(), "Viewport width (CSS length) where fluid scaling tops out."),
923
+ minRatio: desc(z.number(), "Modular ratio at/below `minVw` (usually < `ratio`).")
924
+ })), "Makes the scale fluid: each step compiles to a clamp() that interpolates from `minRatio` at `minVw` to `ratio` at `maxVw`.")
1003
925
  });
1004
- const OrganizationSectionSchema = z.extend(OrganizationSchema, {
1005
- contact: desc(z.optional(ContactConfigSchema), "Primary contact: either a `locations` key (string reference) or an inline name/email/phone/address object."),
1006
- primaryLocation: desc(z.optional(z.string()), "The main `locations` key (for JSON-LD and defaults)."),
1007
- locations: desc(z.optional(z.record(z.string(), LocationSchema)), "Physical locations (schema.org LocalBusiness): address, geo, opening hours, service area."),
1008
- services: desc(z.optional(z.record(z.string(), ServiceSchema)), "Services offered (schema.org Service/Offer): name, description, slug, price offers."),
1009
- links: desc(z.optional(z.object({
1010
- googleMaps: z.optional(z.url()),
1011
- instagram: z.optional(z.url()),
1012
- facebook: z.optional(z.url()),
1013
- x: z.optional(z.url()),
1014
- linkedin: z.optional(z.url()),
1015
- youtube: z.optional(z.url()),
1016
- github: z.optional(z.url())
1017
- })), "Public profile URLs (also feed JSON-LD `sameAs` via `organization.sameAs`).")
926
+ const Pattern = desc(z.strictObject({
927
+ 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`."),
928
+ overrides: z.optional(desc(CssDecls, "Per-pattern token overrides, emitted as `--<prop>-<name>-group` values.")),
929
+ element: desc(z.optional(z.string()), "Style at element level via zero-specificity `:where(<element>)` (instead of, or alongside, a class)."),
930
+ class: desc(z.optional(z.string()), "Class name to emit (defaults to the pattern's key when no `element` is set). Combined with `element`, the pattern emits one zero-specificity `:where(<element>, .<class>)` rule so the class works on any tag and any explicit class overrides it."),
931
+ fill: desc(z.optional(z.boolean()), "Whether this pattern is colour-filled (states/roles drive `background-color` + `on-solid` text) or text-coloured (they drive `color`). Defaults to true when `base` declares a background."),
932
+ default_role: desc(z.optional(z.string()), "Semantic colour role applied to the bare/default variant."),
933
+ base: z.optional(desc(CssDecls, "Base CSS declarations. Geometry properties (padding, border-radius, border, box-shadow, font-size) are wrapped in per-pattern override hooks (`--p-<name>`, `--br-<name>`, `--b-<name>`, `--ds-<name>`, `--fs-<name>`) so consumers can restyle one pattern by setting one variable.")),
934
+ 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)`."),
935
+ 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).")
936
+ }), "One component pattern (button, link, badge, card, …): base declarations + interaction states + semantic role variants, resolved through the pattern token cascade.");
937
+ const Patterns = desc(z.strictObject({
938
+ defaults: z.optional(desc(CssDecls, "Cascade-wide fallback tokens, emitted as `--<prop>-default`.")),
939
+ radii: z.optional(desc(CssDecls, "Shape primitives, emitted as `--br-<name>` (referenced by pattern bases).")),
940
+ groups: desc(z.optional(z.record(z.string(), CssDecls)), "Group-level tokens, emitted as `--<prop>-<group>`; patterns opt in via their `group` key."),
941
+ z: desc(z.optional(z.record(z.string(), z.number())), "Z-index tiers → `--z-tier-<name>`."),
942
+ items: desc(z.optional(z.record(z.string(), Pattern)), "The component patterns to emit, keyed by name.")
943
+ }), "Component patterns and their token cascade: `defaults` → `groups` → per-pattern `overrides`, plus shape (`radii`) and z-index primitives.");
944
+ 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.");
945
+ const Typography = desc(z.strictObject({
946
+ 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)\")."),
947
+ 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."),
948
+ 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.")
949
+ }), "Typography: family aliases, semantic type roles (→ `font-<role>` classes), and the bare-element → role mapping.");
950
+ const AnimEffect = desc(z.strictObject({
951
+ kf: desc(z.string(), "Keyframe family driving the effect: composite (transform/opacity), paint, or layout."),
952
+ css: z.optional(desc(VarMap, "Extra literal declarations merged into the effect class as-is.")),
953
+ vars: z.optional(desc(VarMap, "Effect endpoint variables (`--<key>: <value>`, e.g. opacity-from, translate-y-to) that override the keyframe defaults."))
954
+ }), "A named animation effect class — a pure value layer (`--_anim` + `--<prop>-from/-to`) over the static keyframe engine.");
955
+ const Animations = desc(z.strictObject({
956
+ effects: desc(z.optional(z.record(z.string(), AnimEffect)), "Effect classes to emit (`.fade-in`, `.reveal-left`, …), keyed by class name."),
957
+ journeys: desc(z.optional(z.strictObject({
958
+ base: desc(z.optional(z.record(z.string(), VarMap)), "Named journey building blocks: part name → var map."),
959
+ compose: desc(z.optional(z.array(z.array(z.string()))), "Combinations of base parts, each emitted as a `.<parts>-journey` class.")
960
+ })), "Multi-part journey classes composed from `base` building blocks.")
961
+ }), "Animation effect + journey classes (pure value layers). The animation engine itself — keyframes, drivers, floats, utilities — is static framework CSS, not configured here.");
962
+ const DesignSystemSchema = z.strictObject({
963
+ $schema: desc(z.optional(z.string()), "URL of the published JSON Schema (stamped by `vitops init`) so editors provide autocomplete + validation."),
964
+ meta: desc(z.optional(z.strictObject({
965
+ 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\"."),
966
+ 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.")
967
+ })), "Brand identity for agent-facing output. Consumed only by the `design` format (`DESIGN.md`); it emits no CSS and no tokens."),
968
+ colors: Colors,
969
+ 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."),
970
+ 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."),
971
+ typeScale: desc(z.optional(Scale), "Fluid modular TYPE scale → `--text-<name>` tokens, consumed by typography roles and text-size utilities."),
972
+ spaceScale: desc(z.optional(Scale), "Fluid modular SPACE scale → `--space-<name>` tokens, consumed by spacing/gap utilities and vertical rhythm."),
973
+ patterns: z.optional(Patterns),
974
+ typography: z.optional(Typography),
975
+ animations: z.optional(Animations)
1018
976
  });
1019
- const ConfigSchema = z.object({
1020
- designSystem: DesignSystemBlock,
1021
- organization: desc(z.optional(OrganizationSectionSchema), "The company: schema.org Organization details for JSON-LD (name, legalName, logo, tax IDs, sameAs), plus its contact, physical locations, services and public profiles. These stay true across sites — several sites can share one `organization` and differ only in `site`."),
1022
- site: desc(SiteSectionSchema, "This published site: locales, domains, environments, content templates, SEO, analytics, legal documents, icons, favicon and deployment. Facts about a *presentation* of the organization rather than the organization itself.")
977
+ const ColorsPatch = z.strictObject({
978
+ palette: z.optional(z.record(z.string(), Ramp)),
979
+ roles: z.optional(z.record(z.string(), RoleSpecSchema)),
980
+ utilities: z.optional(z.array(UtilityName))
1023
981
  });
1024
- const CONFIG_SCHEMA_URL = "https://unpkg.com/@getvitops/generator/config.schema.json";
982
+ const DesignSystemPatchSchema = z.extend(z.partial(DesignSystemSchema), { colors: z.optional(ColorsPatch) });
983
+ /**
984
+ * Stable URL of the published JSON Schema. Resolves once `@getvitops/generator` is on
985
+ * npm (unpkg serves the packaged `schema.json`); `vitops init` stamps this into a
986
+ * scaffolded config's `$schema` so editors light up autocomplete + validation.
987
+ */
988
+ const SCHEMA_URL = "https://unpkg.com/@getvitops/generator/schema.json";
989
+ /**
990
+ * What `vitops init` actually stamps into a scaffolded config's `$schema`.
991
+ *
992
+ * A **local** path, following wrangler's `node_modules/wrangler/config-schema.json`.
993
+ * `SCHEMA_URL` has no version in it, so unpkg resolves it to whatever is newest
994
+ * — an editor validating a project pinned to generator 2.x silently checks it
995
+ * against the 3.x schema, reporting errors for valid config and accepting
996
+ * fields that do not exist yet. The installed copy is by definition the one
997
+ * that matches the toolchain doing the building.
998
+ *
999
+ * `SCHEMA_URL` remains the schema's `$id`, which is what it is for.
1000
+ */
1001
+ const SCHEMA_LOCAL_PATH = "./node_modules/@getvitops/generator/schema.json";
1025
1002
  /** The published JSON Schema (draft-2020-12), derived from the zod schema. */
1026
- const configJsonSchema = {
1027
- $id: CONFIG_SCHEMA_URL,
1028
- ...z.toJSONSchema(ConfigSchema, { target: "draft-2020-12" })
1003
+ const jsonSchema = {
1004
+ $id: SCHEMA_URL,
1005
+ ...z.toJSONSchema(DesignSystemSchema, { target: "draft-2020-12" })
1029
1006
  };
1030
- const issue = (path, message) => ({
1031
- code: "custom",
1032
- path,
1033
- message
1034
- });
1007
+ /** Split a CSS value on top-level commas, ignoring commas nested in `rgba(…)` etc. */
1008
+ const splitLayers = (value) => {
1009
+ const out = [];
1010
+ let depth = 0;
1011
+ let start = 0;
1012
+ for (let i = 0; i < value.length; i++) {
1013
+ const c = value[i];
1014
+ if (c === "(") depth++;
1015
+ else if (c === ")") depth--;
1016
+ else if (c === "," && depth === 0) {
1017
+ out.push(value.slice(start, i));
1018
+ start = i + 1;
1019
+ }
1020
+ }
1021
+ out.push(value.slice(start));
1022
+ return out.map((s) => s.trim()).filter(Boolean);
1023
+ };
1024
+ const LENGTH = /^[+-]?(\d+\.?\d*|\.\d+)(px|rem|em|ch|ex|vh|vw|vmin|vmax|cm|mm|in|pt|pc|q)?$/i;
1035
1025
  /**
1036
- * Where each key of the old flat config now lives.
1037
- *
1038
- * This exists so the restructure fails with a sentence a reader can act on. Zod
1039
- * reports a flat config as a dozen separate `unrecognized_keys` issues plus a
1040
- * missing required `site` — an accurate description of the document that teaches
1041
- * nobody where anything went. Checked first, and it short-circuits: once we know
1042
- * the document is the old shape, every other error is downstream noise.
1026
+ * Why a shadow can be *valid CSS* and still render nothing: a `--shadow-<name>`
1027
+ * token is consumed both as a `box-shadow` (pattern geometry) and inside
1028
+ * `filter: drop-shadow(…)` (the `.drop-shadow-<name>` utilities, the `shadow:`
1029
+ * state shortcut). `drop-shadow()` accepts a *single* layer of at most three
1030
+ * lengths and no `inset` — a spread radius, a second comma-separated layer or an
1031
+ * inset keyword makes the function invalid, which drops the entire `filter`
1032
+ * declaration. `box-shadow` accepts all three happily, so the value looks fine
1033
+ * everywhere it is authored and only the utility silently goes blank.
1043
1034
  */
1044
- const MOVED_KEYS = {
1045
- defaultLocale: "site",
1046
- locales: "site",
1047
- domains: "site",
1048
- dns: "site",
1049
- cloudflare: "site",
1050
- environments: "site",
1051
- abTesting: "site",
1052
- fonts: "site",
1053
- tags: "site",
1054
- postTypes: "site",
1055
- galleries: "site",
1056
- testimonials: "site",
1057
- templates: "site",
1058
- navigation: "site",
1059
- seo: "site",
1060
- analytics: "site",
1061
- notifications: "site",
1062
- tracking: "site",
1063
- security: "site",
1064
- legal: "site",
1065
- icons: "site",
1066
- favicon: "site",
1067
- deployment: "site",
1068
- contact: "organization",
1069
- primaryLocation: "organization",
1070
- locations: "organization",
1071
- services: "organization",
1072
- links: "organization"
1035
+ const dropShadowIssue = (value) => {
1036
+ const layers = splitLayers(value);
1037
+ if (layers.length > 1) return `${layers.length} comma-separated layers`;
1038
+ const layer = layers[0] ?? "";
1039
+ if (/(^|\s)inset(\s|$)/i.test(layer)) return "an `inset` keyword";
1040
+ const lengths = layer.replaceAll(/[\w-]*\([^()]*\)/g, " ").split(/\s+/).filter((t) => LENGTH.test(t));
1041
+ if (lengths.length > 3) return `a spread radius (${lengths.length} lengths)`;
1073
1042
  };
1043
+ const RAMP_KEYS = new Set([
1044
+ "seed",
1045
+ "anchors",
1046
+ "tones"
1047
+ ]);
1074
1048
  /**
1075
- * Detect the pre-3.0 flat shape and name the moves.
1049
+ * Replace the bare "Invalid input" a failed union produces with the reason.
1076
1050
  *
1077
- * Keyed on a moved key sitting at the ROOT, not on the absence of `site` — a
1078
- * config that simply forgot `site` should get the ordinary "required" error, and
1079
- * a partially-migrated one (some keys moved, some not) is exactly the case worth
1080
- * catching loudly.
1051
+ * `Ramp` is `SeededRamp | FixedRamp`, and both branches are closed — so `{ seed,
1052
+ * tones }` or a stray key fails *both* and zod reports only that the union didn't
1053
+ * match. That message sends you reading the schema rather than looking at the two
1054
+ * keys in front of you, and the config that produced it is one whose `tones` were
1055
+ * being silently discarded at generate time.
1081
1056
  */
1082
- function migrationIssues(raw) {
1083
- if (typeof raw !== "object" || raw == null || Array.isArray(raw)) return [];
1084
- const found = Object.keys(raw).filter((k) => k in MOVED_KEYS);
1085
- if (found.length === 0) return [];
1086
- return [issue([], `this is the pre-3.0 flat site config. The top level is now three sections — \`designSystem\`, \`organization\`, \`site\` — and these keys moved:
1087
- ${found.map((k) => ` ${k} → ${MOVED_KEYS[k]}.${k}`).join("\n")}\n\`designSystem\` and the fields already under \`organization\` stay where they are.`)];
1088
- }
1057
+ const explainRamp = (input) => (issue) => {
1058
+ if (issue.code === "unrecognized_keys") {
1059
+ const keys = issue.keys ?? [];
1060
+ if (keys.length) return {
1061
+ ...issue,
1062
+ message: `unknown key${keys.length > 1 ? "s" : ""} ${keys.map((k) => `"${k}"`).join(", ")} — not part of the schema, so ignored at generate time.`
1063
+ };
1064
+ }
1065
+ if (issue.path.length !== 3 || issue.path[0] !== "colors" || issue.path[1] !== "palette") return issue;
1066
+ const hue = input?.colors?.palette?.[String(issue.path[2])];
1067
+ if (hue == null || typeof hue !== "object") return issue;
1068
+ const keys = Object.keys(hue);
1069
+ const unknown = keys.filter((k) => !RAMP_KEYS.has(k));
1070
+ 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;
1071
+ return message ? {
1072
+ ...issue,
1073
+ message
1074
+ } : issue;
1075
+ };
1089
1076
  /**
1090
- * Validate a raw (already parsed) config: shape via zod, then the cross-field
1091
- * integrity that JSON Schema can't express (referential keys, extends acyclicity).
1092
- * These live only here — the published JSON Schema enforces shape, not integrity.
1077
+ * Validate an unknown value against the design-system schema.
1078
+ *
1079
+ * `warnings` are configurations that parse and generate, but produce output that
1080
+ * won't behave as authored — currently only the token-namespace collisions the
1081
+ * flat `--<prop>-<name>` grammar makes possible.
1093
1082
  */
1094
- function validateConfig(input) {
1095
- const migration = migrationIssues(input);
1096
- if (migration.length) return {
1097
- ok: false,
1098
- data: void 0,
1099
- errors: migration
1100
- };
1101
- const parsed = z.safeParse(ConfigSchema, input);
1102
- if (!parsed.success) return {
1083
+ function validate(input) {
1084
+ const result = z.safeParse(DesignSystemSchema, input);
1085
+ if (!result.success) return {
1103
1086
  ok: false,
1104
1087
  data: void 0,
1105
- errors: parsed.error.issues
1088
+ errors: result.error.issues.map(explainRamp(input)),
1089
+ warnings: []
1106
1090
  };
1107
- const cfg = parsed.data;
1108
- const site = cfg.site;
1109
- const org = cfg.organization;
1110
- const errors = [];
1111
- const has = (m, k) => !!m && k in m;
1112
- if (!has(site.locales, site.defaultLocale)) errors.push(issue(["site", "defaultLocale"], `defaultLocale "${site.defaultLocale}" is not in locales`));
1113
- const themes = cfg.designSystem?.themes ?? {};
1114
- const defaultTheme = cfg.designSystem?.defaultTheme;
1115
- if (!has(themes, "default") && defaultTheme == null) errors.push(issue(["designSystem", "themes"], "themes must include a \"default\" entry"));
1116
- if (defaultTheme != null && !has(themes, defaultTheme)) errors.push(issue(["designSystem", "defaultTheme"], `defaultTheme "${defaultTheme}" is not in designSystem.themes`));
1117
- for (const [key, entry] of Object.entries(themes)) if (entry.extends != null && !has(themes, entry.extends)) errors.push(issue([
1118
- "designSystem",
1119
- "themes",
1120
- key,
1121
- "extends"
1122
- ], `extends "${entry.extends}" is not a themes key`));
1123
- for (const key of Object.keys(themes)) try {
1124
- resolveTheme(themes, key);
1125
- } catch (e) {
1126
- errors.push(issue([
1127
- "designSystem",
1128
- "themes",
1129
- key
1130
- ], e.message));
1131
- }
1132
- if (org?.primaryLocation != null && !has(org.locations, org.primaryLocation)) errors.push(issue(["organization", "primaryLocation"], `primaryLocation "${org.primaryLocation}" is not in organization.locations`));
1133
- if (typeof org?.contact === "string" && !has(org.locations, org.contact)) errors.push(issue(["organization", "contact"], `contact "${org.contact}" is not an organization.locations key`));
1134
- for (const [name, v] of Object.entries(site.abTesting?.variants ?? {})) if (!has(site.environments, v.environment)) errors.push(issue([
1135
- "site",
1136
- "abTesting",
1137
- "variants",
1138
- name,
1139
- "environment"
1140
- ], `environment "${v.environment}" is not in site.environments`));
1141
- for (const [i, a] of (site.domains?.aliases ?? []).entries()) if (a.environment != null && !has(site.environments, a.environment)) errors.push(issue([
1142
- "site",
1143
- "domains",
1144
- "aliases",
1145
- i,
1146
- "environment"
1147
- ], `environment "${a.environment}" is not in site.environments`));
1148
- const templates = site.templates;
1149
- const at = site.navigation?.activeTemplate;
1150
- if (at?.default != null && !has(templates, at.default)) errors.push(issue([
1151
- "site",
1152
- "navigation",
1153
- "activeTemplate",
1154
- "default"
1155
- ], `template "${at.default}" is not in site.templates`));
1156
- for (const [bp, tpl] of Object.entries(at?.breakpoints ?? {})) if (!has(templates, tpl)) errors.push(issue([
1157
- "site",
1158
- "navigation",
1159
- "activeTemplate",
1160
- "breakpoints",
1161
- bp
1162
- ], `template "${tpl}" is not in site.templates`));
1163
- const privacy = site.legal?.privacyPolicy;
1164
- if (typeof privacy?.privacyOfficer === "string" && !has(org?.locations, privacy.privacyOfficer)) errors.push(issue([
1165
- "site",
1166
- "legal",
1167
- "privacyPolicy",
1168
- "privacyOfficer"
1169
- ], `privacyOfficer "${privacy.privacyOfficer}" is not an organization.locations key`));
1170
- if (privacy?.enabled) {
1171
- if (resolvePrivacyContact(cfg) == null) errors.push(issue([
1172
- "site",
1173
- "legal",
1174
- "privacyPolicy"
1175
- ], "privacyPolicy.enabled requires a contact for privacy requests — set site.legal.privacyPolicy.privacyOfficer, organization.contact, or organization.email/address"));
1176
- if (site.domains?.canonical == null) errors.push(issue([
1177
- "site",
1178
- "legal",
1179
- "privacyPolicy"
1180
- ], "privacyPolicy.enabled requires site.domains.canonical — the policy states where information is held"));
1091
+ const { palette, roles } = result.data.colors;
1092
+ for (const [role, spec] of Object.entries(roles)) {
1093
+ const hue = roleHue(spec);
1094
+ if (palette[hue] == null) return {
1095
+ ok: false,
1096
+ data: void 0,
1097
+ errors: [{
1098
+ code: "custom",
1099
+ path: [
1100
+ "colors",
1101
+ "roles",
1102
+ role
1103
+ ],
1104
+ message: `role "${role}" references unknown palette hue "${hue}"`
1105
+ }],
1106
+ warnings: []
1107
+ };
1181
1108
  }
1182
- const indexing = site.seo?.indexing;
1183
- if (indexing != null && site.domains?.canonical == null) {
1184
- if (indexing.sitemapUrl == null) errors.push(issue([
1185
- "site",
1186
- "seo",
1187
- "indexing",
1188
- "sitemapUrl"
1189
- ], "site.seo.indexing needs site.domains.canonical to derive the sitemap URL — set one, or state sitemapUrl explicitly"));
1190
- if (indexing.indexNow != null && indexing.indexNow.keyLocation == null) errors.push(issue([
1191
- "site",
1192
- "seo",
1193
- "indexing",
1194
- "indexNow",
1195
- "keyLocation"
1196
- ], "site.seo.indexing.indexNow needs site.domains.canonical to derive the key location — set one, or state keyLocation explicitly"));
1109
+ const warnings = [];
1110
+ const missing = REQUIRED_ROLES.filter((r) => roles[r] == null);
1111
+ if (missing.length) warnings.push(`colors.roles is missing ${missing.map((r) => `"${r}"`).join(", ")} — the framework's own component CSS references ${missing.length === 1 ? "it" : "them"} with no fallback, so those components will render uncoloured. Add any role you like, but keep these defined.`);
1112
+ const radii = Object.keys(result.data.patterns?.radii ?? {});
1113
+ const items = new Set(Object.keys(result.data.patterns?.items ?? {}));
1114
+ for (const name of radii) if (items.has(name)) warnings.push(`patterns.radii.${name} collides with the "${name}" pattern on --br-${name}; the pattern's override hook wins — rename the radius`);
1115
+ for (const [name, value] of Object.entries(result.data.shadows ?? {})) {
1116
+ const issue = dropShadowIssue(value);
1117
+ if (issue) warnings.push(`shadows.${name} carries ${issue}, which \`filter: drop-shadow()\` rejects — \`.drop-shadow-${name}\` will render no shadow at all (the token still works as a \`box-shadow\`). Use one layer of at most three lengths.`);
1197
1118
  }
1198
- if (errors.length) return {
1119
+ const refs = tokenRefIssues(result.data);
1120
+ warnings.push(...refs.warnings);
1121
+ if (refs.errors.length) return {
1199
1122
  ok: false,
1200
1123
  data: void 0,
1201
- errors
1124
+ errors: refs.errors.map((e) => ({
1125
+ code: "custom",
1126
+ ...e
1127
+ })),
1128
+ warnings
1202
1129
  };
1203
1130
  return {
1204
1131
  ok: true,
1205
- data: cfg,
1206
- errors: []
1132
+ data: result.data,
1133
+ errors: [],
1134
+ warnings
1207
1135
  };
1208
1136
  }
1137
+
1138
+ //#endregion
1139
+ //#region src/config.ts
1209
1140
  /**
1210
- * Resolve who privacy requests go to: the explicit privacy officer, else the
1211
- * organization's contact, else its primary location, else the organization itself.
1141
+ * The top-level configuration schema — the umbrella around a
1142
+ * `design-system.json`-compliant `designSystem`.
1212
1143
  *
1213
- * Exported because `validateConfig` and the legal-document derivation must agree
1214
- * on it exactly — a config that validates must be one the policy can render, and
1215
- * two copies of this precedence would drift into a policy with a blank address.
1144
+ * **Three sections, one document.** A config is `{ designSystem, organization,
1145
+ * site }`:
1216
1146
  *
1217
- * Note every candidate but the first now lives in `organization`, and the first
1218
- * (`site.legal.privacyPolicy.privacyOfficer`) still dereferences against
1219
- * `organization.locations` — the officer is a person at the company, named by the
1220
- * site because a company may publish more than one policy.
1147
+ * - `designSystem` — the token set (named themes + which theme/appearance).
1148
+ * - `organization` — the company: who it is, where it is, how to reach it, what
1149
+ * it sells. Facts that stay true if the site is rebuilt from scratch.
1150
+ * - `site` — this published site: locales, domains, environments, SEO,
1151
+ * analytics, legal, icons, deployment. Facts about *a* presentation of the
1152
+ * organization.
1153
+ *
1154
+ * The split is not cosmetic. The flat predecessor (`SiteConfig`) held the company
1155
+ * and the deployment as peers, so no single noun described it — and the
1156
+ * multi-site case was inexpressible. With three sections, several sites can share
1157
+ * one `organization` and override only `site`.
1158
+ *
1159
+ * Authored with `zod/mini` (same instance the design-system schema uses), so the
1160
+ * two compose: `DesignSystemSchema` is embedded as `designSystem.themes.<theme>` and the
1161
+ * whole thing serialises to one JSON Schema via `z.toJSONSchema`. Everything a
1162
+ * generator needs derives from here: the `Config` type (`z.infer`), the
1163
+ * published JSON Schema (`configJsonSchema`), and runtime validation (`validateConfig`).
1164
+ *
1165
+ * Pure: no YAML/env/fs. `resolveConfig` takes an already-parsed object; a
1166
+ * thin loader (reading YAML, injecting `SITE_ENV`) lives outside this module.
1221
1167
  */
1222
- function resolvePrivacyContact(cfg) {
1223
- const org = cfg.organization;
1224
- const deref = (c) => {
1225
- if (c == null) return void 0;
1226
- if (typeof c !== "string") return c;
1227
- const loc = org?.locations?.[c];
1228
- return loc && {
1229
- email: loc.email,
1230
- phone: loc.phone,
1231
- address: loc.address
1232
- };
1233
- };
1234
- return [
1235
- deref(cfg.site.legal?.privacyPolicy?.privacyOfficer),
1236
- deref(org?.contact),
1237
- deref(org?.primaryLocation),
1238
- org && {
1239
- email: org.email,
1240
- phone: org.phone,
1241
- address: org.address
1242
- }
1243
- ].find((c) => c != null && (c.email != null || c.address != null));
1244
- }
1168
+ const LocalizableSchema = desc(z.union([z.string(), z.record(z.string(), z.string())]), "A string, or a per-locale map of strings (`{ en: \"…\", fr: \"…\" }`).");
1169
+ const ImageRefSchema = z.union([z.string(), z.object({
1170
+ src: z.string(),
1171
+ alt: z.string(),
1172
+ width: z.optional(z.number()),
1173
+ height: z.optional(z.number())
1174
+ })]);
1175
+ const AttrValue = z.union([
1176
+ z.string(),
1177
+ z.number(),
1178
+ z.boolean()
1179
+ ]);
1180
+ const AttrsSchema = z.optional(z.record(z.string(), AttrValue));
1181
+ const IsoDate = z.string().check(z.regex(/^\d{4}-\d{2}-\d{2}$/));
1182
+ const Iso4217 = z.string().check(z.regex(/^[A-Z]{3}$/));
1183
+ const DayOfWeek = z.enum([
1184
+ "Monday",
1185
+ "Tuesday",
1186
+ "Wednesday",
1187
+ "Thursday",
1188
+ "Friday",
1189
+ "Saturday",
1190
+ "Sunday"
1191
+ ]);
1192
+ const DesignSystemEntry = z.extend(DesignSystemPatchSchema, {
1193
+ 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`)."),
1194
+ extends: desc(z.optional(z.string()), "Another `themes` key to inherit from; this entry then only supplies what it overrides.")
1195
+ });
1196
+ /**
1197
+ * The design system as a whole: the named themes plus the facts that describe the
1198
+ * system rather than any one theme.
1199
+ *
1200
+ * `themes` is nested rather than being the block itself because the block used to
1201
+ * BE the map — every key was a theme name, so there was nowhere to put
1202
+ * system-wide metadata without it colliding with a theme of that name.
1203
+ *
1204
+ * Note the two axes here are independent, and deliberately so. `defaultTheme`
1205
+ * picks a *theme* (an authored design system); `defaultColorScheme` picks an
1206
+ * *appearance* (light/dark), which every theme already has because the flip is
1207
+ * derived from one ramp rather than authored. "Theme `elegant`, appearance
1208
+ * follows the OS" has to be sayable, and a single union could not express it.
1209
+ */
1210
+ const DesignSystemBlock = desc(z.strictObject({
1211
+ 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."),
1212
+ 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."),
1213
+ defaultColorScheme: desc(z.optional(z.enum([
1214
+ "light",
1215
+ "dark",
1216
+ "system"
1217
+ ])), "Initial appearance. `\"system\"` follows the OS via `prefers-color-scheme` and is what makes `<wc-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.")
1218
+ }), "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: … }`.");
1219
+ const LocaleSchema = z.object({
1220
+ name: desc(z.string(), "Display name of the locale (e.g. \"English\")."),
1221
+ tagline: desc(z.optional(z.string()), "Site tagline in this locale."),
1222
+ basePath: desc(z.optional(z.string()), "URL prefix for this locale (e.g. \"/fr\").")
1223
+ });
1224
+ const HttpRedirect = z.union([
1225
+ z.literal(301),
1226
+ z.literal(302),
1227
+ z.literal(307),
1228
+ z.literal(308)
1229
+ ]);
1230
+ const DomainAliasSchema = z.object({
1231
+ domain: z.string(),
1232
+ redirectType: HttpRedirect,
1233
+ redirectTo: z.string(),
1234
+ environment: z.optional(z.string())
1235
+ });
1236
+ const PostalAddressSchema = desc(z.object({
1237
+ streetAddress: z.string(),
1238
+ addressLocality: desc(z.string(), "City."),
1239
+ addressRegion: desc(z.optional(z.string()), "State / province."),
1240
+ postalCode: z.optional(z.string()),
1241
+ addressCountry: desc(z.string(), "Country (ISO 3166-1 alpha-2 preferred).")
1242
+ }), "schema.org `PostalAddress` field names, so JSON-LD generation is lossless.");
1243
+ const ContactObjectSchema = z.object({
1244
+ name: z.optional(z.string()),
1245
+ email: z.optional(z.email()),
1246
+ phone: z.optional(z.string()),
1247
+ address: z.optional(PostalAddressSchema)
1248
+ });
1249
+ const ContactConfigSchema = z.union([z.string(), ContactObjectSchema]);
1250
+ const GeoSchema = z.object({
1251
+ latitude: z.number().check(z.gte(-90), z.lte(90)),
1252
+ longitude: z.number().check(z.gte(-180), z.lte(180))
1253
+ });
1254
+ /** schema.org `OpeningHoursSpecification` — per-day open/close (24h `HH:MM`). */
1255
+ const OpeningHoursSchema = z.object({
1256
+ dayOfWeek: z.array(DayOfWeek),
1257
+ opens: z.string().check(z.regex(/^\d{2}:\d{2}$/)),
1258
+ closes: z.string().check(z.regex(/^\d{2}:\d{2}$/))
1259
+ });
1260
+ const LocalBusinessType = z.enum([
1261
+ "LocalBusiness",
1262
+ "Store",
1263
+ "Restaurant",
1264
+ "CafeOrCoffeeShop",
1265
+ "ProfessionalService",
1266
+ "MedicalBusiness",
1267
+ "HealthAndBeautyBusiness",
1268
+ "HomeAndConstructionBusiness",
1269
+ "AutomotiveBusiness",
1270
+ "FinancialService",
1271
+ "LegalService",
1272
+ "RealEstateAgent",
1273
+ "Organization"
1274
+ ]);
1275
+ const LocationSchema = z.object({
1276
+ slug: z.optional(LocalizableSchema),
1277
+ name: LocalizableSchema,
1278
+ email: z.optional(z.email()),
1279
+ phone: z.optional(z.string()),
1280
+ address: z.optional(PostalAddressSchema),
1281
+ geo: z.optional(GeoSchema),
1282
+ hours: z.optional(z.array(OpeningHoursSchema)),
1283
+ type: z.optional(LocalBusinessType),
1284
+ description: z.optional(LocalizableSchema),
1285
+ areaServed: z.optional(z.array(z.string())),
1286
+ priceRange: z.optional(z.string()),
1287
+ paymentAccepted: z.optional(z.array(z.string())),
1288
+ currenciesAccepted: z.optional(Iso4217),
1289
+ knowsLanguage: z.optional(z.array(z.string()))
1290
+ });
1291
+ const OrganizationSchema = z.object({
1292
+ name: desc(z.optional(LocalizableSchema), "Trading name, as a reader would recognise it (e.g. \"Acme\"). Localisable."),
1293
+ legalName: desc(z.optional(z.string()), "Registered legal name (e.g. \"Acme Widgets Inc.\"). The generated legal documents prefer this over `name` — a policy is a statement by the legal entity."),
1294
+ foundingDate: desc(z.optional(IsoDate), "Date the organization was founded (YYYY-MM-DD)."),
1295
+ logo: desc(z.optional(ImageRefSchema), "Organization logo → JSON-LD `logo`."),
1296
+ email: desc(z.optional(z.email()), "General contact address. Also the last fallback for privacy requests — see `contact`."),
1297
+ phone: desc(z.optional(z.string()), "General contact telephone number."),
1298
+ address: desc(z.optional(PostalAddressSchema), "Registered/mailing address. Its `addressRegion` and `addressCountry` are what the governing-law clause of the generated terms of service defaults to."),
1299
+ taxID: desc(z.optional(z.string()), "Tax identification number."),
1300
+ vatID: desc(z.optional(z.string()), "VAT identification number."),
1301
+ sameAs: desc(z.optional(z.array(z.url())), "Profile URLs → JSON-LD `sameAs` (mirrors `links`).")
1302
+ });
1303
+ const DnsRecordType = z.enum([
1304
+ "A",
1305
+ "AAAA",
1306
+ "CNAME",
1307
+ "TXT",
1308
+ "MX",
1309
+ "NS",
1310
+ "SRV",
1311
+ "CAA",
1312
+ "PTR",
1313
+ "SOA"
1314
+ ]);
1315
+ const DnsRecordSchema = z.object({
1316
+ type: DnsRecordType,
1317
+ name: z.optional(z.string()),
1318
+ value: z.string(),
1319
+ ttl: z.optional(z.number().check(z.int(), z.positive())),
1320
+ proxied: z.optional(z.boolean())
1321
+ });
1322
+ const DnsDomainSchema = z.object({
1323
+ provider: z.optional(z.string()),
1324
+ nameservers: z.optional(z.array(z.string())),
1325
+ records: z.optional(z.array(DnsRecordSchema))
1326
+ });
1245
1327
  /**
1246
- * Resolve a design-system theme by following its `extends` chain and deep-merging
1247
- * base → child (child wins; arrays replace wholesale, correct for token overrides).
1248
- * Throws on unknown keys or an `extends` cycle. The result is a plain
1249
- * `DesignSystem` (validate it against `DesignSystemSchema` for completeness).
1328
+ * One domain to onboard into Google Search Console via `vitops search setup`.
1329
+ *
1330
+ * The verification token is fetched live from the Site Verification API at run
1331
+ * time and written to DNS — it is never stored here. What the config records is
1332
+ * the *intent*: which domains to onboard, and who should co-own them.
1250
1333
  */
1251
- function resolveTheme(designSystem, key) {
1252
- const chain = [];
1253
- const seen = /* @__PURE__ */ new Set();
1254
- let cur = key;
1255
- while (cur != null) {
1256
- if (seen.has(cur)) throw new Error(`extends cycle in designSystem at "${cur}"`);
1257
- seen.add(cur);
1258
- const entry = designSystem[cur];
1259
- if (entry == null) throw new Error(`designSystem theme "${cur}" not found`);
1260
- chain.unshift(entry);
1261
- cur = entry.extends;
1262
- }
1263
- const out = {};
1264
- for (const entry of chain) {
1265
- const { extends: _e, displayName: _d, ...ds } = entry;
1266
- deepMerge(out, ds);
1334
+ const SearchConsoleSetupSchema = z.object({
1335
+ delegatedOwners: desc(z.optional(z.array(z.email())), "Emails added as verification owners on the Site Verification web resource. Automated via the API — additive and idempotent, never removes an existing owner."),
1336
+ fullUserGroup: desc(z.optional(z.email()), "A Google Group to grant Full-User access in Search Console. SURFACED AS A REMINDER only: Search Console exposes no user/permission API, so this step stays manual by design.")
1337
+ });
1338
+ const RobotsSchema = z.string();
1339
+ const EnvironmentSchema = z.object({
1340
+ url: desc(z.url(), "Public origin of this environment."),
1341
+ api: desc(z.optional(z.url()), "API origin, when different from `url`."),
1342
+ analytics: desc(z.optional(z.boolean()), "Whether analytics fire in this environment."),
1343
+ ads: desc(z.optional(z.boolean()), "Whether `site.ads` pixels fire in this environment. Defaults to `analytics`, then true — a preview deployment sending pageviews is survivable, one firing conversion pixels is not, so this can be turned off on its own."),
1344
+ robots: desc(z.optional(RobotsSchema), "Robots policy (e.g. \"noindex,nofollow\" for dev)."),
1345
+ variant: desc(z.optional(z.string()), "Active `abTesting.variants` key for this environment.")
1346
+ });
1347
+ const AbVariantSchema = z.object({
1348
+ environment: z.string(),
1349
+ description: z.optional(z.string()),
1350
+ overrides: z.optional(z.record(z.string(), z.unknown()))
1351
+ });
1352
+ const FontProvider = z.enum([
1353
+ "google",
1354
+ "fontsource",
1355
+ "adobe",
1356
+ "bunny",
1357
+ "fontshare",
1358
+ "googleicons",
1359
+ "npm",
1360
+ "local"
1361
+ ]);
1362
+ const FontStyle = z.enum(["normal", "italic"]);
1363
+ const FontDisplay = z.enum([
1364
+ "auto",
1365
+ "block",
1366
+ "swap",
1367
+ "fallback",
1368
+ "optional"
1369
+ ]);
1370
+ const FontWeight = z.union([z.number(), z.string()]);
1371
+ /** A local-font face (`provider: 'local'`). */
1372
+ const FontVariantSchema = z.object({
1373
+ src: z.array(z.string()),
1374
+ weight: z.optional(FontWeight),
1375
+ style: z.optional(FontStyle),
1376
+ unicodeRange: z.optional(z.array(z.string())),
1377
+ display: z.optional(FontDisplay)
1378
+ });
1379
+ const SiteFontSchema = z.object({
1380
+ name: z.string(),
1381
+ provider: FontProvider,
1382
+ cssVariable: z.string().check(z.regex(/^--/)),
1383
+ weights: z.optional(z.array(FontWeight)),
1384
+ styles: z.optional(z.array(FontStyle)),
1385
+ subsets: z.optional(z.array(z.string())),
1386
+ fallbacks: z.optional(z.array(z.string())),
1387
+ optimizedFallbacks: z.optional(z.boolean()),
1388
+ display: z.optional(FontDisplay),
1389
+ formats: z.optional(z.array(z.string())),
1390
+ unicodeRange: z.optional(z.array(z.string())),
1391
+ variants: z.optional(z.array(FontVariantSchema)),
1392
+ preload: desc(z.optional(z.union([z.boolean(), z.array(z.object({
1393
+ weight: FontWeight,
1394
+ style: z.optional(FontStyle)
1395
+ }))])), "Emit `<Font preload />` for this family (true, or a list of weight/style faces).")
1396
+ });
1397
+ const PresentSchema = z.optional(z.record(z.string(), z.unknown()));
1398
+ const NavItemSchema = z.object({
1399
+ label: z.optional(LocalizableSchema),
1400
+ image: z.optional(ImageRefSchema),
1401
+ href: z.optional(z.string()),
1402
+ icon: z.optional(z.string()),
1403
+ attrs: AttrsSchema,
1404
+ itemAttrs: AttrsSchema,
1405
+ submenu: z.optional(z.object({
1406
+ present: PresentSchema,
1407
+ get items() {
1408
+ return z.optional(z.array(NavItemSchema));
1409
+ }
1410
+ }))
1411
+ });
1412
+ const FormFieldType = z.enum([
1413
+ "text",
1414
+ "email",
1415
+ "tel",
1416
+ "url",
1417
+ "number",
1418
+ "password",
1419
+ "textarea",
1420
+ "select",
1421
+ "checkbox",
1422
+ "radio",
1423
+ "date",
1424
+ "file",
1425
+ "hidden"
1426
+ ]);
1427
+ const FormFieldSchema = z.object({
1428
+ name: z.string(),
1429
+ type: FormFieldType,
1430
+ label: z.optional(LocalizableSchema),
1431
+ required: z.optional(z.boolean()),
1432
+ validation: z.optional(z.string()),
1433
+ options: z.optional(z.union([z.string(), z.array(z.object({
1434
+ label: z.string(),
1435
+ value: z.string()
1436
+ }))])),
1437
+ attrs: AttrsSchema
1438
+ });
1439
+ const ContentNodeSchema = z.object({
1440
+ tag: z.string(),
1441
+ attrs: AttrsSchema,
1442
+ text: z.optional(z.string()),
1443
+ get children() {
1444
+ return z.optional(z.array(ContentNodeSchema));
1267
1445
  }
1268
- return out;
1269
- }
1446
+ });
1447
+ const TemplateSchema = z.union([
1448
+ z.object({
1449
+ type: z.literal("nav"),
1450
+ tags: z.optional(z.array(z.string())),
1451
+ present: PresentSchema,
1452
+ items: z.optional(z.array(NavItemSchema))
1453
+ }),
1454
+ z.object({
1455
+ type: z.literal("form"),
1456
+ tags: z.optional(z.array(z.string())),
1457
+ present: PresentSchema,
1458
+ action: z.optional(z.string()),
1459
+ method: z.optional(z.enum(["get", "post"])),
1460
+ fields: z.optional(z.array(FormFieldSchema)),
1461
+ submit: z.optional(z.record(z.string(), AttrValue)),
1462
+ honeypot: z.optional(z.boolean())
1463
+ }),
1464
+ z.object({
1465
+ type: z.literal("nodes"),
1466
+ tags: z.optional(z.array(z.string())),
1467
+ nodes: z.optional(z.array(ContentNodeSchema))
1468
+ })
1469
+ ]);
1470
+ const GalleryImageSchema = z.object({
1471
+ src: z.string(),
1472
+ alt: LocalizableSchema,
1473
+ caption: z.optional(LocalizableSchema)
1474
+ });
1475
+ const GallerySchema = z.object({
1476
+ title: z.optional(LocalizableSchema),
1477
+ tags: z.optional(z.array(z.string())),
1478
+ images: z.array(GalleryImageSchema)
1479
+ });
1480
+ const TestimonialItemSchema = z.object({
1481
+ quote: LocalizableSchema,
1482
+ name: z.string(),
1483
+ role: z.optional(z.string()),
1484
+ rating: z.optional(z.number().check(z.gte(0), z.lte(5))),
1485
+ date: z.optional(IsoDate)
1486
+ });
1487
+ const TestimonialGroupSchema = z.object({
1488
+ title: z.optional(LocalizableSchema),
1489
+ items: z.array(TestimonialItemSchema)
1490
+ });
1491
+ const OfferSchema = z.object({
1492
+ name: z.optional(LocalizableSchema),
1493
+ price: z.optional(z.union([z.string(), z.number()])),
1494
+ priceCurrency: z.optional(Iso4217),
1495
+ availability: z.optional(z.string()),
1496
+ url: z.optional(z.url()),
1497
+ validFrom: z.optional(IsoDate),
1498
+ priceValidUntil: z.optional(IsoDate)
1499
+ });
1500
+ const ServiceSchema = z.object({
1501
+ name: LocalizableSchema,
1502
+ description: z.optional(LocalizableSchema),
1503
+ slug: z.optional(LocalizableSchema),
1504
+ offers: z.optional(z.array(OfferSchema))
1505
+ });
1506
+ const OpenGraphSchema = z.object({
1507
+ type: z.optional(z.string()),
1508
+ siteName: z.optional(z.string()),
1509
+ title: z.optional(LocalizableSchema),
1510
+ description: z.optional(LocalizableSchema),
1511
+ image: z.optional(ImageRefSchema),
1512
+ locale: z.optional(z.string())
1513
+ });
1514
+ const TwitterSchema = z.object({
1515
+ card: z.optional(z.enum([
1516
+ "summary",
1517
+ "summary_large_image",
1518
+ "app",
1519
+ "player"
1520
+ ])),
1521
+ site: z.optional(z.string()),
1522
+ creator: z.optional(z.string()),
1523
+ title: z.optional(LocalizableSchema),
1524
+ description: z.optional(LocalizableSchema),
1525
+ image: z.optional(ImageRefSchema)
1526
+ });
1270
1527
  /**
1271
- * Normalise the two `designSystem` shorthands to the canonical
1272
- * `{ themes: { … } }` shape.
1528
+ * What `vitops search notify` needs to tell search engines a deploy happened.
1273
1529
  *
1274
- * Both are keyed off what CANNOT be a theme name in the other reading:
1275
- * - `colors` → a bare `DesignSystem` was written inline.
1276
- * - `themes` → already canonical.
1277
- * - neither → the legacy bare theme map, from before the block gained
1278
- * system-wide fields. Every config authored against the old schema takes this
1279
- * branch, which is what keeps the change forgiving at runtime even though the
1280
- * published JSON Schema moved.
1530
+ * Scoped by what search engines actually accept, which is narrower than it looks:
1531
+ * Google exposes **no** "request indexing" API (the Search Console button is not in
1532
+ * its API, and URL Inspection is read-only), and it removed the sitemap ping
1533
+ * endpoint in 2023. So the sanctioned set is: an accurate sitemap, a Search Console
1534
+ * sitemap resubmit, an IndexNow ping — which Google does *not* honour but Bing,
1535
+ * Yandex, Naver, Seznam and Yep do — and a read-only inspection pass to find out
1536
+ * what Google actually did.
1537
+ *
1538
+ * Facts only, the same rule the legal block follows: this records what the site has
1539
+ * (a key, a property, which pages matter), never what to say about it.
1281
1540
  */
1282
- function normaliseDesignSystem(raw) {
1283
- const ds = raw.designSystem;
1284
- if (ds == null || typeof ds !== "object") return;
1285
- if ("themes" in ds) return;
1286
- raw.designSystem = "colors" in ds ? { themes: { default: ds } } : { themes: ds };
1287
- }
1541
+ const IndexNowSchema = z.object({
1542
+ 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 search notify --new-key`."),
1543
+ 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."),
1544
+ 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.")
1545
+ });
1546
+ const SearchConsoleSchema = z.object({
1547
+ 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\"."),
1548
+ 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.")
1549
+ });
1550
+ const IndexingSchema = z.object({
1551
+ sitemapUrl: desc(z.optional(z.url()), "The sitemap to submit and to diff for changed URLs. Defaults to `<canonical>/sitemap-index.xml`."),
1552
+ indexNow: desc(z.optional(IndexNowSchema), "IndexNow submission (Bing, Yandex, Naver, Seznam, Yep — not Google). Omit to skip the channel."),
1553
+ searchConsole: desc(z.optional(SearchConsoleSchema), "Google Search Console property. Needs a credential at run time — either a service account in `VITOPS_GSC_SERVICE_ACCOUNT` / `GOOGLE_APPLICATION_CREDENTIALS`, or the user OAuth credential `vitops search setup` uses (`VITOPS_GOOGLE_CLIENT_ID` / `_CLIENT_SECRET` / `_REFRESH_TOKEN`). The service account wins when both are set. Never put a key in this file."),
1554
+ priorityUrls: desc(z.optional(z.array(z.url())), "The pages whose indexing actually matters. `vitops search 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.")
1555
+ });
1288
1556
  /**
1289
- * Resolve a raw (already-parsed) config into a validated `Config`:
1290
- * 1. strip YAML nulls,
1291
- * 2. normalise the `designSystem` shorthands to `{ themes: … }`,
1292
- * 3. apply the active A/B variant's `overrides` (by `siteEnv`),
1293
- * 4. validate (shape + cross-field).
1294
- * Pure — no YAML/env access. A loader supplies `siteEnv`.
1557
+ * The ad platforms `vitops ads` can link a site to.
1295
1558
  *
1296
- * Steps 2 and 3 are in this order deliberately. Normalising first means an
1297
- * `abTesting` override always addresses the canonical shape
1298
- * (`designSystem.themes.<name>`), whatever shorthand the base config used. With
1299
- * the merge first, an override's key path depended on how the base happened to be
1300
- * written — so the same patch hit a different place in two configs that were
1301
- * otherwise equivalent.
1302
- *
1303
- * The A/B lookup reads `site.environments` / `site.abTesting`, and the overrides
1304
- * still deep-merge at the **root** — a variant patches whichever section it names,
1305
- * which is what lets one target `designSystem` and another `site.seo`.
1559
+ * A closed enum, mirroring `AD_PROVIDER_KEYS` in `@getvitops/utils/ads` — that
1560
+ * module owns what each platform *can do* (whether it verifies a domain, what its
1561
+ * tag is, which click ID it stamps), this owns what a config may *say*. Mirrored
1562
+ * rather than derived because a `z.enum` needs literals to reach the published JSON
1563
+ * Schema; `config.test.ts` asserts the two lists are the same set, so a provider
1564
+ * added to one and not the other fails the build rather than silently getting no
1565
+ * verification step and no tag.
1306
1566
  */
1307
- function resolveConfig(input, siteEnv = "production") {
1308
- const raw = stripNulls(input);
1309
- normaliseDesignSystem(raw);
1310
- const site = raw.site;
1311
- const envConfig = (site?.environments)?.[siteEnv];
1312
- if (envConfig?.variant) {
1313
- const overrides = (site?.abTesting)?.variants?.[envConfig.variant]?.overrides;
1314
- if (overrides) {
1315
- normaliseDesignSystem(overrides);
1316
- deepMerge(raw, overrides);
1317
- }
1318
- }
1319
- const result = validateConfig(raw);
1320
- if (!result.ok) {
1321
- const msg = result.errors.map((e) => ` ${e.path.join(".") || "<root>"}: ${e.message}`).join("\n");
1322
- throw new Error(`Invalid config:\n${msg}`);
1323
- }
1324
- return result.data;
1325
- }
1567
+ const AD_PROVIDERS = [
1568
+ "google",
1569
+ "meta",
1570
+ "linkedin",
1571
+ "reddit",
1572
+ "tiktok",
1573
+ "microsoft",
1574
+ "pinterest",
1575
+ "snapchat"
1576
+ ];
1577
+ const AdProviderSchema = z.enum(AD_PROVIDERS);
1326
1578
  /**
1327
- * Is this raw config a full `Config` rather than a bare `design-system.json`?
1579
+ * One ad property this site is linked to.
1328
1580
  *
1329
- * The discriminator is **total**, not a heuristic, and both halves are load-
1330
- * bearing: `ConfigSchema` cannot validate without a `designSystem` (it is a
1331
- * required key), and `DesignSystemSchema` is strict, so a `designSystem` key in a
1332
- * design system is an `unrecognized_keys` error. No document can be read as both,
1333
- * and no document that is neither reaches here without failing validation anyway.
1581
+ * Facts, not prose — the same rule the legal block follows. This records *which*
1582
+ * account and *which* tag id, and the platform capability table decides what can be
1583
+ * done about them. It is also what finally lets the rest of the toolchain see an ad
1584
+ * pixel: the generated cookie notice discloses its cookies, `vitops ads lint`
1585
+ * checks its click ID is captured, and the consent gate is handed the cookie names
1586
+ * to clear on revoke. A pixel pasted into a template is invisible to all three.
1587
+ */
1588
+ const AdPropertySchema = z.object({
1589
+ accountId: desc(z.optional(z.string()), "The advertising account as the platform shows it — Google Ads customer ID, Meta ad account, LinkedIn partner ID, Reddit advertiser ID. For LinkedIn this is also the Insight Tag id; everywhere else the tag id is `pixelId`."),
1590
+ pixelId: desc(z.optional(z.string()), "The tag/pixel ID the browser snippet initialises — Meta pixel, TikTok pixel, Reddit pixel, Microsoft UET tag ID, Pinterest tag ID, Snap pixel, or the Google Ads conversion ID (`AW-…`, which is NOT the customer ID)."),
1591
+ conversionLabel: desc(z.optional(z.string()), "Google Ads only: the conversion action label paired with the `AW-…` conversion ID."),
1592
+ domain: desc(z.optional(z.string()), "Bare hostname to verify with this platform. Defaults to the host of `domains.canonical` — set it only when the ad account is verified against a different domain."),
1593
+ domainVerification: desc(z.optional(z.string()), "The verification token from the platform UI, for the platforms that verify by DNS TXT (Meta, TikTok, Pinterest, Snapchat). `vitops ads setup` prompts for it and writes it here on first run. NOT a secret — it is published in DNS, and the platform fetching it back is the ownership proof, exactly like the IndexNow key. A value containing `=` is used as the whole record, which is the escape hatch if a platform changes its prefix."),
1594
+ category: desc(z.optional(z.enum(["marketing", "analytics"])), "Consent category the tag waits on (default `marketing`). An ad pixel is advertising by default; `analytics` is for a site using one purely for its own measurement."),
1595
+ enabled: desc(z.optional(z.boolean()), "Set false to keep the property on record without emitting its tag.")
1596
+ });
1597
+ /**
1598
+ * Which template set the generated legal documents are written against. This is
1599
+ * a closed enum rather than a free string because an unrecognised value would
1600
+ * otherwise silently fall back to the wrong body of law; `validateConfig` rejects
1601
+ * anything without a registered template. Add a member here and a matching entry
1602
+ * in `legal/templates/index.ts` — the two are checked against each other.
1603
+ */
1604
+ const JURISDICTIONS = ["ca"];
1605
+ const JurisdictionSchema = z.enum(JURISDICTIONS);
1606
+ /**
1607
+ * The country each template set frames cross-border transfers as *leaving*.
1334
1608
  *
1335
- * The restructure did not weaken this: `designSystem` stayed at the root
1336
- * precisely so the discriminator kept working unchanged.
1609
+ * A jurisdiction's own country is not a transfer, so this is what filters the
1610
+ * disclosure lists — without it, `country: "Canada"` renders the incoherent
1611
+ * "outside of Canada, including Canada".
1337
1612
  *
1338
- * Deliberately shape-based rather than filename-based: consumers name this file
1339
- * whatever suits them (`company.json`, `site.json`, `vitops.config.json`), and a
1340
- * rule keyed to a name would silently stop working the moment someone renamed it.
1613
+ * It belongs to the **jurisdiction**, not to `organization.address`: a `ca` policy
1614
+ * says "outside of Canada" even for a business incorporated elsewhere. (The
1615
+ * address drives a different sentence — the terms' governing-law clause, via
1616
+ * `governingCountry`.) `satisfies` makes a new jurisdiction unable to forget it,
1617
+ * the same guard style as `TEMPLATES`.
1341
1618
  */
1342
- function isConfig(raw) {
1343
- return typeof raw === "object" && raw != null && !Array.isArray(raw) && "designSystem" in raw;
1344
- }
1619
+ const JURISDICTION_COUNTRIES = { ca: "Canada" };
1345
1620
  /**
1346
- * Resolve a raw config of either kind into the design system to build from.
1347
- *
1348
- * Every entry point that used to take a `design-system.json` goes through this,
1349
- * so a consumer who keeps their tokens inside the larger config points the
1350
- * same option at that file instead of maintaining a second one. The config
1351
- * is handed back too, because the parts of generation that depend on site-level
1352
- * facts (`designSystem.defaultColorScheme`, the legal documents, the icon
1353
- * sprite) would otherwise need the same path declared a second time.
1621
+ * A third party that receives personal information. The generator derives the
1622
+ * ones it can see (analytics IDs, Turnstile, the deploy platform); this is for
1623
+ * the rest, which no other part of the config implies.
1354
1624
  *
1355
- * The returned `designSystem` is **not** validated here — the caller does that,
1356
- * so it can say which file and which theme the errors belong to. Validation of
1357
- * the config itself has already happened (`resolveConfig` throws).
1625
+ * Deliberately facts, not prose: the config records *what is true*, the template
1626
+ * owns *how it is said*. That keeps a policy correct when the wording changes.
1358
1627
  */
1359
- function resolveInput(raw, opts = {}) {
1360
- if (!isConfig(raw)) {
1361
- 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 full config to select a theme.`);
1362
- return { designSystem: raw };
1363
- }
1364
- const config = resolveConfig(raw, opts.siteEnv);
1365
- const themes = config.designSystem?.themes ?? {};
1366
- const theme = opts.theme ?? config.designSystem?.defaultTheme ?? "default";
1367
- if (!(theme in themes)) throw new Error(`designSystem.themes has no "${theme}" entry (found: ${Object.keys(themes).join(", ") || "none"})`);
1368
- return {
1369
- designSystem: resolveTheme(themes, theme),
1370
- config,
1371
- theme
1372
- };
1373
- }
1374
- /** Strip nulls (YAML `~`/`null`) → undefined so optional fields validate. */
1375
- function stripNulls(obj) {
1376
- if (obj === null) return void 0;
1377
- if (Array.isArray(obj)) return obj.map(stripNulls);
1378
- if (obj && typeof obj === "object") {
1379
- const result = {};
1380
- for (const [key, value] of Object.entries(obj)) {
1381
- const stripped = stripNulls(value);
1382
- if (stripped !== void 0) result[key] = stripped;
1383
- }
1384
- return result;
1385
- }
1386
- return obj;
1387
- }
1388
- /** Deep-merge `source` into `target` (in place). Arrays replace wholesale. */
1389
- function deepMerge(target, source) {
1390
- for (const key of Object.keys(source)) {
1391
- const s = source[key];
1392
- const t = target[key];
1393
- if (s && typeof s === "object" && !Array.isArray(s) && t && typeof t === "object" && !Array.isArray(t)) deepMerge(t, s);
1394
- else target[key] = s;
1395
- }
1396
- }
1397
-
1398
- //#endregion
1399
- //#region src/tokens.ts
1628
+ const ProcessorStorageSchema = z.object({
1629
+ country: desc(z.string(), "A country or bloc where this provider stores or processes the information, as it should read in a sentence (e.g. \"Canada\", \"the United States\", \"the European Union\"). Naming the jurisdiction the policy is written for is meaningful: it says this information does not leave it."),
1630
+ scope: desc(z.optional(z.string()), "Which information is held there, as a noun phrase that reads after \"in the case of\" (e.g. \"account and sign-in information\", \"mailbox contents\"). Omit when this location holds everything the provider receives — a scoped entry claims LESS than an unscoped one, so omitting it is the safe default.")
1631
+ });
1632
+ const ProcessorSchema = z.object({
1633
+ name: desc(z.string(), "The provider, as a reader would recognise it (e.g. \"Stripe\")."),
1634
+ purpose: desc(z.string(), "Why they receive it, as a noun phrase that reads after \"for\" (e.g. \"payment processing\")."),
1635
+ country: desc(z.optional(z.string()), "Shorthand for the common case where one country is both where they store it and whose laws reach it: asserts BOTH `storage: [{ country }]` AND `operatorCountry`. Reads inside a sentence (e.g. \"the United States\"). When the two differ — a Canadian region operated by a US company — state `storage` and `operatorCountry` instead; setting this alongside either is rejected."),
1636
+ storage: desc(z.optional(z.array(ProcessorStorageSchema)), "Where the information actually rests. Feeds the \"stored or processed outside of <jurisdiction>\" disclosure and nothing else. Several entries are allowed, each optionally scoped to a category of information — which is what makes a Canadian-region tenant holding identity data in the US expressible."),
1637
+ operatorCountry: desc(z.optional(z.string()), "The jurisdiction that can compel this provider to hand the information over — where it is established, or from which it is controlled. Reads after \"the laws of\" (e.g. \"the United States\"); a bloc is acceptable. A SEPARATE fact from `storage`, because privacy law cares about foreign *access*, not only foreign storage: a Canadian-region service run by a US company is `storage: [{ country: \"Canada\" }]` with `operatorCountry: \"the United States\"`."),
1638
+ privacyUrl: z.optional(z.url())
1639
+ });
1640
+ const SiteSectionSchema = z.object({
1641
+ defaultLocale: desc(z.string(), "The locale used when none is specified; must be a `locales` key."),
1642
+ locales: desc(z.record(z.string(), LocaleSchema), "Locales the site is published in, keyed by BCP 47 tag (e.g. \"en\", \"fr\")."),
1643
+ domains: desc(z.optional(z.object({
1644
+ canonical: desc(z.url(), "The canonical origin used for absolute URLs and SEO."),
1645
+ aliases: desc(z.optional(z.array(DomainAliasSchema)), "Alias domains and how they redirect to the canonical one.")
1646
+ })), "Canonical domain + redirecting aliases."),
1647
+ dns: desc(z.optional(z.record(z.string(), DnsDomainSchema)), "Desired DNS state per domain (provider, nameservers, records) — declarative reference, not applied automatically."),
1648
+ cloudflare: desc(z.optional(z.record(z.string(), z.unknown())), "Deliberate escape hatch for provider-specific Cloudflare settings — out of scope to model here."),
1649
+ searchConsole: desc(z.optional(z.record(z.string(), SearchConsoleSetupSchema)), "Domains to onboard as Google Search Console *domain properties* via `vitops search setup`, keyed by bare hostname (mirrors `dns`). Credentials come from the environment (CLOUDFLARE_API_TOKEN + the Google OAuth vars) — never in this file."),
1650
+ environments: desc(z.record(z.string(), EnvironmentSchema), "Deploy environments (production, dev, …): URL, API origin, analytics toggle, robots policy, active A/B variant."),
1651
+ abTesting: desc(z.optional(z.object({
1652
+ enabled: z.boolean(),
1653
+ cookieName: z.optional(z.string()),
1654
+ cookieMaxAge: desc(z.optional(z.number().check(z.int(), z.positive())), "Seconds."),
1655
+ splitRatio: desc(z.optional(z.number().check(z.gte(0), z.lte(1))), "Fraction of traffic sent to the variant (0–1)."),
1656
+ variants: desc(z.optional(z.record(z.string(), AbVariantSchema)), "Named variants; each targets an environment and may deep-merge config `overrides`.")
1657
+ })), "A/B testing: cookie-based split plus named variants whose `overrides` patch the config per environment."),
1658
+ 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`."),
1659
+ tags: desc(z.optional(z.record(z.string(), z.unknown())), "Free-form content-tag taxonomy (site-specific; not interpreted by the generator)."),
1660
+ postTypes: desc(z.optional(z.record(z.string(), z.unknown())), "Free-form content-type definitions (site-specific; not interpreted by the generator)."),
1661
+ galleries: desc(z.optional(z.record(z.string(), GallerySchema)), "Named image galleries (title, tags, images with localisable alt/caption)."),
1662
+ testimonials: desc(z.optional(z.record(z.string(), TestimonialGroupSchema)), "Named testimonial groups (quote, name, role, rating, date)."),
1663
+ templates: desc(z.optional(z.record(z.string(), TemplateSchema)), "Named content templates rendered at SSR time: `nav` (menu trees), `form` (field lists + submit), or `nodes` (raw element trees)."),
1664
+ navigation: desc(z.optional(z.object({ activeTemplate: desc(z.optional(z.object({
1665
+ default: desc(z.optional(z.string()), "A `templates` key."),
1666
+ breakpoints: desc(z.optional(z.record(z.string(), z.string())), "Breakpoint name → `templates` key overrides.")
1667
+ })), "Which nav template is active, optionally per breakpoint.") })), "Site navigation settings (which nav template renders where)."),
1668
+ seo: desc(z.optional(z.object({
1669
+ titleTemplate: desc(z.optional(z.string()), "Page-title pattern (e.g. \"%s · Acme\")."),
1670
+ descriptionTemplate: z.optional(z.string()),
1671
+ robots: desc(z.optional(RobotsSchema), "e.g. \"index,follow\" / \"noindex,nofollow\"."),
1672
+ googleSiteVerification: z.optional(z.string()),
1673
+ bingSiteVerification: z.optional(z.string()),
1674
+ openGraph: z.optional(OpenGraphSchema),
1675
+ twitter: z.optional(TwitterSchema),
1676
+ indexing: desc(z.optional(IndexingSchema), "How `vitops search notify` tells search engines about a deploy: which sitemap, IndexNow key, Search Console property, and which pages to verify afterwards.")
1677
+ })), "SEO defaults: title/description templates, robots policy, verification tokens, Open Graph + Twitter cards, and post-deploy indexing notification."),
1678
+ analytics: desc(z.optional(z.object({
1679
+ googleAnalyticsId: z.optional(z.string()),
1680
+ plausibleDomain: z.optional(z.string()),
1681
+ googleTagManagerId: z.optional(z.string()),
1682
+ clarityId: desc(z.optional(z.string()), "Microsoft Clarity project ID."),
1683
+ matomo: desc(z.optional(z.object({
1684
+ url: desc(z.string(), "Instance base URL (self-hosted, or `*.matomo.cloud`)."),
1685
+ siteId: desc(z.string(), "Site ID within that instance."),
1686
+ 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.")
1687
+ })), "Matomo instance.")
1688
+ })), "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`."),
1689
+ notifications: desc(z.optional(z.object({ email: desc(z.optional(z.union([z.email(), z.object({
1690
+ provider: desc(z.literal("cloudflare"), "Delivery provider. Only Cloudflare Email Sending is implemented; the field is stated so adding another is additive."),
1691
+ to: desc(z.optional(z.email()), "Recipient. Defaults to the primary location’s `email`, then the first location that has one."),
1692
+ from: desc(z.optional(z.email()), "Sender. Defaults to `noreply@<domains.canonical>`. Its domain must be onboarded (`wrangler email sending enable <domain>`) or every send fails."),
1693
+ fromName: desc(z.optional(z.string()), "Display name on the From header. Defaults to `organization.name`."),
1694
+ replyTo: desc(z.optional(z.email()), "Reply-To header."),
1695
+ binding: desc(z.optional(z.string()), "Workers `send_email` binding name (default `EMAIL`). Set it when using a restricted binding.")
1696
+ })])), "Where conversion notifications are e-mailed. A bare address is shorthand for `{ provider: \"cloudflare\", to }`.") })), "Where site notifications (form submissions, tracked calls) are sent. TODO: `sms` and `persist` channels — only `email` is implemented."),
1697
+ tracking: desc(z.optional(z.object({
1698
+ enabled: desc(z.optional(z.boolean()), "Capture ad click IDs and UTMs from the landing URL into the `_ac` cookie, for attributing conversions."),
1699
+ category: desc(z.optional(z.enum(["marketing", "analytics"])), "Consent category the `_ac` cookie waits on (default `marketing`). It is a 90-day identifier tying a visitor to the ad that brought them, so it is only written once this category is granted — and asking for it is what raises the banner."),
1700
+ platforms: desc(z.optional(z.array(z.string())), "Informational list of ad platforms in use. Capture recognises every known click-ID parameter regardless.")
1701
+ })), "Ad-click attribution for conversion tracking."),
1702
+ ads: desc(z.optional(z.partialRecord(AdProviderSchema, AdPropertySchema)), "Ad properties this site is linked to, keyed by platform. Read by `vitops ads setup` (ensure the DNS verification record), `vitops ads tags` (emit the consent-gated pixel), and the generated cookie notice (disclose what each pixel stores). Credentials never live here — the DNS write uses CLOUDFLARE_API_TOKEN from the environment."),
1703
+ security: desc(z.optional(z.object({ turnstile: z.optional(z.object({ siteKey: desc(z.optional(z.string()), "Cloudflare Turnstile site key (public).") })) })), "Security integrations (bot protection)."),
1704
+ legal: desc(z.optional(z.object({
1705
+ jurisdiction: desc(z.optional(JurisdictionSchema), "Which legal template set the generated documents use. Default `ca`."),
1706
+ privacyPolicy: z.optional(z.object({
1707
+ enabled: z.boolean(),
1708
+ url: z.optional(z.url()),
1709
+ lastUpdated: z.optional(IsoDate),
1710
+ privacyOfficer: desc(z.optional(ContactConfigSchema), "Who privacy requests go to. Falls back to `contact`, then `primaryLocation`."),
1711
+ retention: desc(z.optional(z.string()), "How long personal information is kept, in prose (e.g. \"24 months after last contact\")."),
1712
+ processors: desc(z.optional(z.array(ProcessorSchema)), "Third parties that receive personal information and cannot be inferred from the rest of the config (payment processors, CRMs, mail senders). Known providers implied by `analytics`, `security` and `deployment` are added automatically — list only the rest.")
1713
+ })),
1714
+ termsOfService: z.optional(z.object({
1715
+ enabled: z.boolean(),
1716
+ url: z.optional(z.url()),
1717
+ lastUpdated: z.optional(z.nullable(IsoDate)),
1718
+ governingLaw: desc(z.optional(z.string()), "Governing-law clause, in prose (e.g. \"the Province of Ontario\"). Defaults from the contact address.")
1719
+ })),
1720
+ cookieConsent: z.optional(z.object({
1721
+ enabled: z.boolean(),
1722
+ type: desc(z.optional(z.enum(["opt-in", "opt-out"])), "Consent model."),
1723
+ position: z.optional(z.enum([
1724
+ "top",
1725
+ "bottom",
1726
+ "center"
1727
+ ])),
1728
+ categories: z.optional(z.array(z.string()))
1729
+ }))
1730
+ })), "Legal pages: which documents exist, where they live, and the facts the generated prose asserts."),
1731
+ icons: desc(z.optional(z.looseObject({
1732
+ ui: desc(z.optional(z.string()), "Icon set used for UI chrome (e.g. \"lucide\", \"ph\")."),
1733
+ brand: desc(z.optional(z.string()), "Icon set used for brand marks."),
1734
+ 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."),
1735
+ 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)."),
1736
+ semantic: desc(z.optional(z.array(z.string())), "Named semantic icons to include."),
1737
+ "fa7-solid": z.optional(z.array(z.string())),
1738
+ "fa7-regular": z.optional(z.array(z.string())),
1739
+ "fa7-light": z.optional(z.array(z.string())),
1740
+ "fa7-thin": z.optional(z.array(z.string())),
1741
+ "fa7-brands": z.optional(z.array(z.string())),
1742
+ "simple-icons": z.optional(z.array(z.string())),
1743
+ "material-symbols": z.optional(z.array(z.string())),
1744
+ lucide: z.optional(z.array(z.string())),
1745
+ ph: z.optional(z.array(z.string()))
1746
+ })), "Icon sets and the specific icons to bundle from each (keys are iconify collection names)."),
1747
+ favicon: desc(z.optional(z.object({
1748
+ source: desc(z.string(), "Source image (SVG/PNG) the favicon set is generated from."),
1749
+ lowResSource: desc(z.optional(z.string()), "Alternate source for small raster sizes (16/32px) when the main source scales down poorly."),
1750
+ name: desc(z.optional(z.string()), "App name for the generated web manifest. Setting this AND `themeColor` is what makes the site installable: together they emit `site.webmanifest` (with `display: \"standalone\"`) and link it. Neither alone does anything. A marketing site that does not want a browser offering to install it should leave one of them unset."),
1751
+ themeColor: desc(z.optional(z.string()), "Browser UI colour, emitted as `<meta name=\"theme-color\">` on every page — so it applies with or without a manifest. Note it is also half the manifest switch: see `name`."),
1752
+ backgroundColor: desc(z.optional(z.string()), "Background composited under the OPAQUE generated icons — `apple-touch-icon.png` and the maskable `icon-mask.png` — which are produced whether or not there is a manifest. Defaults to white, so a dark logo on a transparent source needs this set. It is also the manifest `background_color` when one is emitted, but it is not manifest-only.")
1753
+ })), "Favicon/PWA asset generation (consumed by `@getvitops/utils` favicon tooling)."),
1754
+ deployment: desc(z.optional(z.object({
1755
+ platform: z.optional(z.string()),
1756
+ buildCommand: z.optional(z.string()),
1757
+ deployCommand: z.optional(z.string()),
1758
+ outputDirectory: z.optional(z.string()),
1759
+ type: z.optional(z.string())
1760
+ })), "How the site is built and deployed (platform, commands, output directory) — informational for tooling.")
1761
+ });
1762
+ const OrganizationSectionSchema = z.extend(OrganizationSchema, {
1763
+ contact: desc(z.optional(ContactConfigSchema), "Primary contact: either a `locations` key (string reference) or an inline name/email/phone/address object."),
1764
+ primaryLocation: desc(z.optional(z.string()), "The main `locations` key (for JSON-LD and defaults)."),
1765
+ locations: desc(z.optional(z.record(z.string(), LocationSchema)), "Physical locations (schema.org LocalBusiness): address, geo, opening hours, service area."),
1766
+ services: desc(z.optional(z.record(z.string(), ServiceSchema)), "Services offered (schema.org Service/Offer): name, description, slug, price offers."),
1767
+ links: desc(z.optional(z.object({
1768
+ googleMaps: z.optional(z.url()),
1769
+ instagram: z.optional(z.url()),
1770
+ facebook: z.optional(z.url()),
1771
+ x: z.optional(z.url()),
1772
+ linkedin: z.optional(z.url()),
1773
+ youtube: z.optional(z.url()),
1774
+ github: z.optional(z.url())
1775
+ })), "Public profile URLs (also feed JSON-LD `sameAs` via `organization.sameAs`).")
1776
+ });
1777
+ const ConfigSchema = z.object({
1778
+ designSystem: DesignSystemBlock,
1779
+ organization: desc(z.optional(OrganizationSectionSchema), "The company: schema.org Organization details for JSON-LD (name, legalName, logo, tax IDs, sameAs), plus its contact, physical locations, services and public profiles. These stay true across sites — several sites can share one `organization` and differ only in `site`."),
1780
+ site: desc(SiteSectionSchema, "This published site: locales, domains, environments, content templates, SEO, analytics, legal documents, icons, favicon and deployment. Facts about a *presentation* of the organization rather than the organization itself.")
1781
+ });
1782
+ const CONFIG_SCHEMA_URL = "https://unpkg.com/@getvitops/generator/config.schema.json";
1783
+ /** The published JSON Schema (draft-2020-12), derived from the zod schema. */
1784
+ const configJsonSchema = {
1785
+ $id: CONFIG_SCHEMA_URL,
1786
+ ...z.toJSONSchema(ConfigSchema, { target: "draft-2020-12" })
1787
+ };
1788
+ const issue = (path, message) => ({
1789
+ code: "custom",
1790
+ path,
1791
+ message
1792
+ });
1400
1793
  /**
1401
- * Palette expansion + semantic colour tokens.
1402
- *
1403
- * A palette hue is authored one of two ways:
1404
- * • `{ seed, anchors? }` — an 11-step numeric scale (50…950) generated in
1405
- * OKLCH via @getvitops/utils/color. Lightness comes from the shared
1406
- * `LIGHTNESS_LADDER`; the seed (and any anchors) are reproduced verbatim at
1407
- * their steps.
1408
- * • `{ tones }` — a fixed brand kit: authored tones placed verbatim at their
1409
- * nearest steps + tinted endpoints; nothing else is invented (strict kits).
1410
- *
1411
- * **The grammar.** Every role gets tokens named `--color-<target>-<role>[-<variant>]`,
1412
- * where target ∈ bg | text | icon | border. Putting the target *inside* the name is
1413
- * the point: `--color-bg-danger-muted` and `--color-text-danger-muted` are distinct
1414
- * tokens, so the two axes that used to collide over one `<family>-<role>-<modifier>
1415
- * ` namespace cannot any more. The utility class is the token name minus `--color-`.
1416
- *
1417
- * Variants are ordinal — xx-muted < x-muted < muted < (bare) < bold < x-bold —
1418
- * and the tables are **sparse**: a target only emits the cells that are actually
1419
- * viable. `bold` means "more emphatic than the role's default in the current
1420
- * appearance", not "darker"; the appearance decides polarity.
1421
- *
1422
- * Roles come in two kinds (declared in `colors.roles`):
1423
- * • `surface` — a page/panel colour. Has a bare `bg-<role>` plus the full
1424
- * emphasis range and the complete text scale.
1425
- * • `chromatic` (default) — a signal colour. Backgrounds split into *tints*
1426
- * (`bg-<role>-x-muted` / `-muted`) and *solids* (`bg-<role>-solid[-bold|-x-bold]`);
1427
- * there is deliberately **no bare `bg-<role>`**, because "how loud?" is a
1428
- * question the author has to answer.
1794
+ * Where each key of the old flat config now lives.
1429
1795
  *
1430
- * Dark mode re-points which step each token reads; the solid family and the
1431
- * `text-on-<role>` foreground computed against it stay mode-stable, so a filled
1432
- * button keeps its identity across appearances.
1433
- */
1434
- const NUMERIC_STEPS = [
1435
- "50",
1436
- "100",
1437
- "200",
1438
- "300",
1439
- "400",
1440
- "500",
1441
- "600",
1442
- "700",
1443
- "800",
1444
- "900",
1445
- "950"
1446
- ];
1447
- /**
1448
- * Lightness of each numeric step. Re-exported from the engine's ladder rather
1449
- * than restated — there used to be three copies of this curve (here, the engine,
1450
- * and the linter) and they were free to drift.
1796
+ * This exists so the restructure fails with a sentence a reader can act on. Zod
1797
+ * reports a flat config as a dozen separate `unrecognized_keys` issues plus a
1798
+ * missing required `site` — an accurate description of the document that teaches
1799
+ * nobody where anything went. Checked first, and it short-circuits: once we know
1800
+ * the document is the old shape, every other error is downstream noise.
1451
1801
  */
1452
- const STEP_L = Object.fromEntries(NUMERIC_STEPS.map((s) => [s, LIGHTNESS_LADDER[Number(s)]]));
1453
- const isHex = (v) => v.startsWith("#");
1454
- const toOklchStr = (v) => {
1455
- if (!isHex(v)) return v;
1456
- const { l, c, h } = hexToOklch(v);
1457
- return `oklch(${(l * 100).toFixed(2)}% ${c.toFixed(4)} ${h.toFixed(2)})`;
1802
+ const MOVED_KEYS = {
1803
+ defaultLocale: "site",
1804
+ locales: "site",
1805
+ domains: "site",
1806
+ dns: "site",
1807
+ cloudflare: "site",
1808
+ environments: "site",
1809
+ abTesting: "site",
1810
+ fonts: "site",
1811
+ tags: "site",
1812
+ postTypes: "site",
1813
+ galleries: "site",
1814
+ testimonials: "site",
1815
+ templates: "site",
1816
+ navigation: "site",
1817
+ seo: "site",
1818
+ analytics: "site",
1819
+ notifications: "site",
1820
+ tracking: "site",
1821
+ security: "site",
1822
+ legal: "site",
1823
+ icons: "site",
1824
+ favicon: "site",
1825
+ deployment: "site",
1826
+ contact: "organization",
1827
+ primaryLocation: "organization",
1828
+ locations: "organization",
1829
+ services: "organization",
1830
+ links: "organization"
1458
1831
  };
1459
- const nearestStep = (l) => NUMERIC_STEPS.reduce((best, s) => Math.abs(l - STEP_L[s]) < Math.abs(l - STEP_L[best]) ? s : best);
1460
1832
  /**
1461
- * Claim `step` for `value`, refusing to overwrite a step another colour already
1462
- * took.
1833
+ * Detect the pre-3.0 flat shape and name the moves.
1463
1834
  *
1464
- * Silently dropping one of two colliding tones is the worst outcome: the palette
1465
- * still builds, and the tone the author cared about is simply gone. The record
1466
- * form (`tones: { "600": "#…", "700": "#…" }`) is the escape hatch, so the error
1467
- * points at it.
1835
+ * Keyed on a moved key sitting at the ROOT, not on the absence of `site` — a
1836
+ * config that simply forgot `site` should get the ordinary "required" error, and
1837
+ * a partially-migrated one (some keys moved, some not) is exactly the case worth
1838
+ * catching loudly.
1468
1839
  */
1469
- function claim(into, step, value, hueLabel, what) {
1470
- const taken = into[step];
1471
- if (taken != null && taken !== value) throw new Error(`palette hue "${hueLabel}": ${what} ${value} and ${taken} both map to step ${step}. Key them explicitly, e.g. { "${step}": "${taken}", "${Number(step) + 100}": "${value}" }.`);
1472
- into[step] = value;
1840
+ function migrationIssues(raw) {
1841
+ if (typeof raw !== "object" || raw == null || Array.isArray(raw)) return [];
1842
+ const found = Object.keys(raw).filter((k) => k in MOVED_KEYS);
1843
+ if (found.length === 0) return [];
1844
+ return [issue([], `this is the pre-3.0 flat site config. The top level is now three sections — \`designSystem\`, \`organization\`, \`site\` — and these keys moved:
1845
+ ${found.map((k) => ` ${k} → ${MOVED_KEYS[k]}.${k}`).join("\n")}\n\`designSystem\` and the fields already under \`organization\` stay where they are.`)];
1473
1846
  }
1474
- function expandSeeded(input, hueLabel) {
1475
- const anchors = {};
1476
- for (const [key, value] of Object.entries(input.anchors ?? {})) claim(anchors, NUMERIC_STEPS.includes(key) ? key : nearestStep(hexToOklch(value).l), toOklchStr(value), hueLabel, "anchors");
1477
- const seedStep = nearestStep(hexToOklch(input.seed).l);
1478
- anchors[seedStep] ??= toOklchStr(input.seed);
1479
- const scale = generateOklchPalette(anchors);
1480
- const numeric = {};
1481
- for (const s of NUMERIC_STEPS) if (scale[s] != null) numeric[s] = oklchStringToHex(scale[s]);
1482
- return {
1483
- numeric,
1484
- deviations: deviationsFor(anchors)
1847
+ /**
1848
+ * Validate a raw (already parsed) config: shape via zod, then the cross-field
1849
+ * integrity that JSON Schema can't express (referential keys, extends acyclicity).
1850
+ * These live only here — the published JSON Schema enforces shape, not integrity.
1851
+ */
1852
+ function validateConfig(input) {
1853
+ const migration = migrationIssues(input);
1854
+ if (migration.length) return {
1855
+ ok: false,
1856
+ data: void 0,
1857
+ errors: migration
1858
+ };
1859
+ const parsed = z.safeParse(ConfigSchema, input);
1860
+ if (!parsed.success) return {
1861
+ ok: false,
1862
+ data: void 0,
1863
+ errors: parsed.error.issues
1864
+ };
1865
+ const cfg = parsed.data;
1866
+ const site = cfg.site;
1867
+ const org = cfg.organization;
1868
+ const errors = [];
1869
+ const has = (m, k) => !!m && k in m;
1870
+ if (!has(site.locales, site.defaultLocale)) errors.push(issue(["site", "defaultLocale"], `defaultLocale "${site.defaultLocale}" is not in locales`));
1871
+ const themes = cfg.designSystem?.themes ?? {};
1872
+ const defaultTheme = cfg.designSystem?.defaultTheme;
1873
+ if (!has(themes, "default") && defaultTheme == null) errors.push(issue(["designSystem", "themes"], "themes must include a \"default\" entry"));
1874
+ if (defaultTheme != null && !has(themes, defaultTheme)) errors.push(issue(["designSystem", "defaultTheme"], `defaultTheme "${defaultTheme}" is not in designSystem.themes`));
1875
+ for (const [key, entry] of Object.entries(themes)) if (entry.extends != null && !has(themes, entry.extends)) errors.push(issue([
1876
+ "designSystem",
1877
+ "themes",
1878
+ key,
1879
+ "extends"
1880
+ ], `extends "${entry.extends}" is not a themes key`));
1881
+ for (const key of Object.keys(themes)) try {
1882
+ resolveTheme(themes, key);
1883
+ } catch (e) {
1884
+ errors.push(issue([
1885
+ "designSystem",
1886
+ "themes",
1887
+ key
1888
+ ], e.message));
1889
+ }
1890
+ if (org?.primaryLocation != null && !has(org.locations, org.primaryLocation)) errors.push(issue(["organization", "primaryLocation"], `primaryLocation "${org.primaryLocation}" is not in organization.locations`));
1891
+ if (typeof org?.contact === "string" && !has(org.locations, org.contact)) errors.push(issue(["organization", "contact"], `contact "${org.contact}" is not an organization.locations key`));
1892
+ for (const [name, v] of Object.entries(site.abTesting?.variants ?? {})) if (!has(site.environments, v.environment)) errors.push(issue([
1893
+ "site",
1894
+ "abTesting",
1895
+ "variants",
1896
+ name,
1897
+ "environment"
1898
+ ], `environment "${v.environment}" is not in site.environments`));
1899
+ for (const [i, a] of (site.domains?.aliases ?? []).entries()) if (a.environment != null && !has(site.environments, a.environment)) errors.push(issue([
1900
+ "site",
1901
+ "domains",
1902
+ "aliases",
1903
+ i,
1904
+ "environment"
1905
+ ], `environment "${a.environment}" is not in site.environments`));
1906
+ const templates = site.templates;
1907
+ const at = site.navigation?.activeTemplate;
1908
+ if (at?.default != null && !has(templates, at.default)) errors.push(issue([
1909
+ "site",
1910
+ "navigation",
1911
+ "activeTemplate",
1912
+ "default"
1913
+ ], `template "${at.default}" is not in site.templates`));
1914
+ for (const [bp, tpl] of Object.entries(at?.breakpoints ?? {})) if (!has(templates, tpl)) errors.push(issue([
1915
+ "site",
1916
+ "navigation",
1917
+ "activeTemplate",
1918
+ "breakpoints",
1919
+ bp
1920
+ ], `template "${tpl}" is not in site.templates`));
1921
+ const privacy = site.legal?.privacyPolicy;
1922
+ /**
1923
+ * `country` is shorthand for BOTH `storage` and `operatorCountry`, so combining it
1924
+ * with either is genuinely ambiguous — is the shorthand narrowing the explicit
1925
+ * fact, or adding to it? Both readings are plausible and they make *contradictory
1926
+ * legal claims*, so this is rejected rather than resolved by a silent rule.
1927
+ *
1928
+ * Checked unconditionally, not gated on `privacy.enabled`: the fact is wrong
1929
+ * either way, and a document turned on later shouldn't surface it for the first
1930
+ * time. Here rather than as a `z.refine` on the schema because parse-time
1931
+ * rejection would make `resolveConfig` throw on the `vitops legal` path, which
1932
+ * has no formatting for a zod issue — `validateConfig` is where every other
1933
+ * cross-field legal requirement already lives, and it prints `path: message`.
1934
+ */
1935
+ for (const [i, p] of (privacy?.processors ?? []).entries()) if (p.country != null && (p.storage != null || p.operatorCountry != null)) errors.push(issue([
1936
+ "site",
1937
+ "legal",
1938
+ "privacyPolicy",
1939
+ "processors",
1940
+ i,
1941
+ "country"
1942
+ ], `processor "${p.name}": \`country\` is shorthand for BOTH \`storage\` and \`operatorCountry\`, so combining it with either makes the narrower claim ambiguous — drop \`country\` and state the two facts, or drop the two and keep the shorthand`));
1943
+ if (typeof privacy?.privacyOfficer === "string" && !has(org?.locations, privacy.privacyOfficer)) errors.push(issue([
1944
+ "site",
1945
+ "legal",
1946
+ "privacyPolicy",
1947
+ "privacyOfficer"
1948
+ ], `privacyOfficer "${privacy.privacyOfficer}" is not an organization.locations key`));
1949
+ if (privacy?.enabled) {
1950
+ if (resolvePrivacyContact(cfg) == null) errors.push(issue([
1951
+ "site",
1952
+ "legal",
1953
+ "privacyPolicy"
1954
+ ], "privacyPolicy.enabled requires a contact for privacy requests — set site.legal.privacyPolicy.privacyOfficer, organization.contact, or organization.email/address"));
1955
+ if (site.domains?.canonical == null) errors.push(issue([
1956
+ "site",
1957
+ "legal",
1958
+ "privacyPolicy"
1959
+ ], "privacyPolicy.enabled requires site.domains.canonical — the policy states where information is held"));
1960
+ }
1961
+ const indexing = site.seo?.indexing;
1962
+ if (indexing != null && site.domains?.canonical == null) {
1963
+ if (indexing.sitemapUrl == null) errors.push(issue([
1964
+ "site",
1965
+ "seo",
1966
+ "indexing",
1967
+ "sitemapUrl"
1968
+ ], "site.seo.indexing needs site.domains.canonical to derive the sitemap URL — set one, or state sitemapUrl explicitly"));
1969
+ if (indexing.indexNow != null && indexing.indexNow.keyLocation == null) errors.push(issue([
1970
+ "site",
1971
+ "seo",
1972
+ "indexing",
1973
+ "indexNow",
1974
+ "keyLocation"
1975
+ ], "site.seo.indexing.indexNow needs site.domains.canonical to derive the key location — set one, or state keyLocation explicitly"));
1976
+ }
1977
+ for (const [provider, entry] of Object.entries(site.ads ?? {})) {
1978
+ if (entry == null) continue;
1979
+ const platform = AD_PLATFORMS[provider];
1980
+ const at = [
1981
+ "site",
1982
+ "ads",
1983
+ provider
1984
+ ];
1985
+ if (entry.domainVerification != null && platform.verification.method === "none") errors.push(issue([...at, "domainVerification"], `${platform.name} has no domain verification — ${platform.verification.reason ?? "nothing verifies a domain there"}. Remove the token; linking is the tag and the account id.`));
1986
+ if (entry.domainVerification != null && entry.domain == null && site.domains?.canonical == null) errors.push(issue([...at, "domain"], `site.ads.${provider}.domainVerification needs a domain to put the record on — set site.domains.canonical, or state site.ads.${provider}.domain explicitly`));
1987
+ if (entry.accountId == null && entry.pixelId == null) errors.push(issue(at, `site.ads.${provider} identifies nothing — set accountId (the advertising account) and/or pixelId (the tag: ${platform.tag.needs} is what its snippet needs)`));
1988
+ }
1989
+ if (errors.length) return {
1990
+ ok: false,
1991
+ data: void 0,
1992
+ errors
1485
1993
  };
1486
- }
1487
- function expandTones(input, hueLabel) {
1488
- const entries = Array.isArray(input.tones) ? input.tones.map((hex) => [null, hex]) : Object.entries(input.tones).map(([k, hex]) => [k, hex]);
1489
- const placed = {};
1490
- for (const [key, hex] of entries) claim(placed, key != null && NUMERIC_STEPS.includes(key) ? key : nearestStep(hexToOklch(hex).l), hex, hueLabel, "tones");
1491
- const numeric = {};
1492
- for (const [step, hex] of Object.entries(placed)) numeric[step] = hex;
1493
- const ends = tintedEndpoints(hexToOklch(entries[0]?.[1] ?? "#808080").h);
1494
- numeric["50"] ??= ends.light;
1495
- numeric["950"] ??= ends.dark;
1496
1994
  return {
1497
- numeric,
1498
- sparse: true,
1499
- deviations: deviationsFor(placed)
1995
+ ok: true,
1996
+ data: cfg,
1997
+ errors: []
1500
1998
  };
1501
1999
  }
1502
- /** How far each pinned colour sits from its step's ladder lightness. */
1503
- function deviationsFor(pinned) {
1504
- const out = {};
1505
- for (const [step, value] of Object.entries(pinned)) out[step] = (isHex(value) ? hexToOklch(value).l : hexToOklch(oklchStringToHex(value)).l) - STEP_L[step];
1506
- return out;
1507
- }
1508
- /** Snap to the nearest AVAILABLE numeric step (sparse kits). */
1509
- function snap(numeric, want) {
1510
- if (numeric[want] != null) return want;
1511
- return NUMERIC_STEPS.filter((s) => numeric[s] != null).reduce((best, s) => Math.abs(STEP_L[s] - STEP_L[want]) < Math.abs(STEP_L[best] - STEP_L[want]) ? s : best);
1512
- }
1513
- /** Expand every palette hue into its numeric scale. */
1514
- function expandPalette(raw) {
1515
- const out = {};
1516
- for (const [name, entry] of Object.entries(raw)) {
1517
- const e = entry;
1518
- if (typeof e?.seed === "string") out[name] = expandSeeded(e, name);
1519
- else if (e?.tones != null) out[name] = expandTones(e, name);
1520
- else throw new Error(`palette hue "${name}" must have a "seed" or "tones"`);
1521
- }
1522
- return out;
1523
- }
1524
- /**
1525
- * Warn about pinned colours that sit noticeably off the ladder. Not an error:
1526
- * the author asked for that exact colour and gets it. But at ~0.03 L the hue
1527
- * starts reading visibly heavier or lighter than its siblings at that step, and
1528
- * that is the class of drift the fixed ladder exists to prevent.
1529
- */
1530
- const LADDER_TOLERANCE = .03;
1531
- function ladderWarnings(palette) {
1532
- const warnings = [];
1533
- for (const [hue, expanded] of Object.entries(palette)) for (const [step, deviation] of Object.entries(expanded.deviations ?? {})) {
1534
- if (Math.abs(deviation) <= .03) continue;
1535
- const dir = deviation > 0 ? "lighter" : "darker";
1536
- warnings.push(`colors.palette.${hue}: the colour pinned at step ${step} is ${Math.abs(deviation).toFixed(3)} ${dir} than the shared lightness ladder — this hue will read ${dir} than others at ${step}. Legal, but move it to a neighbouring step if that wasn't intended.`);
1537
- }
1538
- return warnings;
1539
- }
1540
- /**
1541
- * Gamut mapping nudges lightness by a hair, so an exact `<` comparison would
1542
- * report rounding as an inversion. Well below any deviation a person could see.
1543
- */
1544
- const MONOTONIC_EPSILON = .001;
1545
2000
  /**
1546
- * Ramps whose lightness does not fall monotonically from 50 to 950.
2001
+ * Resolve who privacy requests go to: the explicit privacy officer, else the
2002
+ * organization's contact, else its primary location, else the organization itself.
1547
2003
  *
1548
- * A ramp built from the ladder alone cannot invert — only pinned colours (a seed,
1549
- * an `anchor`, a `tones` entry) can, because they are the one thing allowed off
1550
- * it. `ladderWarnings` will not catch it: it compares each pinned step against
1551
- * *its own* ladder rung, never against its neighbours, so it reports the wrong
1552
- * defect ("reads darker than others at 600") while the real problem is that 600
1553
- * is darker than 700. And since `LADDER_TOLERANCE` is 0.03 — the same size as the
1554
- * ladder's own 50→100 gap — two anchors can each sit within tolerance and still
1555
- * invert, with no warning at all.
2004
+ * Exported because `validateConfig` and the legal-document derivation must agree
2005
+ * on it exactly — a config that validates must be one the policy can render, and
2006
+ * two copies of this precedence would drift into a policy with a blank address.
1556
2007
  *
1557
- * This is an error rather than a warning because the rest of the system assumes
1558
- * the order: `snap` picks the nearest step by ladder lightness, the mode-stable
1559
- * solid family hard-codes 600/700/800 as increasingly dark, and both dark tables
1560
- * re-point steps on the same assumption. An inverted ramp therefore runs hover
1561
- * states backwards — the failure the fixed ladder was introduced to eliminate.
2008
+ * Note every candidate but the first now lives in `organization`, and the first
2009
+ * (`site.legal.privacyPolicy.privacyOfficer`) still dereferences against
2010
+ * `organization.locations` — the officer is a person at the company, named by the
2011
+ * site because a company may publish more than one policy.
1562
2012
  */
1563
- function monotonicityErrors(palette) {
1564
- const errors = [];
1565
- for (const [hue, expanded] of Object.entries(palette)) {
1566
- const numeric = expanded.numeric ?? {};
1567
- const steps = NUMERIC_STEPS.filter((s) => numeric[s] != null);
1568
- for (let i = 1; i < steps.length; i++) {
1569
- const [prev, step] = [steps[i - 1], steps[i]];
1570
- const lPrev = hexToOklch(numeric[prev]).l;
1571
- const lStep = hexToOklch(numeric[step]).l;
1572
- if (lStep < lPrev + MONOTONIC_EPSILON) continue;
1573
- const pinned = expanded.deviations ?? {};
1574
- const culprits = [prev, step].filter((s) => pinned[s] != null);
1575
- const fix = culprits.length ? culprits.map((s) => {
1576
- const l = hexToOklch(numeric[s]).l;
1577
- return `${numeric[s]} (pinned at ${s}) sits at L ${l.toFixed(3)}, nearest step ${nearestStep(l)}`;
1578
- }).join("; ") : `no step here is pinned, so this is a bug in the generator rather than in your config`;
1579
- 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}.`);
2013
+ function resolvePrivacyContact(cfg) {
2014
+ const org = cfg.organization;
2015
+ const deref = (c) => {
2016
+ if (c == null) return void 0;
2017
+ if (typeof c !== "string") return c;
2018
+ const loc = org?.locations?.[c];
2019
+ return loc && {
2020
+ email: loc.email,
2021
+ phone: loc.phone,
2022
+ address: loc.address
2023
+ };
2024
+ };
2025
+ return [
2026
+ deref(cfg.site.legal?.privacyPolicy?.privacyOfficer),
2027
+ deref(org?.contact),
2028
+ deref(org?.primaryLocation),
2029
+ org && {
2030
+ email: org.email,
2031
+ phone: org.phone,
2032
+ address: org.address
1580
2033
  }
1581
- }
1582
- return errors;
2034
+ ].find((c) => c != null && (c.email != null || c.address != null));
1583
2035
  }
1584
2036
  /**
1585
- * Surface roles: a bare `bg` (cards, panels, inputs), `bg-muted` for the page
1586
- * behind them, `bg-x-muted` for wells, and `bg-bold`/`-x-bold` for the inverse
1587
- * surface a tooltip sits on. Elevation is expressed by *which* token you reach
1588
- * for — page `bg-muted`, card `bg` — rather than by a raised/sunken pair. That
1589
- * is what lets a future `data-surfaces` axis flatten it without touching markup.
1590
- */
1591
- const SURFACE_LIGHT = {
1592
- bg: "50",
1593
- "bg-muted": "100",
1594
- "bg-x-muted": "200",
1595
- "bg-bold": "800",
1596
- "bg-x-bold": "950",
1597
- text: "900",
1598
- "text-bold": "950",
1599
- "text-muted": "700",
1600
- "text-x-muted": "500",
1601
- "text-xx-muted": "400",
1602
- "text-on-bold": "50",
1603
- icon: "600",
1604
- "icon-muted": "500",
1605
- "border-muted": "200",
1606
- border: "300",
1607
- "border-bold": "600"
1608
- };
1609
- const SURFACE_DARK = {
1610
- bg: "900",
1611
- "bg-muted": "950",
1612
- "bg-x-muted": "950",
1613
- "bg-bold": "200",
1614
- "bg-x-bold": "50",
1615
- text: "100",
1616
- "text-bold": "50",
1617
- "text-muted": "300",
1618
- "text-x-muted": "500",
1619
- "text-xx-muted": "600",
1620
- "text-on-bold": "950",
1621
- icon: "400",
1622
- "icon-muted": "500",
1623
- "border-muted": "800",
1624
- border: "700",
1625
- "border-bold": "400"
1626
- };
1627
- /**
1628
- * Chromatic roles: tints for alert/badge backgrounds, solids for fills. No bare
1629
- * `bg` — see the module docblock. No `text-muted` either: a de-emphasised
1630
- * coloured text could not hold its contrast target off a white surface, so the
1631
- * way to soften coloured text is weight/size, not another colour.
2037
+ * Resolve a design-system theme by following its `extends` chain and deep-merging
2038
+ * base → child (child wins; arrays replace wholesale, correct for token overrides).
2039
+ * Throws on unknown keys or an `extends` cycle. The result is a plain
2040
+ * `DesignSystem` (validate it against `DesignSystemSchema` for completeness).
1632
2041
  */
1633
- const CHROMATIC_LIGHT = {
1634
- "bg-x-muted": "50",
1635
- "bg-muted": "100",
1636
- text: "700",
1637
- "text-bold": "900",
1638
- icon: "600",
1639
- border: "200",
1640
- "border-bold": "300"
1641
- };
1642
- const CHROMATIC_DARK = {
1643
- "bg-x-muted": "950",
1644
- "bg-muted": "900",
1645
- text: "300",
1646
- "text-bold": "200",
1647
- icon: "400",
1648
- border: "800",
1649
- "border-bold": "700"
1650
- };
2042
+ function resolveTheme(designSystem, key) {
2043
+ const chain = [];
2044
+ const seen = /* @__PURE__ */ new Set();
2045
+ let cur = key;
2046
+ while (cur != null) {
2047
+ if (seen.has(cur)) throw new Error(`extends cycle in designSystem at "${cur}"`);
2048
+ seen.add(cur);
2049
+ const entry = designSystem[cur];
2050
+ if (entry == null) throw new Error(`designSystem theme "${cur}" not found`);
2051
+ chain.unshift(entry);
2052
+ cur = entry.extends;
2053
+ }
2054
+ const out = {};
2055
+ for (const entry of chain) {
2056
+ const { extends: _e, displayName: _d, ...ds } = entry;
2057
+ deepMerge(out, ds);
2058
+ }
2059
+ return out;
2060
+ }
1651
2061
  /**
1652
- * The solid family — mode-stable, so a filled button keeps its identity when the
1653
- * appearance flips. Fixed steps rather than the old scan-and-clamp: with a shared
1654
- * lightness ladder every hue's 600 already sits at the same lightness, so there
1655
- * is nothing left to search for. `text-on-<role>` is computed against `bg-solid`.
2062
+ * Normalise the two `designSystem` shorthands to the canonical
2063
+ * `{ themes: { … } }` shape.
2064
+ *
2065
+ * Both are keyed off what CANNOT be a theme name in the other reading:
2066
+ * - `colors` → a bare `DesignSystem` was written inline.
2067
+ * - `themes` → already canonical.
2068
+ * - neither → the legacy bare theme map, from before the block gained
2069
+ * system-wide fields. Every config authored against the old schema takes this
2070
+ * branch, which is what keeps the change forgiving at runtime even though the
2071
+ * published JSON Schema moved.
1656
2072
  */
1657
- const SOLID = {
1658
- "bg-solid": "600",
1659
- "bg-solid-bold": "700",
1660
- "bg-solid-x-bold": "800"
1661
- };
2073
+ function normaliseDesignSystem(raw) {
2074
+ const ds = raw.designSystem;
2075
+ if (ds == null || typeof ds !== "object") return;
2076
+ if ("themes" in ds) return;
2077
+ raw.designSystem = "colors" in ds ? { themes: { default: ds } } : { themes: ds };
2078
+ }
1662
2079
  /**
1663
- * Map a token key to its CSS custom property for a given role.
2080
+ * Resolve a raw (already-parsed) config into a validated `Config`:
2081
+ * 1. strip YAML nulls,
2082
+ * 2. normalise the `designSystem` shorthands to `{ themes: … }`,
2083
+ * 3. apply the active A/B variant's `overrides` (by `siteEnv`),
2084
+ * 4. validate (shape + cross-field).
2085
+ * Pure — no YAML/env access. A loader supplies `siteEnv`.
1664
2086
  *
1665
- * `on` is the one irregular case: the foreground for a filled surface reads
1666
- * `--color-text-on-<role>`, with the role *after* `on`, because it names what it
1667
- * sits on rather than what it is.
2087
+ * Steps 2 and 3 are in this order deliberately. Normalising first means an
2088
+ * `abTesting` override always addresses the canonical shape
2089
+ * (`designSystem.themes.<name>`), whatever shorthand the base config used. With
2090
+ * the merge first, an override's key path depended on how the base happened to be
2091
+ * written — so the same patch hit a different place in two configs that were
2092
+ * otherwise equivalent.
1668
2093
  *
1669
- * The utility class name is this minus the `--color-` prefix, which is why the
1670
- * emitter and the linter can both derive their vocabulary from one function.
2094
+ * The A/B lookup reads `site.environments` / `site.abTesting`, and the overrides
2095
+ * still deep-merge at the **root** — a variant patches whichever section it names,
2096
+ * which is what lets one target `designSystem` and another `site.seo`.
1671
2097
  */
1672
- function tokenVar(role, key) {
1673
- const dash = key.indexOf("-");
1674
- const target = dash === -1 ? key : key.slice(0, dash);
1675
- const variant = dash === -1 ? "" : key.slice(dash + 1);
1676
- if (variant === "on") return `--color-${target}-on-${role}`;
1677
- if (variant.startsWith("on-")) return `--color-${target}-on-${role}-${variant.slice(3)}`;
1678
- return variant ? `--color-${target}-${role}-${variant}` : `--color-${target}-${role}`;
1679
- }
1680
- /** The class name a token key produces for a role (`bg-danger-solid`, …). */
1681
- const tokenClass = (role, key) => tokenVar(role, key).slice(8);
1682
- /** Resolve the token set for one role over its hue. */
1683
- function functionalRole(role, hueName, hue, kind = "chromatic") {
1684
- const numeric = hue.numeric;
1685
- const v = (n) => `var(--color-${hueName}-${snap(numeric, n)})`;
1686
- const hex = (n) => numeric[snap(numeric, n)];
1687
- const map = (table) => {
1688
- const out = {};
1689
- for (const [token, step] of Object.entries(table)) out[token] = v(step);
1690
- return out;
1691
- };
1692
- const solids = map(SOLID);
1693
- const onSolid = pickOn(hex(SOLID["bg-solid"]));
1694
- return {
1695
- role,
1696
- hue: hueName,
1697
- kind,
1698
- light: {
1699
- ...map(kind === "surface" ? SURFACE_LIGHT : CHROMATIC_LIGHT),
1700
- ...solids,
1701
- "text-on": onSolid
1702
- },
1703
- dark: {
1704
- ...map(kind === "surface" ? SURFACE_DARK : CHROMATIC_DARK),
1705
- ...solids,
1706
- "text-on": onSolid
2098
+ function resolveConfig(input, siteEnv = "production") {
2099
+ const raw = stripNulls(input);
2100
+ normaliseDesignSystem(raw);
2101
+ const site = raw.site;
2102
+ const envConfig = (site?.environments)?.[siteEnv];
2103
+ if (envConfig?.variant) {
2104
+ const overrides = (site?.abTesting)?.variants?.[envConfig.variant]?.overrides;
2105
+ if (overrides) {
2106
+ normaliseDesignSystem(overrides);
2107
+ deepMerge(raw, overrides);
1707
2108
  }
1708
- };
2109
+ }
2110
+ const result = validateConfig(raw);
2111
+ if (!result.ok) {
2112
+ const msg = result.errors.map((e) => ` ${e.path.join(".") || "<root>"}: ${e.message}`).join("\n");
2113
+ throw new Error(`Invalid config:\n${msg}`);
2114
+ }
2115
+ return result.data;
1709
2116
  }
1710
2117
  /**
1711
- * APCA targets. `TEXT` is the body-text bar; `SECONDARY` covers text on a
1712
- * non-primary surface and de-emphasised text. `NON_TEXT` is the icon/boundary
1713
- * tier — the APCA analogue of WCAG's 3:1, where a glyph may run more vivid than
1714
- * text because shape carries meaning alongside contrast.
1715
- */
1716
- const CONTRAST = {
1717
- TEXT: 75,
1718
- SECONDARY: 60,
1719
- NON_TEXT: 45
1720
- };
1721
- /**
1722
- * Text tokens deliberately exempt from the contract. Placeholders and disabled
1723
- * text are required to look unavailable; holding them to the body-text bar would
1724
- * defeat the affordance. Nothing else may be exempt.
2118
+ * Is this raw config a full `Config` rather than a bare `design-system.json`?
2119
+ *
2120
+ * The discriminator is **total**, not a heuristic, and both halves are load-
2121
+ * bearing: `ConfigSchema` cannot validate without a `designSystem` (it is a
2122
+ * required key), and `DesignSystemSchema` is strict, so a `designSystem` key in a
2123
+ * design system is an `unrecognized_keys` error. No document can be read as both,
2124
+ * and no document that is neither reaches here without failing validation anyway.
2125
+ *
2126
+ * The restructure did not weaken this: `designSystem` stayed at the root
2127
+ * precisely so the discriminator kept working unchanged.
2128
+ *
2129
+ * Deliberately shape-based rather than filename-based: consumers name this file
2130
+ * whatever suits them (`company.json`, `site.json`, `vitops.config.json`), and a
2131
+ * rule keyed to a name would silently stop working the moment someone renamed it.
1725
2132
  */
1726
- const EXEMPT_TEXT = new Set(["text-x-muted", "text-xx-muted"]);
2133
+ function isConfig(raw) {
2134
+ return typeof raw === "object" && raw != null && !Array.isArray(raw) && "designSystem" in raw;
2135
+ }
1727
2136
  /**
1728
- * Verify a role's contrast targets. Returns human-readable failures.
2137
+ * Resolve a raw config of either kind into the design system to build from.
1729
2138
  *
1730
- * Chromatic roles are checked against the surface planes they actually sit on
1731
- * (`surfaceBg`) as well as their own tints — coloured text almost never appears
1732
- * over its own solid, it appears over the page. Checking only within the role
1733
- * would have missed the pairing that matters most.
2139
+ * Every entry point that used to take a `design-system.json` goes through this,
2140
+ * so a consumer who keeps their tokens inside the larger config points the
2141
+ * same option at that file instead of maintaining a second one. The config
2142
+ * is handed back too, because the parts of generation that depend on site-level
2143
+ * facts (`designSystem.defaultColorScheme`, the legal documents, the icon
2144
+ * sprite) would otherwise need the same path declared a second time.
2145
+ *
2146
+ * The returned `designSystem` is **not** validated here — the caller does that,
2147
+ * so it can say which file and which theme the errors belong to. Validation of
2148
+ * the config itself has already happened (`resolveConfig` throws).
1734
2149
  */
1735
- function checkContrast(fr, numericOf, surfaceBg) {
1736
- const failures = [];
1737
- const resolve = (val) => {
1738
- const m = /^var\(--color-([a-z0-9-]+)-(\d+)\)$/.exec(val);
1739
- if (!m) return val;
1740
- return numericOf(m[1])[m[2]] ?? val;
2150
+ function resolveInput(raw, opts = {}) {
2151
+ if (!isConfig(raw)) {
2152
+ 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 full config to select a theme.`);
2153
+ return { designSystem: raw };
2154
+ }
2155
+ const config = resolveConfig(raw, opts.siteEnv);
2156
+ const themes = config.designSystem?.themes ?? {};
2157
+ const theme = opts.theme ?? config.designSystem?.defaultTheme ?? "default";
2158
+ if (!(theme in themes)) throw new Error(`designSystem.themes has no "${theme}" entry (found: ${Object.keys(themes).join(", ") || "none"})`);
2159
+ return {
2160
+ designSystem: resolveTheme(themes, theme),
2161
+ config,
2162
+ theme
1741
2163
  };
1742
- const fail = (appearance, msg) => failures.push(`${fr.role}/${appearance}: ${msg}`);
1743
- for (const [appearance, tokens] of [["light", fr.light], ["dark", fr.dark]]) {
1744
- const ownBg = Object.keys(tokens).filter((k) => k === "bg" || k.startsWith("bg-")).filter((k) => !k.startsWith("bg-solid")).filter((k) => !k.startsWith("bg-bold") && !k.startsWith("bg-x-bold"));
1745
- const planes = fr.kind === "surface" ? ownBg.map((k) => [k, resolve(tokens[k])]) : [...(surfaceBg?.[appearance] ?? []).map((h, i) => [`surface#${i}`, h]), ...ownBg.map((k) => [k, resolve(tokens[k])])];
1746
- for (const [planeName, bg] of planes) {
1747
- const textMin = planeName === "bg" ? CONTRAST.TEXT : CONTRAST.SECONDARY;
1748
- for (const key of [
1749
- "text",
1750
- "text-bold",
1751
- "text-muted"
1752
- ]) {
1753
- if (tokens[key] == null || EXEMPT_TEXT.has(key)) continue;
1754
- const min = key === "text-muted" ? CONTRAST.SECONDARY : textMin;
1755
- const lc = contrastLc(resolve(tokens[key]), bg);
1756
- if (lc < min) fail(appearance, `${key} Lc ${lc.toFixed(1)} < ${min} on ${planeName} ${bg}`);
1757
- }
1758
- const nonText = fr.kind === "surface" ? ["icon", "border-bold"] : ["icon"];
1759
- for (const key of nonText) {
1760
- if (tokens[key] == null) continue;
1761
- const lc = contrastLc(resolve(tokens[key]), bg);
1762
- if (lc < CONTRAST.NON_TEXT) fail(appearance, `${key} Lc ${lc.toFixed(1)} < ${CONTRAST.NON_TEXT} on ${planeName} ${bg}`);
1763
- }
1764
- }
1765
- const solid = resolve(tokens["bg-solid"]);
1766
- const onLc = contrastLc(tokens["text-on"], solid);
1767
- if (onLc < CONTRAST.SECONDARY) fail(appearance, `text-on Lc ${onLc.toFixed(1)} < ${CONTRAST.SECONDARY} on bg-solid`);
1768
- if (tokens["bg-bold"] != null && tokens["text-on-bold"] != null) {
1769
- const lc = contrastLc(resolve(tokens["text-on-bold"]), resolve(tokens["bg-bold"]));
1770
- if (lc < CONTRAST.TEXT) fail(appearance, `text-on-bold Lc ${lc.toFixed(1)} < ${CONTRAST.TEXT} on bg-bold`);
2164
+ }
2165
+ /** Strip nulls (YAML `~`/`null`) → undefined so optional fields validate. */
2166
+ function stripNulls(obj) {
2167
+ if (obj === null) return void 0;
2168
+ if (Array.isArray(obj)) return obj.map(stripNulls);
2169
+ if (obj && typeof obj === "object") {
2170
+ const result = {};
2171
+ for (const [key, value] of Object.entries(obj)) {
2172
+ const stripped = stripNulls(value);
2173
+ if (stripped !== void 0) result[key] = stripped;
1771
2174
  }
2175
+ return result;
2176
+ }
2177
+ return obj;
2178
+ }
2179
+ /** Deep-merge `source` into `target` (in place). Arrays replace wholesale. */
2180
+ function deepMerge(target, source) {
2181
+ for (const key of Object.keys(source)) {
2182
+ const s = source[key];
2183
+ const t = target[key];
2184
+ if (s && typeof s === "object" && !Array.isArray(s) && t && typeof t === "object" && !Array.isArray(t)) deepMerge(t, s);
2185
+ else target[key] = s;
1772
2186
  }
1773
- return failures;
1774
2187
  }
1775
2188
 
1776
2189
  //#endregion
@@ -2041,12 +2454,17 @@ const TIERS = {
2041
2454
  "pricing-grid"
2042
2455
  ], "patterns/subgrid.css"),
2043
2456
  astro: a("Subgrid", "css"),
2044
- use: "`<Subgrid>` — emits `<ul class=\"grid\">` with row-subgrid children so cards align across tracks."
2457
+ use: "`<Subgrid>` — emits `<ul class=\"subgrid\" role=\"list\">`; author `<li class=\"card subgrid-card\">` items yourself, so tranches align across the set."
2045
2458
  },
2046
2459
  card: {
2047
- css: c(["card"], void 0, true),
2048
- astro: a("Cards", "css"),
2049
- use: "`<Cards>` for a list, or the `.card` class directly. Generated pattern: has roles and states."
2460
+ css: c(["card"], "patterns/card.css", true),
2461
+ wc: {
2462
+ tag: "wc-cards",
2463
+ registered: true,
2464
+ adds: "whole-card click that keeps the card text selectable"
2465
+ },
2466
+ astro: a("Cards", "wc"),
2467
+ use: "`<Cards>` — it emits `<wc-cards>` itself, so do NOT add your own wrapper. `<Subgrid>` is the same grid without the click enhancement."
2050
2468
  },
2051
2469
  icon: {
2052
2470
  css: c([
@@ -2104,8 +2522,13 @@ const TIERS = {
2104
2522
  use: "`.cta` on any element — usually `<a>`, since a CTA navigates. Filled, with role variants."
2105
2523
  },
2106
2524
  link: {
2107
- css: c(["link", "skip-link"], "patterns/anchor-link.css", true),
2108
- use: "`<a>` gets it at zero specificity; `.link` for other tags."
2525
+ css: c([
2526
+ "link",
2527
+ "skip-link",
2528
+ "stretched-link",
2529
+ "raised"
2530
+ ], "patterns/anchor-link.css", true),
2531
+ use: "`<a>` gets it at zero specificity; `.link` for other tags. `.stretched-link` makes one link cover its positioned ancestor (whole-card click, no JS — but the card text stops being selectable); `.raised` lifts content back above that overlay."
2109
2532
  },
2110
2533
  badge: {
2111
2534
  css: c(["badge", "badge-indicator"], "patterns/tag.css", true),
@@ -2330,7 +2753,7 @@ function tierTags() {
2330
2753
  * The patterns one tier provides, in `TIER_NAMES` order.
2331
2754
  *
2332
2755
  * Every renderer goes through this rather than filtering `TIERS` itself: the docs
2333
- * bundle projects all four tiers into one table for agents, the docsite projects
2756
+ * bundle projects every tier into one table for agents, the docsite projects
2334
2757
  * each tier as its own page for humans, and they must agree about which patterns
2335
2758
  * are in a tier. Five copies of `.filter(e => e.wc)` would agree until one of them
2336
2759
  * didn't.
@@ -2883,6 +3306,44 @@ Every utility accepts a **container-breakpoint prefix**; animation utilities als
2883
3306
  - State prefixes (animation effects only): \`hover-\`, \`active-\`, \`focus-\`, and \`flip-<effect>\`
2884
3307
  (plays the effect in reverse on toggle). Effects require \`transition\` on the element.
2885
3308
 
3309
+ ## Foundations — read this before writing any page markup
3310
+
3311
+ Six classes carry the structure of almost every page. They are not a starting suggestion
3312
+ you refine into hand-written CSS; **they are the answer**, and reaching past them is the
3313
+ single most common way a page drifts off the design system. Each line below is the
3314
+ temptation followed by what to write instead.
3315
+
3316
+ | If you are about to write… | Write this instead |
3317
+ | -------------------------------------------------------------- | ------------------------------------------------------ |
3318
+ | a \`wrap\` / \`wrapper\` / \`container\` / \`inner\` class | **\`centered\`** — never invent a container class |
3319
+ | \`max-width\` + \`margin-inline: auto\` | **\`centered\`** |
3320
+ | \`padding-block\` on a section, to space it from the next | **\`region\`** |
3321
+ | margins between headings, paragraphs and lists | **\`rhythm\`** on the container |
3322
+ | \`display: grid\` + \`grid-template-columns: repeat(n, 1fr)\` | **\`subgrid\`** — see below, this is the missed one |
3323
+ | \`grid-template-columns: repeat(auto-fit, minmax(…))\` | **\`grid-auto\`** (or \`subgrid\`, see below) |
3324
+ | \`display: flex\` + \`gap\` for a row of buttons/badges/meta | **\`cluster\`** (or \`flex\` + \`items-*\` utilities) |
3325
+
3326
+ **The container is \`centered\`, and it is not a max-width box.** It is a grid of *named
3327
+ tracks*, so a child opts into a wider track (\`breakout\`, \`spotlight\`, \`fullbleed\`) without
3328
+ the parent knowing. A hand-rolled \`.wrap\` renders the same on the happy path and then has
3329
+ no answer for the full-bleed image, which is why the escape from it is always more
3330
+ hand-written CSS. There is **no case** where a bespoke container class is the right call.
3331
+
3332
+ **Multiple cards means \`subgrid\`, essentially always.** This is the most under-used class in
3333
+ the framework, and the reason is that a plain \`grid\` *looks* correct: it aligns the outer
3334
+ boxes. What it cannot do is align the tranches **inside** them — so across three cards the
3335
+ headings sit at three different heights, the bodies start at three different points, and the
3336
+ footers float wherever the copy ended. \`subgrid\` re-declares the parent's row tracks on every
3337
+ item, so head / body / footer land on shared row lines no matter how much content each card
3338
+ holds. If the answer to "is this a repeated set of things with internal structure" is yes,
3339
+ this is the class.
3340
+
3341
+ **The one honest alternative is \`grid-auto\`** — an auto-fit track (\`--grid-min\`, \`--grid-gap\`)
3342
+ where the column count is content-driven. Use it when the items have **no internal tranches to
3343
+ align**: a bare image gallery, a logo wall, a row of icons. The moment an item has an eyebrow
3344
+ and a heading and a footer, \`subgrid\` is the one that makes them line up. Never hand-write
3345
+ \`repeat(auto-fit, minmax(…))\` — that is \`grid-auto\`.
3346
+
2886
3347
  ## Layout & structure
2887
3348
 
2888
3349
  - **\`centered\`** — named-track grid centering content in the reading \`measure\` track.
@@ -2891,6 +3352,70 @@ Every utility accepts a **container-breakpoint prefix**; animation utilities als
2891
3352
  \`--width-breakout\` / \`--width-spotlight\` and \`--gutter\`.
2892
3353
  - **\`rhythm\`** — relationship-based vertical spacing (margins between headings, paragraphs,
2893
3354
  lists, media) driven by the space scale. Usually paired with \`centered\`.
3355
+ - **\`region\`** — vertical rhythm *between sections* (fluid \`padding-block\`, tunable with
3356
+ \`--region-space\` / \`--region-space-min\` / \`--region-space-max\`). Goes on the section;
3357
+ \`centered\` goes on the container inside it. \`rhythm\` handles flow **within** a region.
3358
+ - **\`subgrid\`** — the class for any repeated set of items with internal structure: cards,
3359
+ pricing tiers, feature panels. Items share the parent's row tracks, so each item's
3360
+ tranches (head / body / footer) align across the whole set — which a plain \`grid\`
3361
+ cannot do. **Reach for this whenever there is more than one card.**
3362
+ - Columns: \`--subgrid-cols\` (default 3) or \`subgrid-cols-{1,2,3,4}\`; tranches per item:
3363
+ \`--subgrid-row-span\` (default 2) or \`subgrid-rows-{2,3,4}\` — this must match how many
3364
+ row bands each item actually contains.
3365
+ - \`subgrid-responsive\` collapses to 2 columns under 60rem and 1 under 40rem.
3366
+ - Gaps: \`--subgrid-gap\` (between columns *and* between tranches) and \`--subgrid-row-gap\`
3367
+ (extra space between wrapped rows of items only).
3368
+ - \`subgrid-card\` is the matching item: \`--subgrid-card-rows\` tranches, with
3369
+ \`subgrid-card__media\` (bleeds to the card edge) and \`subgrid-card__footer\`
3370
+ (pinned to the last row band, so CTAs line up across the set).
3371
+ - It is markerless when it is a \`ul\`/\`ol\` — and a set of cards **is** a list, so prefer
3372
+ \`<ul class="subgrid" role="list">\` with \`<li>\` items over a stack of \`<div>\`s.
3373
+ **\`role="list"\` is not optional here.** Safari + VoiceOver stop announcing a marker-less
3374
+ \`<ul>\` as a list, so the marker reset silently costs the semantics the \`<ul>\` was chosen
3375
+ for. \`<Subgrid>\` adds it for you; hand-written markup must say it.
3376
+ - **When the whole card is a link, the card is still the item**, and there are two ways to
3377
+ do it. Both keep the accessible name on real text rather than on a whole card of it.
3378
+ | Want | Use |
3379
+ | -------------------------------------------- | -------------------------------------------- |
3380
+ | no JS at all | \`stretched-link\` — **text not selectable** |
3381
+ | selectable text and a clickable card | \`<Cards>\` / \`<wc-cards>\` — needs JS |
3382
+ - \`stretched-link\` goes on a link **inside** the card (usually the heading); its
3383
+ \`::after\` covers the card. \`subgrid-card\` already carries the \`position: relative\` it
3384
+ resolves against; on a bare \`card\`, add \`relative\`. Anything else interactive in the
3385
+ card needs **\`raised\`** to sit above the overlay — \`relative\` alone is not enough,
3386
+ because a positioned element at \`z-index: auto\` does not rise above an explicit one.
3387
+ The cost is inherent: the overlay takes the pointer-drag, so the card's text can no
3388
+ longer be selected.
3389
+ - \`<Cards>\` wraps the list in \`<wc-cards>\`, which adds no overlay and instead tells a
3390
+ click apart from the end of a drag. Text stays selectable; with no JS the card's own
3391
+ link still works, and the pointer cursor only appears once the element has upgraded.
3392
+ - **Never layer the two** — the overlay wins, so you lose selection and the JS never
3393
+ runs. \`vitops lint\` reports the combination.
3394
+ **\`<li><a class="card">\` is the wrong shape and it renders fine**, which is why it keeps
3395
+ getting written: the \`<li>\` is the grid item, so the anchor is an ordinary block inside it
3396
+ and the tranches within the anchor never reach the parent's shared row lines — the
3397
+ alignment subgrid exists for silently does not happen, and the anchor does not fill the
3398
+ cell either. Putting the anchor in the grid's place instead (\`<ul><a></ul>\`) is invalid
3399
+ HTML. \`vitops lint\` reports this one.
3400
+ - Related subgrid layouts: \`pricing-grid\` + \`pricing-card\` (with \`pricing-card--featured\`)
3401
+ and \`comparison-table\`.
3402
+ - **\`grid-auto\`** — an auto-fit grid: items wrap into as many equal columns as fit, each at
3403
+ least \`--grid-min\` wide (default 13rem), gap \`--grid-gap\`. The content-driven counterpart
3404
+ to \`split\`'s explicit fractions. Prefer \`subgrid\` when the items have internal tranches to
3405
+ align; this is the right class for a gallery, a logo wall, a row of icons.
3406
+ - **\`stretched-link\`** — makes one link cover its nearest positioned ancestor, so a whole
3407
+ card is clickable while the accessible name stays on real text. Pair with \`relative\` on the
3408
+ container (\`subgrid-card\` already has it). **The card's text stops being selectable** — the
3409
+ overlay takes the pointer-drag — so where that matters use \`<Cards>\` instead.
3410
+ - **\`raised\`** — lifts content back above a \`stretched-link\` overlay, for a second link or a
3411
+ button in the same card. Sets \`position: relative\` **and** a z-index, both of which are
3412
+ required: \`relative\` on its own leaves the element underneath and unclickable.
3413
+ - **\`relative\`** — \`position: relative\`, the containing block \`stretched-link\` needs.
3414
+ - **\`cluster\`** — a wrapping row of related inline things (buttons, badges, tags, meta) with
3415
+ a shared \`--cluster-gap\`. Alignment variants \`cluster-{start,center,end}\`,
3416
+ \`cluster-{between,around,evenly}\`, and the two-axis form
3417
+ \`cluster-<align>-<justify>\` over \`start\`/\`center\`/\`end\`. Prefer this over a hand-written
3418
+ \`display: flex\` + \`gap\` for a group of controls.
2894
3419
  - **\`split\`** — a two-column pair. Ratio rule: **\`split-<a>-<b>\`** where \`<a>-<b>\` ∈
2895
3420
  \`1-2\`, \`2-1\`, \`1-3\`, \`3-1\`, \`1-4\`, \`4-1\`, \`2-3\`, \`3-2\` (breakpoint-prefixable); equal
2896
3421
  columns without one. The ratio is a flex **basis**, so a column's padding counts
@@ -3158,7 +3683,16 @@ const escapeHtml = (s) => s.replace(/[&<>"]/g, (c) => ({
3158
3683
  ">": "&gt;",
3159
3684
  "\"": "&quot;"
3160
3685
  })[c]);
3161
- /** Sentinel for a lifted code span. Cannot occur in authored schema prose. */
3686
+ /**
3687
+ * Sentinel for a lifted code span. Cannot occur in authored schema prose.
3688
+ *
3689
+ * Written as an ESCAPE, never as the literal byte. A raw NUL here made this whole
3690
+ * file `data` rather than `text` to libmagic, so grep and ripgrep classified it
3691
+ * as binary and **silently skipped it** — every search for a string in the
3692
+ * largest doc emitter in the repo returned nothing, with no error and nothing to
3693
+ * notice. That is the failure mode this codebase is written against, aimed at the
3694
+ * tool an agent uses to find anything.
3695
+ */
3162
3696
  const CODE_SLOT = "\0";
3163
3697
  /**
3164
3698
  * The inline markdown the schema's `desc()` text actually uses, as HTML. Closed
@@ -3823,7 +4357,10 @@ bare, unsuffixed pattern. States re-apply per variant with the variant's role.
3823
4357
  `;
3824
4358
  }
3825
4359
  /**
3826
- * All four tiers in ONE doc — the agent-facing projection of `TIERS`.
4360
+ * All three tiers in ONE doc — the agent-facing projection of `TIERS`.
4361
+ *
4362
+ * Four columns, three tiers: Astro and Bricks are both the platform-wrapper tier,
4363
+ * split by package rather than ranked.
3827
4364
  *
3828
4365
  * The docsite projects each tier as its own page, because a human arrives already
3829
4366
  * knowing which stack they are in ("show me the Astro components"). An agent
@@ -3871,7 +4408,7 @@ function renderComponentsConcept(ds) {
3871
4408
  return `${frontmatter({
3872
4409
  type: "Design Concept",
3873
4410
  title: "Vitops components — which tier provides a pattern, and what to write",
3874
- description: "Every UI pattern across the four tiers (CSS classes, wc-* web components, Astro components, Bricks elements): which tiers provide it, which call to make, and how the tiers compose.",
4411
+ description: "Every UI pattern across the three tiers (CSS classes, wc-* web components, and the platform wrappers: Astro components and Bricks elements): which tiers provide it, which call to make, and how the tiers compose.",
3875
4412
  resource: DS_PATH,
3876
4413
  tags: [
3877
4414
  "components",
@@ -3885,20 +4422,26 @@ function renderComponentsConcept(ds) {
3885
4422
 
3886
4423
  # Components
3887
4424
 
3888
- A pattern is provided by up to four tiers, and they **compose** rather than compete:
4425
+ A pattern is provided by up to **three tiers**, and they **compose** rather than compete:
3889
4426
 
3890
4427
  1. **CSS framework classes** — every pattern expressible in pure HTML/CSS. Reach here first.
3891
4428
  2. **Web components** (${code("<wc-*>")}, Lit) — only where a pattern genuinely benefits from
3892
4429
  progressive enhancement. The slotted markup is the fallback and must be usable with no JS;
3893
4430
  the element parses and augments it in place.
3894
- 3. **Astro components** — authoring conveniences that emit the correct markup. They must not
3895
- require runtime JS. Where a pattern has a web component, the Astro component emits that tag
3896
- **with the accessible fallback inside it**.
3897
- 4. **Bricks elements** — the same patterns as WordPress/Bricks Builder elements.
4431
+ 3. **Platform wrappers** — authoring conveniences that generate the correct markup at build
4432
+ time, using the classes and elements of tiers 1 and 2. They must not require runtime JS.
4433
+ Where a pattern has a web component, the wrapper emits that tag **with the accessible
4434
+ fallback inside it**. Two platforms:
4435
+ - **Astro components** (${code("@getvitops/astro")})
4436
+ - **Bricks elements** (WordPress / Bricks Builder)
4437
+
4438
+ **Astro and Bricks are the same tier**, not tiers 3 and 4. They are siblings chosen by which
4439
+ platform you are on — no project uses both, and neither outranks the other. The tables below
4440
+ give them separate columns because they are separate packages, not separate levels.
3898
4441
 
3899
4442
  ## Choosing
3900
4443
 
3901
- **Use the highest-numbered tier available for your stack, and write only its call.** In Astro
4444
+ **Use the highest tier available for your stack, and write only its call.** In Astro
3902
4445
  that is the Astro component; in Bricks the element; anywhere else the classes, plus the
3903
4446
  ${code("<wc-*>")} tag when one exists.
3904
4447
 
@@ -4265,6 +4808,19 @@ Both utils entries are **separate subpaths** because they are the only modules t
4265
4808
  **Worker** rather than at build time. Keeping them off the package index is what stops a
4266
4809
  conversion endpoint pulling \`sharp\` into its bundle. Neither may use a Node builtin.
4267
4810
 
4811
+ **Import them from \`@getvitops/astro\` if that is your only direct dependency.**
4812
+ \`@getvitops/astro/tracking\` re-exports everything in \`@getvitops/utils/tracking\` (plus
4813
+ \`TRACKING_ENDPOINT\`), so the flow works with one install — under strict pnpm, app code cannot
4814
+ resolve a transitive dependency, and \`@getvitops/utils\` would otherwise have to be added by
4815
+ hand. Do **not** reach for the same symbols on \`@getvitops/astro\`'s index instead: that entry
4816
+ pulls the integration and its Node builtins, which is the import that drags \`sharp\` toward a
4817
+ Worker bundle. \`@getvitops/astro/tracking\` and \`@getvitops/astro/routes\` are both clean.
4818
+
4819
+ ⚠️ The route you mount must answer **\`/api/track\`** (\`TRACKING_ENDPOINT\`) — the capture
4820
+ script beacons \`tel:\` conversions there, and a route at any other path means every call
4821
+ conversion 404s silently. The integration warns at build when tracking is on and no such route
4822
+ exists.
4823
+
4268
4824
  ## The capture demands consent
4269
4825
 
4270
4826
  \`_ac\` is a 90-day identifier tying a visitor to an ad, so it waits on \`marketing\` (override
@@ -4511,6 +5067,21 @@ is overwritten by the next build.
4511
5067
  - **\`cookies: []\` is meaningfully different from \`undefined\`.** It *asserts* a provider is
4512
5068
  cookieless (Plausible), which the cookie notice states **positively** rather than omitting.
4513
5069
 
5070
+ - **"Stored in" is not the same fact as "reachable by".** A processor's \`storage\` is where the
5071
+ information rests; \`operatorCountry\` is the jurisdiction that can compel the provider to hand
5072
+ it over. Privacy law turns on foreign **access**, not merely foreign storage, so the two get
5073
+ separate sentences — an Azure tenant in a Canadian region never moves the data and is still
5074
+ subject to US law. \`country\` is shorthand asserting both, and combining it with either is
5075
+ **rejected** rather than resolved by a silent rule: whether it narrows or adds is a
5076
+ contradiction between two legal claims, not a formatting choice.
5077
+
5078
+ A \`storage\` entry may be \`scope\`d to a category, which is what makes a Canadian tenant
5079
+ holding identity data abroad expressible. A country in the policy's own jurisdiction is not a
5080
+ transfer and is filtered out — that is what stopped \`country: "Canada"\` rendering "outside of
5081
+ Canada, including Canada". Only what the config states is asserted: hosts like Cloudflare
5082
+ declare an \`operatorCountry\` and **no** storage, because anycast means the config cannot know
5083
+ which region served a request, and "we don't know" is a fact.
5084
+
4514
5085
  - **Form templates are the PII inventory.** \`site.templates\` entries of type \`form\` are the
4515
5086
  only place the config says what personal information the site actually collects, so the
4516
5087
  disclosed list derives from their fields. \`hidden\` fields and honeypots are **excluded** —
@@ -4565,7 +5136,7 @@ function renderConceptsIndex() {
4565
5136
  * [Colour system](color.md) - seeded OKLCH scales on a shared lightness ladder, target-prefixed tokens, automatic dark mode
4566
5137
  * [Type & space scales](scales.md) - fluid modular scales and the tokens they emit
4567
5138
  * [Component patterns](patterns.md) - token cascade, override hooks, states, role variants
4568
- * [Components](components.md) - which of the four tiers provides each pattern, and the call to make
5139
+ * [Components](components.md) - which of the three tiers provides each pattern, and the call to make
4569
5140
  * [Icons](icons.md) - semantic names across icon sets, bundle derivation, sprite delivery
4570
5141
  * [Consent gate](consent.md) - inert gated tags, demand-driven prompting, three-valued choices
4571
5142
  * [Conversion tracking](tracking.md) - ad-click attribution, the \`_ac\` cookie, conversion notifications
@@ -5063,6 +5634,26 @@ across every pattern. Focus rings are never removed, only restyled.
5063
5634
  //#endregion
5064
5635
  //#region src/legal/providers.ts
5065
5636
  /**
5637
+ * What the config already tells us about third parties that receive personal
5638
+ * information.
5639
+ *
5640
+ * A privacy policy's riskiest sentence is the one naming service providers: if
5641
+ * it says "Plausible" while the site runs Google Analytics, the document is a
5642
+ * compliance defect, not a typo. The site config already records which analytics
5643
+ * ID is set, whether Turnstile is deployed and where the site is hosted — so the
5644
+ * sentence derives from those signals rather than from a hand-maintained string
5645
+ * that drifts the moment someone swaps providers.
5646
+ *
5647
+ * Presence of the key is the signal, matching how `analytics` already works
5648
+ * (provider identity is implied by which ID is set, not by an enum).
5649
+ *
5650
+ * This table is deliberately small and covers only what the schema can imply.
5651
+ * Everything else — payment processors, CRMs, mail senders — is declared by the
5652
+ * consumer in `legal.privacyPolicy.processors`, which flows through the same
5653
+ * pipeline. This is not a directory of the web; it's the set of providers the
5654
+ * toolchain itself can vouch for.
5655
+ */
5656
+ /**
5066
5657
  * The providers the config can imply. Keyed for testability — a test can assert
5067
5658
  * a given config resolves to a given key without matching on prose.
5068
5659
  */
@@ -5116,28 +5707,28 @@ const KNOWN_PROCESSORS = {
5116
5707
  turnstile: {
5117
5708
  name: "Cloudflare Turnstile",
5118
5709
  purpose: "bot protection on forms",
5119
- country: "the United States",
5710
+ operatorCountry: "the United States",
5120
5711
  privacyUrl: "https://www.cloudflare.com/privacypolicy/",
5121
5712
  cookies: ["cf_clearance"]
5122
5713
  },
5123
5714
  cloudflare: {
5124
5715
  name: "Cloudflare",
5125
5716
  purpose: "website hosting and content delivery",
5126
- country: "the United States",
5717
+ operatorCountry: "the United States",
5127
5718
  privacyUrl: "https://www.cloudflare.com/privacypolicy/",
5128
5719
  cookies: []
5129
5720
  },
5130
5721
  vercel: {
5131
5722
  name: "Vercel",
5132
5723
  purpose: "website hosting",
5133
- country: "the United States",
5724
+ operatorCountry: "the United States",
5134
5725
  privacyUrl: "https://vercel.com/legal/privacy-policy",
5135
5726
  cookies: []
5136
5727
  },
5137
5728
  netlify: {
5138
5729
  name: "Netlify",
5139
5730
  purpose: "website hosting",
5140
- country: "the United States",
5731
+ operatorCountry: "the United States",
5141
5732
  privacyUrl: "https://www.netlify.com/privacy/",
5142
5733
  cookies: []
5143
5734
  }
@@ -5178,9 +5769,81 @@ function detectProcessorKeys(cfg) {
5178
5769
  * an explicit declaration is a statement of fact we have no grounds to drop.
5179
5770
  */
5180
5771
  function resolveProcessors(cfg) {
5181
- const detected = detectProcessorKeys(cfg).map((k) => matomoCountry(KNOWN_PROCESSORS[k], cfg.site));
5772
+ const detected = detectProcessorKeys(cfg).map((k) => matomoLocation(KNOWN_PROCESSORS[k], cfg.site));
5182
5773
  const declared = cfg.site.legal?.privacyPolicy?.processors ?? [];
5183
- return [...detected, ...declared];
5774
+ return [
5775
+ ...detected,
5776
+ ...adProcessors(cfg),
5777
+ ...declared
5778
+ ].map(desugarCountry);
5779
+ }
5780
+ /**
5781
+ * The ad platforms `site.ads` links this site to, as processors.
5782
+ *
5783
+ * Derived from `AD_PLATFORMS` rather than written out here, because the cookie
5784
+ * names, the privacy URL and the opt-out are the *same facts* `vitops ads tags`
5785
+ * writes into `data-consent-cookies` — and the one failure that matters is those
5786
+ * two disagreeing. A pixel that sets `_fbp` while the notice omits it is a
5787
+ * compliance defect, and a revoke that clears a cookie the notice never mentioned
5788
+ * is the same defect seen from the other end.
5789
+ *
5790
+ * `enabled: false` keeps a property on record without emitting its tag, so nothing
5791
+ * is set and nothing is disclosed. Everything else with an id is live.
5792
+ */
5793
+ function adProcessors(cfg) {
5794
+ const ads = cfg.site.ads ?? {};
5795
+ return AD_PROVIDER_KEYS.filter((provider) => {
5796
+ const entry = ads[provider];
5797
+ return entry != null && entry.enabled !== false && (entry.pixelId ?? entry.accountId) != null;
5798
+ }).map((provider) => {
5799
+ const platform = AD_PLATFORMS[provider];
5800
+ return {
5801
+ name: platform.name,
5802
+ purpose: "advertising measurement and retargeting",
5803
+ operatorCountry: platform.operatorCountry,
5804
+ privacyUrl: platform.tag.privacyUrl,
5805
+ cookies: platform.tag.cookies,
5806
+ ...platform.tag.optOut ? { optOut: platform.tag.optOut } : {}
5807
+ };
5808
+ });
5809
+ }
5810
+ /**
5811
+ * `country` → both facts it has always asserted.
5812
+ *
5813
+ * The sentence it fed claimed storage *and* legal reach in one breath ("may be
5814
+ * stored or processed in … including X. As a result … access requests from
5815
+ * governments … in those jurisdictions"). So expanding it to storage alone would
5816
+ * silently retract half of a claim consumers have already published. Expanding to
5817
+ * both is also what keeps the rendered output byte-identical for every config that
5818
+ * used the shorthand.
5819
+ *
5820
+ * `country` is left in place — it is public API and `PolicyVars.countries` still
5821
+ * reports it. When the explicit fields are present they win; `validateConfig`
5822
+ * rejects that combination, so it is unreachable through a validated config, but
5823
+ * `resolveProcessors` is exported and needs a defined answer regardless.
5824
+ */
5825
+ function desugarCountry(p) {
5826
+ if (!p.country) return p;
5827
+ return {
5828
+ ...p,
5829
+ storage: p.storage ?? [{ country: p.country }],
5830
+ operatorCountry: p.operatorCountry ?? p.country
5831
+ };
5832
+ }
5833
+ /**
5834
+ * Processors the config discloses but places nowhere.
5835
+ *
5836
+ * Such a processor cannot appear in the transfer disclosure — there is nothing true
5837
+ * to say about it — and today it vanishes from that section in silence, which is the
5838
+ * tidy-looking failure. Names only, and deliberately NOT on `PolicyVars`: that type
5839
+ * is the facts a template renders from, and a document must not editorialise about
5840
+ * its own gaps. The CLI reports these on stderr instead.
5841
+ *
5842
+ * Not a `validateConfig` error either: a bare `{ name, purpose }` validates today,
5843
+ * so rejecting it would make this a breaking change.
5844
+ */
5845
+ function processorsMissingLocation(cfg) {
5846
+ return resolveProcessors(cfg).filter((p) => !p.operatorCountry && !p.storage?.length).map((p) => p.name);
5184
5847
  }
5185
5848
  /**
5186
5849
  * Matomo is the one provider whose location the config actually knows.
@@ -5190,13 +5853,17 @@ function resolveProcessors(cfg) {
5190
5853
  * country. So the cross-border transfer sentence is asserted only for the case we
5191
5854
  * can see. Naming a transfer that isn't happening is the same class of error as
5192
5855
  * omitting one that is — the config records facts, and "we don't know" is a fact.
5856
+ *
5857
+ * This sets `storage`, not `operatorCountry`: Matomo Cloud genuinely holds the data
5858
+ * in the EU. InnoCraft is New Zealand-based, but nothing in the config sees that, so
5859
+ * no operator jurisdiction is asserted — the same rule, applied to the other fact.
5193
5860
  */
5194
- function matomoCountry(processor, site) {
5861
+ function matomoLocation(processor, site) {
5195
5862
  if (processor.name !== "Matomo") return processor;
5196
5863
  const url = site.analytics?.matomo?.url ?? "";
5197
5864
  return /\.matomo\.cloud/i.test(url) ? {
5198
5865
  ...processor,
5199
- country: "the European Union"
5866
+ storage: [{ country: "the European Union" }]
5200
5867
  } : processor;
5201
5868
  }
5202
5869
 
@@ -5399,7 +6066,38 @@ function derivePolicyVars(cfg) {
5399
6066
  ...processors.filter((p) => p.optOut).map((p) => `You may opt out of ${p.name} directly at ${p.optOut}.`)
5400
6067
  ]);
5401
6068
  const optOutOptions = uniq(["You may unsubscribe from any commercial e-mail we send using the \"unsubscribe\" link in that e-mail.", ...cookieOptOutOptions]);
5402
- const countries = list(uniq(processors.map((p) => p.country)));
6069
+ /**
6070
+ * The cross-border disclosure, as two facts rather than one flattened list.
6071
+ *
6072
+ * Order matters here — each step narrows what the next may claim:
6073
+ *
6074
+ * 1. Anything in this jurisdiction is not a transfer at all, so it is filtered
6075
+ * out. Without this, `country: "Canada"` rendered "outside of Canada,
6076
+ * including Canada".
6077
+ * 2. An unscoped storage country is the BROAD claim; a scoped one covering the
6078
+ * same country would only narrow it, so it is dropped as redundant.
6079
+ * 3. An operator country already named as storage adds no fact — "US law reaches
6080
+ * information stored in the US" is a sentence, not information.
6081
+ *
6082
+ * Steps 2 and 3 are what make the rendered output byte-identical for every config
6083
+ * that used `country` (which desugars to both facts).
6084
+ *
6085
+ * No geography is inferred — "France" is not resolved into "the European Union".
6086
+ * Only what the config states is asserted, which is the `matomoLocation` rule.
6087
+ */
6088
+ const jurisdictionCountry = JURISDICTION_COUNTRIES[legal?.jurisdiction ?? "ca"];
6089
+ const norm = (c) => c.trim().toLowerCase().replace(/^the\s+/, "");
6090
+ const foreign = (c) => norm(c) !== norm(jurisdictionCountry);
6091
+ const allStorage = processors.flatMap((p) => p.storage ?? []);
6092
+ const unscoped = uniq(allStorage.filter((s) => !s.scope && foreign(s.country)).map((s) => s.country));
6093
+ const storageCountries = list(unscoped);
6094
+ const named = new Set(unscoped.map(norm));
6095
+ const scopedStorage = [...new Map(allStorage.filter((s) => s.scope && foreign(s.country) && !named.has(norm(s.country))).map((s) => [`${norm(s.country)}|${s.scope}`, {
6096
+ country: s.country,
6097
+ scope: s.scope
6098
+ }])).values()];
6099
+ const operatorCountries = list(uniq(processors.map((p) => p.operatorCountry)).filter((c) => foreign(c) && !named.has(norm(c))));
6100
+ const countries = list(uniq(processors.flatMap((p) => [...(p.storage ?? []).map((s) => s.country), p.operatorCountry])));
5403
6101
  const address = contact?.address ?? org?.address;
5404
6102
  return {
5405
6103
  businessName,
@@ -5426,6 +6124,10 @@ function derivePolicyVars(cfg) {
5426
6124
  purpose: "records the advertisement or campaign you arrived from, so an enquiry can be attributed to it",
5427
6125
  retention: "90 days"
5428
6126
  }] : [],
6127
+ jurisdictionCountry,
6128
+ storageCountries: storageCountries || void 0,
6129
+ scopedStorage,
6130
+ operatorCountries: operatorCountries || void 0,
5429
6131
  countries: countries || void 0,
5430
6132
  consentModel: consent?.enabled ? consent.type ?? "opt-in" : "none",
5431
6133
  cookieCategories: consent?.categories ?? []
@@ -5753,6 +6455,23 @@ ${bullets(v.cookieCategories)}
5753
6455
 
5754
6456
  //#endregion
5755
6457
  //#region src/legal/templates/privacy.ca.ts
6458
+ /**
6459
+ * Privacy policy — Canada (PIPEDA).
6460
+ *
6461
+ * Ported from the pandoc prototype at `packages/core/legal-templates/`. Two
6462
+ * things changed in the port and both are load-bearing:
6463
+ *
6464
+ * 1. `${var}` is now real TypeScript interpolation rather than a pandoc Lua
6465
+ * filter, so an unresolved variable is a compile error. The filter left
6466
+ * unknown keys as the literal text `${key}` — which would ship a live
6467
+ * privacy policy containing `${countries}`.
6468
+ * 2. Lists are built from arrays, not by splicing a string into a fixed bullet.
6469
+ * An empty derived value used to produce a stray `- ; and` bullet.
6470
+ *
6471
+ * The prose is jurisdiction-specific: it names the Office of the Privacy
6472
+ * Commissioner of Canada and frames transfers as "outside of Canada". Do not
6473
+ * reuse it for another jurisdiction — add a sibling template instead.
6474
+ */
5756
6475
  function privacyCa(v) {
5757
6476
  return `# Privacy Policy
5758
6477
  ${updatedLine(v)}
@@ -5854,15 +6573,46 @@ ${bullets(v.processors.map((p) => `${p.name}, for ${p.purpose}${p.privacyUrl ? `
5854
6573
 
5855
6574
  We remain responsible for personal information in the possession of our service providers.`;
5856
6575
  }
5857
- /** Omitted entirely when nothing leaves the country — a false claim otherwise. */
6576
+ /**
6577
+ * Omitted entirely when nothing leaves the country — a false claim otherwise.
6578
+ *
6579
+ * Two clauses, because storage and legal reach are two facts and the OPC's concern
6580
+ * is foreign **access**, not merely foreign storage. A provider can hold data in
6581
+ * Canada and still be compellable abroad (Azure in a Canadian region), and a
6582
+ * provider can hold only one category of data abroad (a Canadian tenant whose
6583
+ * identity data sits in the US). One sentence could state neither.
6584
+ *
6585
+ * The closer is shared: it is true of whichever clauses appear.
6586
+ */
5858
6587
  function transferSection(v) {
5859
- if (!v.countries) return "";
6588
+ const clauses = [storageClause(v), operatorClause(v)].filter(Boolean);
6589
+ if (clauses.length === 0) return "";
5860
6590
  return `
5861
6591
  ### We May Transfer Personal Information to Other Countries
5862
6592
 
5863
- Some or all of the personal information we collect may be stored or processed in jurisdictions outside of Canada, including ${v.countries}. As a result, this information may be subject to access requests from governments, courts or law enforcement in those jurisdictions according to laws in those jurisdictions.
6593
+ ${clauses.join("\n\n")}
6594
+
6595
+ As a result, this information may be subject to access requests from governments, courts or law enforcement in those jurisdictions according to laws in those jurisdictions.
5864
6596
  `;
5865
6597
  }
6598
+ /**
6599
+ * Where the information rests.
6600
+ *
6601
+ * A scoped location gets its own sentence rather than joining the "including …"
6602
+ * list: folded in, a reader would take it to cover everything the provider holds,
6603
+ * which is a broader claim than the config made.
6604
+ */
6605
+ function storageClause(v) {
6606
+ const scoped = list(v.scopedStorage.map((s) => `${s.country}, in the case of ${s.scope}`));
6607
+ if (v.storageCountries) return `Some or all of the personal information we collect may be stored or processed in jurisdictions outside of Canada, including ${v.storageCountries}.${scoped ? ` Certain categories of personal information are held outside of Canada even where the rest is not: ${scoped}.` : ""}`;
6608
+ if (scoped) return `Certain categories of the personal information we collect are stored or processed outside of Canada: ${scoped}.`;
6609
+ return "";
6610
+ }
6611
+ /** The Azure-in-Canada case: the information never moves, and foreign law still reaches it. */
6612
+ function operatorClause(v) {
6613
+ if (!v.operatorCountries) return "";
6614
+ return `Some of the service providers we use are established in, or controlled from, jurisdictions outside of Canada, including ${v.operatorCountries}. Personal information those providers handle on our behalf may be subject to the laws of those jurisdictions even when it is stored in Canada.`;
6615
+ }
5866
6616
 
5867
6617
  //#endregion
5868
6618
  //#region src/legal/templates/terms.ca.ts
@@ -6350,7 +7100,8 @@ function build(ds, format, assetsDir, buildOpts = {}) {
6350
7100
  if (hue == null) throw new Error(`role "${role}" references unknown palette hue "${hueName}"`);
6351
7101
  scaleRoles.push(functionalRole(role, hueName, hue, roleKind(spec)));
6352
7102
  }
6353
- const surfaceRole = scaleRoles.find((r) => r.role === "surface");
7103
+ const surfaceRole = scaleRoles.find((r) => r.role === "surface" && "bg" in r.light);
7104
+ if (!surfaceRole && roleMap["surface"] != null) console.warn("[vitops] colors.roles.surface is chromatic, so no --color-bg-surface exists — skipping --surface-glass, --overlay and .glass. Declare it as { \"hue\": \"…\", \"kind\": \"surface\" } if you meant a surface role.");
6354
7105
  const focusRole = scaleRoles.find((r) => r.role === "ui-primary") ?? scaleRoles[0];
6355
7106
  for (const w of ladderWarnings(expandedPalette)) console.warn(`[vitops] ${w}`);
6356
7107
  {
@@ -6884,7 +7635,7 @@ function emitTailwind(ctx) {
6884
7635
  for (const fr of scaleRoles) for (const [t, val] of Object.entries(fr.light)) fn += ` ${tokenVar(fr.role, t)}: ${val};\n`;
6885
7636
  const focus = scaleRoles.find((r) => r.role === "ui-primary") ?? scaleRoles[0];
6886
7637
  if (focus) fn += ` --color-border-focus: ${focus.light["bg-solid"]};\n`;
6887
- const surface = scaleRoles.find((r) => r.role === "surface");
7638
+ const surface = scaleRoles.find((r) => r.role === "surface" && "bg" in r.light);
6888
7639
  if (surface) {
6889
7640
  fn += ` --surface-glass: color-mix(in oklch, var(--color-bg-surface) 72%, transparent);\n`;
6890
7641
  fn += ` --overlay: color-mix(in oklch, var(--color-${surface.hue}-950) 45%, transparent);\n`;
@@ -6912,7 +7663,7 @@ function emitTailwind(ctx) {
6912
7663
  if (scaleRoles.length) {
6913
7664
  util.push(`/* Role colour utilities (the public API) */`);
6914
7665
  for (const { cls, prop, value } of roleColorUtilities(scaleRoles, utilities)) util.push(`@utility ${cls} {\n ${prop}: ${value};\n}`);
6915
- if (scaleRoles.some((r) => r.role === "surface")) util.push(`@utility glass {\n background-color: var(--surface-glass);\n backdrop-filter: blur(12px);\n -webkit-backdrop-filter: blur(12px);\n}`);
7666
+ if (scaleRoles.some((r) => r.role === "surface" && "bg" in r.light)) util.push(`@utility glass {\n background-color: var(--surface-glass);\n backdrop-filter: blur(12px);\n -webkit-backdrop-filter: blur(12px);\n}`);
6916
7667
  }
6917
7668
  util.push(`/* Typography roles */`);
6918
7669
  for (const [role, spec] of Object.entries(typography?.roles ?? {})) util.push(`@utility font-${role} {\n${decls(roleDecls(role, spec))}\n}`);
@@ -7458,7 +8209,7 @@ function loadConfigFile(input, siteEnv) {
7458
8209
  */
7459
8210
  function defaultConfig() {
7460
8211
  return {
7461
- $schema: SCHEMA_URL,
8212
+ $schema: SCHEMA_LOCAL_PATH,
7462
8213
  colors: {
7463
8214
  palette: {
7464
8215
  brand: { seed: "#2e9b73" },
@@ -7728,5 +8479,5 @@ function defaultConfig() {
7728
8479
  }
7729
8480
 
7730
8481
  //#endregion
7731
- export { BASE_HOOK, CONFIG_SCHEMA_URL, CONTRAST, ConfigSchema, DARK_SEL, DOC_ORDER, DOC_SLUGS, DesignSystemSchema, JURISDICTIONS, KNOWN_PROCESSORS, NUMERIC_STEPS, REQUIRED_ROLES, ROLE_TOKEN_KEYS, SCHEMA_URL, SiteFontSchema, TEMPLATES, TIERS, TIER_NAMES, TW_CLASH, buildIconSprite, configJsonSchema, deepMerge, defaultConfig, derivePolicyVars, detectProcessorKeys, enabledDocs, expandPalette, functionalRole, generate, generateDocs, generateLegal, isConfig, jsonSchema, loadIconSvg, parseMarkdown, renderInlineMarkdown, renderMarkdown, renderNodes, resolveConfig, resolveInput, resolvePrivacyContact, resolveProcessors, resolveTheme, roleColorUtilities, roleHue, roleKind, schemaTreeNodes, spriteId, stripNulls, tierPatterns, tierTags, toContentNodes, toHtmlFragment, toPortableText, tokenClass, tokenVar, validate, validateConfig };
8482
+ export { AD_PROVIDERS, BASE_HOOK, CONFIG_SCHEMA_URL, CONTRAST, ConfigSchema, DARK_SEL, DOC_ORDER, DOC_SLUGS, DesignSystemSchema, JURISDICTIONS, JURISDICTION_COUNTRIES, KNOWN_PROCESSORS, NUMERIC_STEPS, REQUIRED_ROLES, ROLE_TOKEN_KEYS, SCHEMA_LOCAL_PATH, SCHEMA_URL, SiteFontSchema, TEMPLATES, TIERS, TIER_NAMES, TW_CLASH, buildIconSprite, configJsonSchema, deepMerge, defaultConfig, derivePolicyVars, detectProcessorKeys, enabledDocs, expandPalette, functionalRole, generate, generateDocs, generateLegal, isConfig, jsonSchema, loadIconSvg, movedTokens, parseMarkdown, processorsMissingLocation, renderInlineMarkdown, renderMarkdown, renderNodes, resolveConfig, resolveInput, resolvePrivacyContact, resolveProcessors, resolveTheme, roleColorUtilities, roleHue, roleKind, schemaTreeNodes, spriteId, stripNulls, tierPatterns, tierTags, toContentNodes, toHtmlFragment, toPortableText, tokenClass, tokenVar, validate, validateConfig };
7732
8483
  //# sourceMappingURL=index.mjs.map