@getvitops/generator 1.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +815 -0
- package/README.md +8 -1
- package/assets/bricks/elements/icon.php +102 -0
- package/assets/bricks/elements/split.php +8 -5
- package/assets/bricks/load.php +91 -0
- package/assets/css/animation.css +145 -34
- package/assets/css/global.css +19 -3
- package/assets/css/index.css +8 -0
- package/assets/css/layout-utilities.css +764 -0
- package/assets/css/layout.css +50 -584
- package/assets/css/patterns/consent.css +117 -0
- package/assets/css/patterns/details.css +1 -1
- package/assets/css/patterns/reveal.css +92 -0
- package/assets/css/patterns/subgrid.css +7 -2
- package/assets/css/patterns/theme-editor.css +37 -0
- package/assets/css/utilities.css +19 -98
- package/assets/js/consent.js +1 -0
- package/assets/js/deferred.js +1 -1
- package/assets/js/editor.js +1 -1
- package/dist/index.d.mts +736 -252
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1002 -135
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -3
- package/schema.json +3 -3
- package/site.schema.json +609 -498
package/dist/index.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
1
2
|
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { dirname, join } from "node:path";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
5
|
import { createHash } from "node:crypto";
|
|
5
6
|
import { transform } from "lightningcss";
|
|
6
7
|
import * as z from "zod/mini";
|
|
7
8
|
import { LIGHTNESS_LADDER, contrastLc, generateOklchPalette, hexToOklch, oklchStringToHex, pickOn, tintedEndpoints } from "@getvitops/utils/color";
|
|
8
|
-
import { nodesToHtml, t } from "@getvitops/utils";
|
|
9
|
+
import { generateIconInclude, nodesToHtml, resolveIcon, t } from "@getvitops/utils";
|
|
10
|
+
import { getIconData, iconToSVG, replaceIDs } from "@iconify/utils";
|
|
9
11
|
|
|
10
12
|
//#region src/shared.ts
|
|
11
13
|
/**
|
|
@@ -23,15 +25,27 @@ import { nodesToHtml, t } from "@getvitops/utils";
|
|
|
23
25
|
* clicking "Dark" changed an attribute no rule matched. Matching both makes the
|
|
24
26
|
* component work on every target without changing what Bricks already does.
|
|
25
27
|
*
|
|
26
|
-
*
|
|
27
|
-
* `
|
|
28
|
-
*
|
|
29
|
-
* dark-OS users, which is a product decision, not a bug fix.
|
|
28
|
+
* This covers the EXPLICIT choice. The OS preference is a second, opt-in block —
|
|
29
|
+
* see `SYSTEM_DARK_SEL` — because turning it on flips a site dark for dark-OS
|
|
30
|
+
* users, which is the site's decision rather than the design system's.
|
|
30
31
|
*
|
|
31
32
|
* Shared so the docs quote the selector the generator actually emits.
|
|
32
33
|
*/
|
|
33
34
|
const DARK_SEL = ":root[data-brx-theme=\"dark\"], :root[data-theme=\"dark\"]";
|
|
34
35
|
/**
|
|
36
|
+
* Selector for the OS-preference dark block, emitted inside
|
|
37
|
+
* `@media (prefers-color-scheme: dark)` when a site opts in.
|
|
38
|
+
*
|
|
39
|
+
* Derived from `DARK_SEL`'s attributes rather than written out a third time, and
|
|
40
|
+
* inverted: this must apply when NO explicit choice has been made. That is
|
|
41
|
+
* exactly what `<color-scheme-toggle>`'s "System" position produces — it removes
|
|
42
|
+
* `data-theme` entirely — so with this block present System resolves to the OS
|
|
43
|
+
* instead of silently falling through to light, and needs no JS to do it.
|
|
44
|
+
*
|
|
45
|
+
* The `:not()`s are what keep an explicit *light* choice winning over a dark OS.
|
|
46
|
+
*/
|
|
47
|
+
const SYSTEM_DARK_SEL = `:root${DARK_SEL.split(", ").map((s) => s.replace(/^:root\[([\w-]+)="dark"\]$/, "[$1=\"light\"]")).map((attr) => `:not(${attr})`).join("")}`;
|
|
48
|
+
/**
|
|
35
49
|
* Roles the shipped CSS partials reference with NO fallback, so a config that
|
|
36
50
|
* omits one leaves those rules resolving to nothing — an invalid declaration,
|
|
37
51
|
* not a graceful degrade.
|
|
@@ -168,7 +182,11 @@ const TW_CLASH = new Set([
|
|
|
168
182
|
"text-right",
|
|
169
183
|
"text-justify",
|
|
170
184
|
"text-start",
|
|
171
|
-
"text-end"
|
|
185
|
+
"text-end",
|
|
186
|
+
"text-wrap",
|
|
187
|
+
"text-nowrap",
|
|
188
|
+
"text-balance",
|
|
189
|
+
"text-pretty"
|
|
172
190
|
]);
|
|
173
191
|
|
|
174
192
|
//#endregion
|
|
@@ -192,18 +210,18 @@ const desc = (schema, text) => schema.check(z.describe(text));
|
|
|
192
210
|
const CssDecls = desc(z.record(z.string(), z.string()), "A CSS declaration block: property → value. Values stay strings (they can be hex, var(), clamp(), keywords, …); the generator, not the schema, interprets them.");
|
|
193
211
|
const Scalar = z.union([z.string(), z.number()]);
|
|
194
212
|
const VarMap = z.record(z.string(), Scalar);
|
|
195
|
-
const SeededRamp = desc(z.
|
|
213
|
+
const SeededRamp = desc(z.strictObject({
|
|
196
214
|
seed: desc(z.string(), "Seed colour (hex or oklch()). An 11-step numeric scale (50…950, tinted near-white → tinted near-black) is generated in OKLCH from it; the seed is preserved at its natural step."),
|
|
197
215
|
anchors: z.optional(desc(z.record(z.string(), z.string()), "Step → colour overrides (hex or oklch()) pinned VERBATIM at those steps. Every other step takes its lightness from the shared ladder, with chroma and hue interpolated between the anchors — so an anchor is reproduced exactly and is the only step allowed off the ladder. An explicit anchor overrides the seed at that step; two anchors that resolve to the same step are an error."))
|
|
198
216
|
}), "Seeded hue: the 11-step scale is GENERATED in OKLCH from `seed` (anchors pin specific steps).");
|
|
199
|
-
const FixedRamp = desc(z.
|
|
217
|
+
const FixedRamp = desc(z.strictObject({ tones: desc(z.union([z.array(z.string()), z.record(z.string(), z.string())]), "Fixed brand kit: authored tones placed verbatim at their nearest steps plus tinted off-white/off-black endpoints; no interpolation. Either an ordered light → dark array or a step → colour map.") }), "Fixed hue: authored brand tones used verbatim; no generation.");
|
|
200
218
|
const Ramp = desc(z.union([SeededRamp, FixedRamp]), "A palette hue, authored one of two ways: `{ seed, anchors? }` generates an 11-step numeric OKLCH scale (50…950) from the seed, or `{ tones }` supplies a fixed brand kit used verbatim.");
|
|
201
219
|
/**
|
|
202
220
|
* A role points at a hue, and optionally declares its kind. The bare-string form
|
|
203
221
|
* is the shorthand for a chromatic role — signal colours are the common case,
|
|
204
222
|
* and the two or three surfaces are worth naming explicitly.
|
|
205
223
|
*/
|
|
206
|
-
const RoleSpecSchema = z.union([desc(z.string(), "A palette hue name. Shorthand for `{ hue, kind: \"chromatic\" }`."), z.
|
|
224
|
+
const RoleSpecSchema = z.union([desc(z.string(), "A palette hue name. Shorthand for `{ hue, kind: \"chromatic\" }`."), z.strictObject({
|
|
207
225
|
hue: desc(z.string(), "The palette hue this role resolves to."),
|
|
208
226
|
kind: z.optional(desc(z.enum(["surface", "chromatic"]), "`surface` — a page/panel colour: gets a bare `bg-<role>` plus the full emphasis range and text scale. `chromatic` (default) — a signal colour: tints and solids only, no bare `bg-<role>`."))
|
|
209
227
|
})]);
|
|
@@ -216,25 +234,25 @@ const UtilityName = z.enum([
|
|
|
216
234
|
"fill",
|
|
217
235
|
"stroke"
|
|
218
236
|
]);
|
|
219
|
-
const Colors = desc(z.
|
|
220
|
-
palette: desc(z.record(z.string(), Ramp), "Palette hues by name. Each becomes an 11-step numeric OKLCH scale (`--color-<hue>-50…950`)."),
|
|
237
|
+
const Colors = desc(z.strictObject({
|
|
238
|
+
palette: desc(z.record(z.string(), Ramp), "Palette hues by name. Each becomes an 11-step numeric OKLCH scale (`--color-<hue>-50…950`). Every ramp shares one fixed lightness ladder, so a step means the same lightness in every hue; only chroma and hue vary. Beyond the outermost authored colour the chroma decays towards a small endpoint value (0.008 light / 0.015 dark) so the near-white and near-black ends keep a whisper of the hue — but that is a **ceiling**, not a target, so a low-chroma seed stays low and **a chroma-0 seed gives a true neutral** rather than a tinted one. The ladder is also enforced: if pinned colours leave a ramp non-monotonic (some step darker than the one below it), the build fails rather than shipping a scale whose hover states run backwards."),
|
|
221
239
|
roles: desc(z.record(z.string(), RoleSpecSchema), `Maps semantic role names onto palette hues. Role names are ARBITRARY — add a key and the generator emits that role's token set (\`--color-<target>-<role>[-<variant>]\` for target bg/text/icon/border), its dark-mode flip and its utility classes.\n\nA value is either a hue name (\`"danger": "rust"\`) or \`{ "hue": …, "kind": "surface" | "chromatic" }\`. **The kind decides the shape of the token set.** \`chromatic\` (the default, and what the bare-string form means) is a signal colour: its backgrounds split into tints (\`bg-<role>-x-muted\`/\`-muted\`) and solids (\`bg-<role>-solid[-bold|-x-bold]\`), with deliberately **no bare \`bg-<role>\`** — "how loud?" is a question the author answers. \`surface\` is a page/panel colour: it has a bare \`bg-<role>\` plus the full emphasis range and text scale.\n\nDark mode flips automatically; there is no per-appearance scheme grammar. The solid family and its computed \`text-on-<role>\` foreground stay mode-stable so a filled button keeps its identity. Six roles are a required core, because the shipped framework CSS references them with no fallback: ${REQUIRED_ROLES.join(", ")}. Conventional additions are ui-secondary/accent, brand-secondary, info and success.`),
|
|
222
240
|
utilities: z.optional(desc(z.array(UtilityName), "Which colour utility-class families to emit (`bg-*`, `text-*`, `icon-*`, `border-*`, `outline-*`, `fill-*`, `stroke-*`). Defaults to bg, text, icon, border. `icon` is a separate non-text tier (a glyph may run more vivid than text); `outline`/`fill`/`stroke` have no tokens of their own and alias the border and icon tiers."))
|
|
223
241
|
}), "The colour system (the only required section): `palette` hues become generated OKLCH scales; `roles` map semantic roles onto those hues, from which all role tokens and dark mode derive.");
|
|
224
|
-
const Scale = z.
|
|
242
|
+
const Scale = z.strictObject({
|
|
225
243
|
base: desc(z.string(), "Anchor size (a CSS length, e.g. \"1rem\") — the value at `baseStep`."),
|
|
226
244
|
ratio: desc(z.number(), "Modular ratio between adjacent steps at large viewports."),
|
|
227
245
|
steps: desc(z.optional(z.number()), "Token count when `names` is absent (steps are then named 1..steps)."),
|
|
228
246
|
names: desc(z.optional(z.array(z.string())), "Step names, smallest → largest (e.g. [\"xs\",\"sm\",\"md\",…]); each becomes a token suffix."),
|
|
229
247
|
baseStep: desc(z.optional(z.number()), "1-based index of the step whose value is `base`."),
|
|
230
248
|
baseline: desc(z.optional(z.string()), "Named step used as the fluid pivot / GUI scale centre (defaults to `baseStep`)."),
|
|
231
|
-
fluid: desc(z.optional(z.
|
|
249
|
+
fluid: desc(z.optional(z.strictObject({
|
|
232
250
|
minVw: desc(z.string(), "Viewport width (CSS length) where fluid scaling bottoms out."),
|
|
233
251
|
maxVw: desc(z.string(), "Viewport width (CSS length) where fluid scaling tops out."),
|
|
234
252
|
minRatio: desc(z.number(), "Modular ratio at/below `minVw` (usually < `ratio`).")
|
|
235
253
|
})), "Makes the scale fluid: each step compiles to a clamp() that interpolates from `minRatio` at `minVw` to `ratio` at `maxVw`.")
|
|
236
254
|
});
|
|
237
|
-
const Pattern = desc(z.
|
|
255
|
+
const Pattern = desc(z.strictObject({
|
|
238
256
|
group: desc(z.optional(z.string()), "Token-cascade group this pattern belongs to (e.g. tag / control / panel); base declarations resolve through `--<prop>-<group>` before `--<prop>-default`."),
|
|
239
257
|
overrides: z.optional(desc(CssDecls, "Per-pattern token overrides, emitted as `--<prop>-<name>-group` values.")),
|
|
240
258
|
element: desc(z.optional(z.string()), "Style at element level via zero-specificity `:where(<element>)` (instead of, or alongside, a class)."),
|
|
@@ -245,47 +263,47 @@ const Pattern = desc(z.object({
|
|
|
245
263
|
states: desc(z.optional(z.record(z.string(), z.record(z.string(), z.unknown()))), "Interaction states (hover / active / focus-visible), each a map of shortcuts: `step` (intensify the fill or text by n rungs — `bg-<role>-solid` → `-solid-bold`, `text-<role>` → `-bold`), `scale` (transform scale), `lift` (translateY + shadow), `shadow` (a shadow name → drop-shadow(var(--shadow-<name>)), or true → lift shadow), `ring` (focus ring), or raw `css` declarations. Hover rules are wrapped in `@media (hover: hover)`."),
|
|
246
264
|
roles: desc(z.optional(z.array(z.string())), "Semantic colour role variants to emit as `<pattern>-<role>` classes (fills use the role solid / on-solid tokens).")
|
|
247
265
|
}), "One component pattern (button, link, badge, card, …): base declarations + interaction states + semantic role variants, resolved through the pattern token cascade.");
|
|
248
|
-
const Patterns = desc(z.
|
|
266
|
+
const Patterns = desc(z.strictObject({
|
|
249
267
|
defaults: z.optional(desc(CssDecls, "Cascade-wide fallback tokens, emitted as `--<prop>-default`.")),
|
|
250
268
|
radii: z.optional(desc(CssDecls, "Shape primitives, emitted as `--br-<name>` (referenced by pattern bases).")),
|
|
251
269
|
groups: desc(z.optional(z.record(z.string(), CssDecls)), "Group-level tokens, emitted as `--<prop>-<group>`; patterns opt in via their `group` key."),
|
|
252
270
|
z: desc(z.optional(z.record(z.string(), z.number())), "Z-index tiers → `--z-tier-<name>`."),
|
|
253
271
|
items: desc(z.optional(z.record(z.string(), Pattern)), "The component patterns to emit, keyed by name.")
|
|
254
272
|
}), "Component patterns and their token cascade: `defaults` → `groups` → per-pattern `overrides`, plus shape (`radii`) and z-index primitives.");
|
|
255
|
-
const TypographyRole = desc(z.record(z.string(), Scalar), "
|
|
256
|
-
const Typography = desc(z.
|
|
273
|
+
const TypographyRole = desc(z.record(z.string(), Scalar), "A bag of CSS-ish keys, each mapped to a declaration plus a `--<role>-<sfx>` override hook. The recognised set is **closed**: `family`, `size`, `weight`, `style`, `line-height`, `tracking` (→ `letter-spacing`), `text-transform`, `text-decoration`, `text-wrap`, `color`. Note the last four are spelled with their full CSS property names — `transform` and `decoration` are NOT accepted. Anything unrecognised is **ignored**, not passed through, so the generator warns rather than emitting it: a silently-dropped `transform: uppercase` is how title-case navigation reaches production. Note also that `style`, `text-transform`, `text-decoration` and `text-wrap` are emitted on **every** role at their identity value (`normal`/`none`/`none`/`wrap`) whether declared or not, so applying one role class over another fully resets it — which means **omitting `text-wrap` is not \"inherit\"**: it emits `text-wrap: wrap` and cancels the `pretty` the role would otherwise inherit from a `pretty` ancestor such as a `body`-mapped role. Declare it on every role — `balance` for heading-like roles, `pretty` for copy, `wrap` for short single-line labels.");
|
|
274
|
+
const Typography = desc(z.strictObject({
|
|
257
275
|
families: desc(z.optional(z.record(z.string(), z.string())), "Role-facing family aliases → CSS font values, usually referencing the top-level `fonts` tokens (e.g. \"var(--font-display)\")."),
|
|
258
276
|
roles: desc(z.optional(z.record(z.string(), TypographyRole)), "Semantic type roles (display, title, heading, body, quote, caption, eyebrow, code, lead, footnote, tag, …), each emitted as a `font-<role>` class."),
|
|
259
277
|
headings: desc(z.optional(z.record(z.string(), z.string())), "Maps bare elements to type roles so unclassed markup picks up role styling — `{ \"h1\": \"display\", \"h2\": \"heading\" }`. The key is used verbatim as a selector, so it is not limited to h1…h6: **map `\"body\"` to your prose role** to bind base page typography to the role rather than hand-writing it. That binding is what makes the role editable — a stylesheet that re-states `font-family`/`line-height` as literals on `body` shadows `--<role>-ff`/`--<role>-lh`, and the live theme editor then appears to do nothing.")
|
|
260
278
|
}), "Typography: family aliases, semantic type roles (→ `font-<role>` classes), and the bare-element → role mapping.");
|
|
261
|
-
const AnimEffect = desc(z.
|
|
279
|
+
const AnimEffect = desc(z.strictObject({
|
|
262
280
|
kf: desc(z.string(), "Keyframe family driving the effect: composite (transform/opacity), paint, or layout."),
|
|
263
281
|
css: z.optional(desc(VarMap, "Extra literal declarations merged into the effect class as-is.")),
|
|
264
282
|
vars: z.optional(desc(VarMap, "Effect endpoint variables (`--<key>: <value>`, e.g. opacity-from, translate-y-to) that override the keyframe defaults."))
|
|
265
283
|
}), "A named animation effect class — a pure value layer (`--_anim` + `--<prop>-from/-to`) over the static keyframe engine.");
|
|
266
|
-
const Animations = desc(z.
|
|
284
|
+
const Animations = desc(z.strictObject({
|
|
267
285
|
effects: desc(z.optional(z.record(z.string(), AnimEffect)), "Effect classes to emit (`.fade-in`, `.reveal-left`, …), keyed by class name."),
|
|
268
|
-
journeys: desc(z.optional(z.
|
|
286
|
+
journeys: desc(z.optional(z.strictObject({
|
|
269
287
|
base: desc(z.optional(z.record(z.string(), VarMap)), "Named journey building blocks: part name → var map."),
|
|
270
288
|
compose: desc(z.optional(z.array(z.array(z.string()))), "Combinations of base parts, each emitted as a `.<parts>-journey` class.")
|
|
271
289
|
})), "Multi-part journey classes composed from `base` building blocks.")
|
|
272
290
|
}), "Animation effect + journey classes (pure value layers). The animation engine itself — keyframes, drivers, floats, utilities — is static framework CSS, not configured here.");
|
|
273
|
-
const DesignSystemSchema = z.
|
|
291
|
+
const DesignSystemSchema = z.strictObject({
|
|
274
292
|
$schema: desc(z.optional(z.string()), "URL of the published JSON Schema (stamped by `vitops init`) so editors provide autocomplete + validation."),
|
|
275
|
-
meta: desc(z.optional(z.
|
|
293
|
+
meta: desc(z.optional(z.strictObject({
|
|
276
294
|
name: desc(z.optional(z.string()), "Brand/system name. Used as the `name` field and `<h1>` of the `design` format's `DESIGN.md`. Defaults to \"Design System\"."),
|
|
277
295
|
description: desc(z.optional(z.string()), "One or two sentences on the brand personality and the feeling the UI should evoke — what an agent needs when no token answers the question. Becomes the DESIGN.md `description` field and opens its Overview section; if omitted, a generic description of the system's mechanics is used instead.")
|
|
278
296
|
})), "Brand identity for agent-facing output. Consumed only by the `design` format (`DESIGN.md`); it emits no CSS and no tokens."),
|
|
279
297
|
colors: Colors,
|
|
280
298
|
shadows: desc(z.optional(z.record(z.string(), z.string())), "Named shadows → `--shadow-<name>` tokens and `.drop-shadow-<name>` utilities. Values are shadow parameter lists (offset/blur/colour). Each token feeds two consumers with different grammars — `box-shadow` (pattern geometry, via the `--ds-*` group aliases) and `filter: drop-shadow(…)` (the utilities and the `shadow:` state shortcut) — so values must stay in the intersection: **one layer, no spread radius, no `inset`**. `drop-shadow()` rejects all three, and rejecting them invalidates the whole filter, so the shadow vanishes rather than degrading."),
|
|
281
|
-
fonts: desc(z.optional(z.record(z.string(), z.string())), "Raw font stacks by name, emitted as `--font-<name>` tokens (referenced by `typography.families`)."),
|
|
299
|
+
fonts: desc(z.optional(z.record(z.string(), z.string())), "Raw font stacks by name, emitted as `--font-<name>` tokens (referenced by `typography.families`). **Stacks only — vitops does not load webfonts.** A value here is a `font-family` list and nothing more: it emits no `@font-face`, no preload, and no metrics-matched fallback. If a family needs loading, declare it in Astro's `fonts:` config (`astro.config`, or the site config's `fonts` array) and point the token at the family's `cssVariable` — `\"display\": \"var(--font-league-spartan), sans-serif\"`. Installing a `@fontsource*` package and importing its CSS also works but gives up subsetting, preload and `size-adjust`/`ascent-override` fallbacks, so it regresses CLS."),
|
|
282
300
|
typeScale: desc(z.optional(Scale), "Fluid modular TYPE scale → `--text-<name>` tokens, consumed by typography roles and text-size utilities."),
|
|
283
301
|
spaceScale: desc(z.optional(Scale), "Fluid modular SPACE scale → `--space-<name>` tokens, consumed by spacing/gap utilities and vertical rhythm."),
|
|
284
302
|
patterns: z.optional(Patterns),
|
|
285
303
|
typography: z.optional(Typography),
|
|
286
304
|
animations: z.optional(Animations)
|
|
287
305
|
});
|
|
288
|
-
const ColorsPatch = z.
|
|
306
|
+
const ColorsPatch = z.strictObject({
|
|
289
307
|
palette: z.optional(z.record(z.string(), Ramp)),
|
|
290
308
|
roles: z.optional(z.record(z.string(), RoleSpecSchema)),
|
|
291
309
|
utilities: z.optional(z.array(UtilityName))
|
|
@@ -338,6 +356,39 @@ const dropShadowIssue = (value) => {
|
|
|
338
356
|
const lengths = layer.replaceAll(/[\w-]*\([^()]*\)/g, " ").split(/\s+/).filter((t) => LENGTH.test(t));
|
|
339
357
|
if (lengths.length > 3) return `a spread radius (${lengths.length} lengths)`;
|
|
340
358
|
};
|
|
359
|
+
const RAMP_KEYS = new Set([
|
|
360
|
+
"seed",
|
|
361
|
+
"anchors",
|
|
362
|
+
"tones"
|
|
363
|
+
]);
|
|
364
|
+
/**
|
|
365
|
+
* Replace the bare "Invalid input" a failed union produces with the reason.
|
|
366
|
+
*
|
|
367
|
+
* `Ramp` is `SeededRamp | FixedRamp`, and both branches are closed — so `{ seed,
|
|
368
|
+
* tones }` or a stray key fails *both* and zod reports only that the union didn't
|
|
369
|
+
* match. That message sends you reading the schema rather than looking at the two
|
|
370
|
+
* keys in front of you, and the config that produced it is one whose `tones` were
|
|
371
|
+
* being silently discarded at generate time.
|
|
372
|
+
*/
|
|
373
|
+
const explainRamp = (input) => (issue) => {
|
|
374
|
+
if (issue.code === "unrecognized_keys") {
|
|
375
|
+
const keys = issue.keys ?? [];
|
|
376
|
+
if (keys.length) return {
|
|
377
|
+
...issue,
|
|
378
|
+
message: `unknown key${keys.length > 1 ? "s" : ""} ${keys.map((k) => `"${k}"`).join(", ")} — not part of the schema, so ignored at generate time.`
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
if (issue.path.length !== 3 || issue.path[0] !== "colors" || issue.path[1] !== "palette") return issue;
|
|
382
|
+
const hue = input?.colors?.palette?.[String(issue.path[2])];
|
|
383
|
+
if (hue == null || typeof hue !== "object") return issue;
|
|
384
|
+
const keys = Object.keys(hue);
|
|
385
|
+
const unknown = keys.filter((k) => !RAMP_KEYS.has(k));
|
|
386
|
+
const message = "seed" in hue && "tones" in hue ? "a hue is either seeded (`seed`, with optional `anchors`) or fixed (`tones`) — not both. Drop one: with both present the `tones` are ignored at generate time." : unknown.length ? `unknown key${unknown.length > 1 ? "s" : ""} ${unknown.map((k) => `"${k}"`).join(", ")}. A hue takes \`seed\` + optional \`anchors\`, or \`tones\`.` : keys.length === 0 ? "a hue needs `seed` (+ optional `anchors`) or `tones`." : null;
|
|
387
|
+
return message ? {
|
|
388
|
+
...issue,
|
|
389
|
+
message
|
|
390
|
+
} : issue;
|
|
391
|
+
};
|
|
341
392
|
/**
|
|
342
393
|
* Validate an unknown value against the design-system schema.
|
|
343
394
|
*
|
|
@@ -350,7 +401,7 @@ function validate(input) {
|
|
|
350
401
|
if (!result.success) return {
|
|
351
402
|
ok: false,
|
|
352
403
|
data: void 0,
|
|
353
|
-
errors: result.error.issues,
|
|
404
|
+
errors: result.error.issues.map(explainRamp(input)),
|
|
354
405
|
warnings: []
|
|
355
406
|
};
|
|
356
407
|
const { palette, roles } = result.data.colors;
|
|
@@ -532,6 +583,50 @@ function ladderWarnings(palette) {
|
|
|
532
583
|
return warnings;
|
|
533
584
|
}
|
|
534
585
|
/**
|
|
586
|
+
* Gamut mapping nudges lightness by a hair, so an exact `<` comparison would
|
|
587
|
+
* report rounding as an inversion. Well below any deviation a person could see.
|
|
588
|
+
*/
|
|
589
|
+
const MONOTONIC_EPSILON = .001;
|
|
590
|
+
/**
|
|
591
|
+
* Ramps whose lightness does not fall monotonically from 50 to 950.
|
|
592
|
+
*
|
|
593
|
+
* A ramp built from the ladder alone cannot invert — only pinned colours (a seed,
|
|
594
|
+
* an `anchor`, a `tones` entry) can, because they are the one thing allowed off
|
|
595
|
+
* it. `ladderWarnings` will not catch it: it compares each pinned step against
|
|
596
|
+
* *its own* ladder rung, never against its neighbours, so it reports the wrong
|
|
597
|
+
* defect ("reads darker than others at 600") while the real problem is that 600
|
|
598
|
+
* is darker than 700. And since `LADDER_TOLERANCE` is 0.03 — the same size as the
|
|
599
|
+
* ladder's own 50→100 gap — two anchors can each sit within tolerance and still
|
|
600
|
+
* invert, with no warning at all.
|
|
601
|
+
*
|
|
602
|
+
* This is an error rather than a warning because the rest of the system assumes
|
|
603
|
+
* the order: `snap` picks the nearest step by ladder lightness, the mode-stable
|
|
604
|
+
* solid family hard-codes 600/700/800 as increasingly dark, and both dark tables
|
|
605
|
+
* re-point steps on the same assumption. An inverted ramp therefore runs hover
|
|
606
|
+
* states backwards — the failure the fixed ladder was introduced to eliminate.
|
|
607
|
+
*/
|
|
608
|
+
function monotonicityErrors(palette) {
|
|
609
|
+
const errors = [];
|
|
610
|
+
for (const [hue, expanded] of Object.entries(palette)) {
|
|
611
|
+
const numeric = expanded.numeric ?? {};
|
|
612
|
+
const steps = NUMERIC_STEPS.filter((s) => numeric[s] != null);
|
|
613
|
+
for (let i = 1; i < steps.length; i++) {
|
|
614
|
+
const [prev, step] = [steps[i - 1], steps[i]];
|
|
615
|
+
const lPrev = hexToOklch(numeric[prev]).l;
|
|
616
|
+
const lStep = hexToOklch(numeric[step]).l;
|
|
617
|
+
if (lStep < lPrev + MONOTONIC_EPSILON) continue;
|
|
618
|
+
const pinned = expanded.deviations ?? {};
|
|
619
|
+
const culprits = [prev, step].filter((s) => pinned[s] != null);
|
|
620
|
+
const fix = culprits.length ? culprits.map((s) => {
|
|
621
|
+
const l = hexToOklch(numeric[s]).l;
|
|
622
|
+
return `${numeric[s]} (pinned at ${s}) sits at L ${l.toFixed(3)}, nearest step ${nearestStep(l)}`;
|
|
623
|
+
}).join("; ") : `no step here is pinned, so this is a bug in the generator rather than in your config`;
|
|
624
|
+
errors.push(`colors.palette.${hue}: step ${step} (${numeric[step]}, L ${lStep.toFixed(3)}) is LIGHTER than step ${prev} (${numeric[prev]}, L ${lPrev.toFixed(3)}). A ramp must darken from 50 to 950 — inverted, a hover from \`bg-<role>-solid\` to \`-solid-bold\` gets lighter, and the dark-mode tables re-point steps assuming the order. ${fix}.`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return errors;
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
535
630
|
* Surface roles: a bare `bg` (cards, panels, inputs), `bg-muted` for the page
|
|
536
631
|
* behind them, `bg-x-muted` for wells, and `bg-bold`/`-x-bold` for the inverse
|
|
537
632
|
* surface a tooltip sits on. Elevation is expressed by *which* token you reach
|
|
@@ -1194,6 +1289,8 @@ function renderCssClasses(ds) {
|
|
|
1194
1289
|
const typeRoles = Object.keys(ds.typography?.roles ?? {});
|
|
1195
1290
|
const shadows = Object.keys(ds.shadows ?? {});
|
|
1196
1291
|
const effects = Object.keys(ds.animations?.effects ?? {});
|
|
1292
|
+
const stateful = Object.entries(ds.animations?.effects ?? {}).filter(([, e]) => e.kf === "composite" || e.kf === "paint").map(([name]) => name);
|
|
1293
|
+
const keyframeOnly = effects.filter((name) => !stateful.includes(name));
|
|
1197
1294
|
const items = ds.patterns?.items ?? {};
|
|
1198
1295
|
const patterns = Object.keys(items);
|
|
1199
1296
|
const patternRoles = Array.from(new Set(Object.values(items).flatMap((p) => [p.default_role, ...p.roles ?? []].filter(Boolean))));
|
|
@@ -1253,9 +1350,29 @@ Every utility accepts a **container-breakpoint prefix**; animation utilities als
|
|
|
1253
1350
|
\`--width-breakout\` / \`--width-spotlight\` and \`--gutter\`.
|
|
1254
1351
|
- **\`rhythm\`** — relationship-based vertical spacing (margins between headings, paragraphs,
|
|
1255
1352
|
lists, media) driven by the space scale. Usually paired with \`centered\`.
|
|
1256
|
-
- **\`split\`** —
|
|
1257
|
-
\`1-2\`, \`2-1\`, \`1-3\`, \`3-1\`, \`1-4\`, \`4-1\`, \`2-3\`, \`3-2\` (breakpoint-prefixable)
|
|
1258
|
-
|
|
1353
|
+
- **\`split\`** — a two-column pair. Ratio rule: **\`split-<a>-<b>\`** where \`<a>-<b>\` ∈
|
|
1354
|
+
\`1-2\`, \`2-1\`, \`1-3\`, \`3-1\`, \`1-4\`, \`4-1\`, \`2-3\`, \`3-2\` (breakpoint-prefixable); equal
|
|
1355
|
+
columns without one. The ratio is a flex **basis**, so a column's padding counts
|
|
1356
|
+
inside its share, and \`min-inline-size: 0\` is built in so long unbreakable content
|
|
1357
|
+
can't stretch a column past it.
|
|
1358
|
+
- **Stacking is \`flex-col\`** — there is no split-specific class for it:
|
|
1359
|
+
\`class="split flex-col md-split-1-2"\` is stacked below 48rem and 1:2 above,
|
|
1360
|
+
because the \`<bp>-\` ratio classes assert the row. While stacked the ratio goes
|
|
1361
|
+
inert on its own (a percentage basis against an auto-height column resolves as
|
|
1362
|
+
\`content\`), unless you give the split a definite \`block-size\`.
|
|
1363
|
+
- **\`split-reverse\`** — swaps the two panels (breakpoint-prefixable). Implemented
|
|
1364
|
+
as \`order\` on the first child, so it reverses on whichever axis the split is
|
|
1365
|
+
currently on: bare, it swaps the columns in a row AND the rows in a stack;
|
|
1366
|
+
scoped (\`md-split-reverse\`) it swaps only once there are two columns — media
|
|
1367
|
+
first in source so it leads on mobile, on the right at width. The ratio stays
|
|
1368
|
+
with the source-first child, not with the visual position.
|
|
1369
|
+
- **Accessibility:** reversing makes visual order disagree with DOM order, and
|
|
1370
|
+
focus order follows the DOM (WCAG 2.4.3 Focus Order). Put focusable content in
|
|
1371
|
+
**only one** of the two panels, or the tab order will not be linear. The
|
|
1372
|
+
pattern declares \`reading-flow: flex-visual\`, which fixes this properly where
|
|
1373
|
+
it is supported; support is not yet broad enough to rely on.
|
|
1374
|
+
- **Flex** — \`flex\`, \`flex-row\`, \`flex-col\`, \`flex-row-reverse\`, \`flex-col-reverse\`
|
|
1375
|
+
(all breakpoint-prefixable).
|
|
1259
1376
|
- **Alignment** — \`items-{start,center,end,stretch}\`, \`justify-{start,center,end,between}\`,
|
|
1260
1377
|
text align \`text-{start,center,end}\` (all breakpoint-prefixable).
|
|
1261
1378
|
- **Display** — \`block\`, \`inline\`, \`inline-block\`, \`flex\`, \`grid\`, \`hidden\`
|
|
@@ -1266,15 +1383,27 @@ Every utility accepts a **container-breakpoint prefix**; animation utilities als
|
|
|
1266
1383
|
## Spacing
|
|
1267
1384
|
|
|
1268
1385
|
The space scale is ${code(spaceNames)} exposed as \`--space-<name>\` tokens.
|
|
1269
|
-
|
|
1270
|
-
|
|
1386
|
+
\`rhythm\` margins consume these tokens; prefer \`rhythm\` for vertical flow rather than
|
|
1387
|
+
per-element margins.
|
|
1388
|
+
|
|
1389
|
+
Rule: **\`gap-<name>\`**, **\`gap-x-<name>\`** (column) and **\`gap-y-<name>\`** (row), name ∈
|
|
1390
|
+
${code(spaceNames)} — all breakpoint-prefixable (\`md-gap-l\`, \`@md:gap-l\`). These are the
|
|
1391
|
+
framework's own utilities in every format: the fluid steps are deliberately kept out of
|
|
1392
|
+
Tailwind's \`--spacing-*\` namespace (named keys there shadow the size scales, so
|
|
1393
|
+
\`max-w-7xl\` would resolve to \`var(--spacing-7xl)\`), which means Tailwind's numeric
|
|
1394
|
+
\`gap-4\` still uses its own multiplier and coexists with these.
|
|
1271
1395
|
|
|
1272
1396
|
## Typography
|
|
1273
1397
|
|
|
1274
1398
|
Rule: **\`font-<role>\`** — role ∈ ${code(typeRoles)}. Each role carries its own family,
|
|
1275
|
-
size (from the type scale ${code(typeNames)}), tracking, transform, and
|
|
1399
|
+
size (from the type scale ${code(typeNames)}), tracking, transform, weight and \`text-wrap\`.
|
|
1276
1400
|
Families: ${code(families)} (\`--font-*\`).
|
|
1277
1401
|
|
|
1402
|
+
Because the role owns \`text-wrap\`, a heading is balanced and copy is \`pretty\` with **no
|
|
1403
|
+
class at all** wherever \`typography.headings\` maps the bare element to a role. Override one
|
|
1404
|
+
element with \`text-{wrap,nowrap,balance,pretty}\` — the per-element escape hatch, for markup
|
|
1405
|
+
that carries no role class. (These four are Tailwind's own in the tailwind format.)
|
|
1406
|
+
|
|
1278
1407
|
## Colour
|
|
1279
1408
|
|
|
1280
1409
|
**Functional tokens are the primary vocabulary** — classes name the *job*, not the tone, and
|
|
@@ -1360,10 +1489,41 @@ follows non-rectangular shapes).
|
|
|
1360
1489
|
|
|
1361
1490
|
## Animation
|
|
1362
1491
|
|
|
1363
|
-
|
|
1492
|
+
An effect carries no motion of its own — it sets \`--<prop>-from\`/\`-to\` and picks a keyframe.
|
|
1493
|
+
A **driver** supplies the motion, and you always compose one of each:
|
|
1494
|
+
|
|
1495
|
+
- \`animate-view\` — plays as the element crosses the viewport
|
|
1496
|
+
- \`animate-scroll\` — scrubs against page scroll
|
|
1497
|
+
- \`animate-trigger\` — time-based; plays once when \`.is-active\` / \`[data-active]\` is set
|
|
1498
|
+
- \`transition\` — transitions the same from/to vars, so it reverses on a state flip
|
|
1499
|
+
|
|
1500
|
+
Rule: **\`<effect>\`** — effect ∈ ${code(effects)}. The state/flip prefixes above pair with
|
|
1501
|
+
\`transition\` and apply to every one of them.
|
|
1502
|
+
|
|
1503
|
+
Each state matches the element **or its direct parent** (\`.hover-<fx>:hover, :hover > .hover-<fx>\`),
|
|
1504
|
+
which is what makes \`reveal-*\` usable: it rests at a zero-area \`clip-path\`, and \`clip-path\` clips
|
|
1505
|
+
hit-testing as well as painting, so the element itself can never be hovered.${keyframeOnly.length ? `
|
|
1506
|
+
|
|
1507
|
+
${code(keyframeOnly)} animate \`height\`, the only stage that reflows and the only one behind a
|
|
1508
|
+
feature gate: \`0 → auto\` is not interpolable without \`interpolate-size: allow-keywords\`, so
|
|
1509
|
+
\`transition\` declares \`height\` only inside an \`@supports\` for it. The \`layout\` **keyframe**
|
|
1510
|
+
has the same dependency — this is not a limit of the transition driver.` : ""}
|
|
1511
|
+
|
|
1512
|
+
**When it plays.** \`animate-view\` and \`.is-active\` are both timed off the element's **midpoint**:
|
|
1513
|
+
motion starts once that midpoint is 10% of the viewport in, and a one-shot entrance completes at
|
|
1514
|
+
25%. Both stops are the element's position on screen rather than a fraction of its own height, so a
|
|
1515
|
+
small card and a full-bleed section behave alike. Shift the window with \`--anim-start\` /
|
|
1516
|
+
\`--anim-end\`, or replace it outright with \`--anim-range\`.
|
|
1517
|
+
|
|
1364
1518
|
Composed **journeys** chain multiple effects into one entrance: rule \`<parts>-journey\`
|
|
1365
|
-
(e.g. \`fade-slide-journey\`, \`fade-scale-blur-journey\`).
|
|
1366
|
-
|
|
1519
|
+
(e.g. \`fade-slide-journey\`, \`fade-scale-blur-journey\`). They need a **keyframe** driver, not
|
|
1520
|
+
\`transition\`. A journey is entry → hold → exit, so it starts on that same 10% pivot but runs to the
|
|
1521
|
+
end of the exit phase — the hold occupies the middle of the crossing instead of the bottom edge of
|
|
1522
|
+
the screen.
|
|
1523
|
+
|
|
1524
|
+
**\`stagger\`** on a parent offsets each child by \`--stagger-amount\` (time-based drivers) and by
|
|
1525
|
+
\`--stagger-range-step\` (scroll-driven ones — \`animation-delay\` is ignored on a progress-based
|
|
1526
|
+
timeline). Journeys set an explicit range and opt out.
|
|
1367
1527
|
|
|
1368
1528
|
## Component patterns
|
|
1369
1529
|
|
|
@@ -1467,6 +1627,13 @@ enforces.
|
|
|
1467
1627
|
|
|
1468
1628
|
- Set \`"$schema": "${SCHEMA_URL}"\` in the config for editor autocomplete + validation.
|
|
1469
1629
|
- Scaffold a starter config with \`vitops init\`; check one with \`vitops validate\`.
|
|
1630
|
+
- **This can be a standalone file, or live inside a site config.** Anywhere the
|
|
1631
|
+
toolchain takes a config — \`--input\`, the Vite plugin, the Astro integration's
|
|
1632
|
+
\`css.input\` — that file may be a \`design-system.json\` or the larger site config
|
|
1633
|
+
that embeds one under \`designSystem.themes.<name>\`. They are told apart by shape,
|
|
1634
|
+
and the fields below are the same either way. A site config additionally supplies
|
|
1635
|
+
the site-level facts generation reads (its default colour scheme, legal documents,
|
|
1636
|
+
icon sprite), so the path is declared once rather than per option.
|
|
1470
1637
|
- The *why* behind each section: [colour system](concepts/color.md),
|
|
1471
1638
|
[type & space scales](concepts/scales.md), [component patterns](concepts/patterns.md).
|
|
1472
1639
|
- What each output format does with these tokens: [formats.md](formats.md).
|
|
@@ -1575,19 +1742,31 @@ docs build.
|
|
|
1575
1742
|
@layer vitops.base, vitops.components, vitops.utilities;
|
|
1576
1743
|
\`\`\`
|
|
1577
1744
|
|
|
1578
|
-
- \`vitops.base\` — the
|
|
1579
|
-
|
|
1745
|
+
- \`vitops.base\` — the reset (\`box-sizing: border-box\` on every element and pseudo-element,
|
|
1746
|
+
a 16px root, no body margin) and the pure \`:root\` token blocks. Lowest of the three, so
|
|
1747
|
+
your own reset overrides it — unlayered, or from a layer you declare before \`vitops.base\`.
|
|
1748
|
+
- \`vitops.components\` — the animation engine, the structural patterns (\`.rhythm\`,
|
|
1749
|
+
\`.centered\`, \`.region\`, \`.split\`, \`.reveal\`) and every UI pattern.
|
|
1580
1750
|
- \`vitops.utilities\` — \`bg-*\`, \`text-*\`, \`border-*\`, \`drop-shadow-*\`, \`font-*\`,
|
|
1581
|
-
animation effects,
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1751
|
+
\`gap-*\`, animation effects, the layout utilities (\`.m-*\`, \`.flex-*\`, \`.items-*\`,
|
|
1752
|
+
\`.split-<a>-<b>\`, track placement) and the display/\`sr-only\` families.
|
|
1753
|
+
|
|
1754
|
+
So a utility overrides a pattern: \`class="card bg-danger-muted"\` tints the card,
|
|
1755
|
+
\`class="split flex-col"\` stacks the split, \`class="table text-center"\` centres the table.
|
|
1756
|
+
Your own unlayered CSS beats all three — see [concepts/patterns.md](concepts/patterns.md) for
|
|
1757
|
+
the override story and the one gotcha (a reset must be layered and ordered first).
|
|
1758
|
+
|
|
1759
|
+
The classification is by RULE, not by file: a partial that mixes patterns and utilities is
|
|
1760
|
+
split in two rather than shelved whole. \`layout.css\` (patterns) and \`layout-utilities.css\`
|
|
1761
|
+
(utilities) are the same family in two files for exactly this reason, and the \`tailwind\`
|
|
1762
|
+
format reaches the same arrangement by its own route — patterns in \`@layer components\`,
|
|
1763
|
+
utilities as \`@utility\`.
|
|
1764
|
+
|
|
1765
|
+
Known gap: the \`typography.headings\` bare-element bindings (\`h1\`, \`h2\`, \`body\`) are
|
|
1766
|
+
emitted alongside \`.font-<role>\` and so sit in \`vitops.utilities\`, where a tag rule
|
|
1767
|
+
outranks every pattern — \`<h2 class="pull-quote">\` keeps the heading's font-size in
|
|
1768
|
+
\`css\`/\`bricks\` and the pattern's in \`tailwind\`, which puts the bindings in \`@layer base\`.
|
|
1769
|
+
Give the element a \`.font-<role>\` class to pin it either way.
|
|
1591
1770
|
|
|
1592
1771
|
## \`bricks\` — WordPress / Bricks Builder payload
|
|
1593
1772
|
|
|
@@ -1723,8 +1902,18 @@ ignores the appearance.
|
|
|
1723
1902
|
|
|
1724
1903
|
Two attributes, one flip: \`data-brx-theme\` is Bricks' own (Bricks sets it on the
|
|
1725
1904
|
WordPress target), \`data-theme\` is what the shipped \`<color-scheme-toggle>\` writes on
|
|
1726
|
-
\`<html>\`, so the toggle works on every other target. Set either.
|
|
1727
|
-
|
|
1905
|
+
\`<html>\`, so the toggle works on every other target. Set either.
|
|
1906
|
+
|
|
1907
|
+
The OS preference is a **second, opt-in block**. Set
|
|
1908
|
+
\`designSystem.defaultColorScheme: "system"\` in your site config and the same delta is emitted again inside
|
|
1909
|
+
\`@media (prefers-color-scheme: dark)\`, under
|
|
1910
|
+
\`${SYSTEM_DARK_SEL}\` — i.e. whenever no explicit choice has been made. That is what makes
|
|
1911
|
+
\`<color-scheme-toggle>\`'s "System" position resolve to the OS (it *removes* the attribute,
|
|
1912
|
+
so without this block it fell through to light), and it gives a no-JS page the OS
|
|
1913
|
+
appearance, which the toggle alone never could. An explicit light choice still wins.
|
|
1914
|
+
|
|
1915
|
+
It is opt-in rather than default because turning it on flips a site dark for dark-OS
|
|
1916
|
+
visitors, which is the site's decision and not the design system's.
|
|
1728
1917
|
|
|
1729
1918
|
## Contrast guarantees
|
|
1730
1919
|
|
|
@@ -1947,6 +2136,118 @@ variable-driven CSS framework plus progressively-enhanced web components, genera
|
|
|
1947
2136
|
* [Bricks Builder](bricks/) - custom elements and how to style them
|
|
1948
2137
|
`;
|
|
1949
2138
|
}
|
|
2139
|
+
function renderIconsConcept() {
|
|
2140
|
+
return `${frontmatter({
|
|
2141
|
+
type: "Design Concept",
|
|
2142
|
+
title: "Vitops icons — one semantic vocabulary across icon sets",
|
|
2143
|
+
description: "Semantic icon names that resolve per configured set, how the bundle is derived on a server build, and the three delivery paths (astro-icon, astro-iconset, SVG sprite).",
|
|
2144
|
+
resource: DS_PATH,
|
|
2145
|
+
tags: [
|
|
2146
|
+
"icons",
|
|
2147
|
+
"svg",
|
|
2148
|
+
"sprite",
|
|
2149
|
+
"astro-icon",
|
|
2150
|
+
"design-system"
|
|
2151
|
+
]
|
|
2152
|
+
})}
|
|
2153
|
+
|
|
2154
|
+
# Icons
|
|
2155
|
+
|
|
2156
|
+
Icons are named by **meaning**, not by set. \`menu\` resolves to \`fa7-solid:bars\`,
|
|
2157
|
+
\`lucide:menu\` or \`ph:list\` depending on which set the site configures, so changing
|
|
2158
|
+
sets is a config edit rather than a find-and-replace across templates.
|
|
2159
|
+
|
|
2160
|
+
The escape hatch is deliberate and always available: **a name containing \`:\` passes
|
|
2161
|
+
through untouched**. \`<Icon name="simple-icons:zoho" />\` renders that exact glyph
|
|
2162
|
+
whether or not the semantic map knows it.
|
|
2163
|
+
|
|
2164
|
+
## Declaring them
|
|
2165
|
+
|
|
2166
|
+
The \`icons\` block lives in the **site config**, not \`design-system.json\` — it
|
|
2167
|
+
describes what a site uses, not what the design system defines.
|
|
2168
|
+
|
|
2169
|
+
\`\`\`json
|
|
2170
|
+
{
|
|
2171
|
+
"icons": {
|
|
2172
|
+
"ui": "ph",
|
|
2173
|
+
"brand": "simple-icons",
|
|
2174
|
+
"weight": "bold",
|
|
2175
|
+
"semantic": ["menu", "close", "arrow-right"],
|
|
2176
|
+
"simple-icons": ["zoho", "cloudflare"],
|
|
2177
|
+
"sprite": true
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
\`\`\`
|
|
2181
|
+
|
|
2182
|
+
\`ui\` and \`brand\` pick which set a semantic name resolves against. Any other key is
|
|
2183
|
+
an iconify collection name with an explicit list, passed through verbatim.
|
|
2184
|
+
|
|
2185
|
+
**\`weight\` is only meaningful for sets that encode weight in the icon NAME.**
|
|
2186
|
+
Phosphor does (\`list\`, \`list-bold\`, \`list-fill\`), so \`ph\` accepts it; Font Awesome
|
|
2187
|
+
splits weights across collections instead (\`fa7-solid\` vs \`fa7-regular\`), so there
|
|
2188
|
+
the weight is part of the prefix and this key is ignored.
|
|
2189
|
+
|
|
2190
|
+
## Why the bundle list exists
|
|
2191
|
+
|
|
2192
|
+
astro-icon is **zero-config on a static build**. Under \`output: 'server'\` it bundles
|
|
2193
|
+
*every icon in a set* unless given an \`include\` map — which is why projects end up
|
|
2194
|
+
hand-maintaining one.
|
|
2195
|
+
|
|
2196
|
+
The \`icons\` option on \`getvitops()\` derives it by scanning your source, merged with
|
|
2197
|
+
whatever you declare. On a static build **no include is passed at all**: there is
|
|
2198
|
+
nothing to trim there, and a list could only drop a glyph the scan couldn't see.
|
|
2199
|
+
|
|
2200
|
+
Two failure modes, treated differently on purpose:
|
|
2201
|
+
|
|
2202
|
+
- A name you **declared** that doesn't resolve is a config error and **throws**.
|
|
2203
|
+
- A name only the **scan** found and can't resolve just **warns** — a bare unmapped
|
|
2204
|
+
name is more often a local \`src/icons/*.svg\` than a mistake, and a dev server
|
|
2205
|
+
that dies on a template typo is worse than one that tells you.
|
|
2206
|
+
|
|
2207
|
+
Names computed at runtime (\`<Icon name={expr} />\`) can't be read statically. They are
|
|
2208
|
+
reported with file and line, never guessed at — declare them in \`semantic\` or a
|
|
2209
|
+
per-set list. Run \`vitops icons\` to see the whole report.
|
|
2210
|
+
|
|
2211
|
+
## Delivery
|
|
2212
|
+
|
|
2213
|
+
| Path | Used by | Needs |
|
|
2214
|
+
| --- | --- | --- |
|
|
2215
|
+
| \`astro-icon\` / \`astro-iconset\` | Astro sites | the integration, an optional peer |
|
|
2216
|
+
| **SVG sprite** (\`icons.svg\`) | Bricks/WordPress, EmDash renderers, plain HTML | \`icons.sprite: true\` |
|
|
2217
|
+
|
|
2218
|
+
The sprite is the no-JavaScript path: \`<svg><use href="/vitops/icons.svg#ph--list"></svg>\`.
|
|
2219
|
+
Ids are the qualified name with \`:\` replaced by \`--\` (\`:\` is not valid in a fragment
|
|
2220
|
+
identifier), **plus a set-independent \`icon-<name>\` alias for every semantic name** —
|
|
2221
|
+
so sprite markup survives an icon-set change the same way \`resolveIcon\` protects Astro
|
|
2222
|
+
call sites.
|
|
2223
|
+
|
|
2224
|
+
In WordPress, \`vitops_icon('menu')\` and \`[vitops_icon name="menu"]\` emit that markup,
|
|
2225
|
+
and a **Vitops → Icon** Bricks element wraps the same helper.
|
|
2226
|
+
|
|
2227
|
+
⚠️ An external-file \`<use>\` is **same-origin only** and dead under \`file://\`. Serving
|
|
2228
|
+
\`dist/\` from a different origin makes every icon vanish with no console error.
|
|
2229
|
+
|
|
2230
|
+
## Markup
|
|
2231
|
+
|
|
2232
|
+
Always the framework's \`.icon\` box:
|
|
2233
|
+
|
|
2234
|
+
\`\`\`html
|
|
2235
|
+
<span class="icon" aria-hidden="true"><svg>…</svg></span>
|
|
2236
|
+
\`\`\`
|
|
2237
|
+
|
|
2238
|
+
\`.icon\` sizes in \`em\` via \`--icon-size\` (default \`1.25em\`) and sets \`fill: currentColor\`,
|
|
2239
|
+
so an icon inherits the colour of whatever it sits in. Because \`.cta\` and
|
|
2240
|
+
\`:where(button, .btn)\` are already \`inline-flex\` with a \`gap\`, **a start or end icon is
|
|
2241
|
+
child order — not a modifier class**.
|
|
2242
|
+
|
|
2243
|
+
Icons are \`aria-hidden\` by default. Pass a label only when the icon carries meaning on
|
|
2244
|
+
its own; beside a text label it would be announced twice.
|
|
2245
|
+
|
|
2246
|
+
Note \`.icon-mask\` is a different thing: the CSS-only adornment path used by
|
|
2247
|
+
\`.link--icon::before\`, where the glyph is a \`mask-image\` on a pseudo-element and there
|
|
2248
|
+
is no markup to hang an \`<svg>\` on.
|
|
2249
|
+
`;
|
|
2250
|
+
}
|
|
1950
2251
|
function renderConceptsIndex() {
|
|
1951
2252
|
return `${INDEX_NOTE}
|
|
1952
2253
|
|
|
@@ -1957,6 +2258,7 @@ function renderConceptsIndex() {
|
|
|
1957
2258
|
* [Colour system](color.md) - seeded OKLCH scales on a shared lightness ladder, target-prefixed tokens, automatic dark mode
|
|
1958
2259
|
* [Type & space scales](scales.md) - fluid modular scales and the tokens they emit
|
|
1959
2260
|
* [Component patterns](patterns.md) - token cascade, override hooks, states, role variants
|
|
2261
|
+
* [Icons](icons.md) - semantic names across icon sets, bundle derivation, sprite delivery
|
|
1960
2262
|
`;
|
|
1961
2263
|
}
|
|
1962
2264
|
function renderCssIndex() {
|
|
@@ -2004,6 +2306,7 @@ function generateDocs(ds, assetsDir) {
|
|
|
2004
2306
|
"concepts/color.md": renderColorConcept(ds),
|
|
2005
2307
|
"concepts/scales.md": renderScalesConcept(ds),
|
|
2006
2308
|
"concepts/patterns.md": renderPatternsConcept(ds),
|
|
2309
|
+
"concepts/icons.md": renderIconsConcept(),
|
|
2007
2310
|
"css/index.md": renderCssIndex(),
|
|
2008
2311
|
"css/classes.md": renderCssClasses(ds),
|
|
2009
2312
|
"bricks/index.md": renderBricksIndex(),
|
|
@@ -2362,6 +2665,10 @@ Layout is composed from utilities rather than bespoke CSS:
|
|
|
2362
2665
|
decision rather than per-element margins.
|
|
2363
2666
|
- \`.flex\` / \`.split-<a>-<b>\` / \`.grid\` — the structural families, with container-query
|
|
2364
2667
|
variants prefixed \`sm-\` / \`md-\` / \`lg-\` / \`xl-\` (30 / 48 / 64 / 80rem).
|
|
2668
|
+
\`.split\` is a two-column pair. Stack it with \`.flex-col\` and let a
|
|
2669
|
+
breakpoint-prefixed ratio un-stack it (\`split flex-col md-split-1-2\`);
|
|
2670
|
+
\`.split-reverse\` swaps the two panels. Reversing puts visual order out of step
|
|
2671
|
+
with DOM order, so keep focusable content in only one panel.
|
|
2365
2672
|
|
|
2366
2673
|
Breakpoints are **container** queries, not viewport media queries: a component
|
|
2367
2674
|
responds to the space it is given, so the same markup works in a sidebar and a full-width
|
|
@@ -2442,7 +2749,7 @@ across every pattern. Focus rings are never removed, only restyled.
|
|
|
2442
2749
|
* `design-system.json`-compliant `designSystem`.
|
|
2443
2750
|
*
|
|
2444
2751
|
* Authored with `zod/mini` (same instance the design-system schema uses), so the
|
|
2445
|
-
* two compose: `DesignSystemSchema` is embedded as `designSystem.<theme>` and the
|
|
2752
|
+
* two compose: `DesignSystemSchema` is embedded as `designSystem.themes.<theme>` and the
|
|
2446
2753
|
* whole thing serialises to one JSON Schema via `z.toJSONSchema`. Everything a
|
|
2447
2754
|
* generator needs derives from here: the `SiteConfig` type (`z.infer`), the
|
|
2448
2755
|
* published JSON Schema (`siteJsonSchema`), and runtime validation (`validateSite`).
|
|
@@ -2475,9 +2782,32 @@ const DayOfWeek = z.enum([
|
|
|
2475
2782
|
"Sunday"
|
|
2476
2783
|
]);
|
|
2477
2784
|
const DesignSystemEntry = z.extend(DesignSystemPatchSchema, {
|
|
2478
|
-
displayName: desc(z.optional(LocalizableSchema), "Human-readable theme name (localisable)."),
|
|
2479
|
-
extends: desc(z.optional(z.string()), "Another `
|
|
2785
|
+
displayName: desc(z.optional(LocalizableSchema), "Human-readable theme name (localisable). The label a theme picker would show — nothing reads it yet, because nothing builds more than one theme (see `themes`)."),
|
|
2786
|
+
extends: desc(z.optional(z.string()), "Another `themes` key to inherit from; this entry then only supplies what it overrides.")
|
|
2480
2787
|
});
|
|
2788
|
+
/**
|
|
2789
|
+
* The design system as a whole: the named themes plus the facts that describe the
|
|
2790
|
+
* system rather than any one theme.
|
|
2791
|
+
*
|
|
2792
|
+
* `themes` is nested rather than being the block itself because the block used to
|
|
2793
|
+
* BE the map — every key was a theme name, so there was nowhere to put
|
|
2794
|
+
* system-wide metadata without it colliding with a theme of that name.
|
|
2795
|
+
*
|
|
2796
|
+
* Note the two axes here are independent, and deliberately so. `defaultTheme`
|
|
2797
|
+
* picks a *theme* (an authored design system); `defaultColorScheme` picks an
|
|
2798
|
+
* *appearance* (light/dark), which every theme already has because the flip is
|
|
2799
|
+
* derived from one ramp rather than authored. "Theme `elegant`, appearance
|
|
2800
|
+
* follows the OS" has to be sayable, and a single union could not express it.
|
|
2801
|
+
*/
|
|
2802
|
+
const DesignSystemBlock = desc(z.strictObject({
|
|
2803
|
+
themes: desc(z.record(z.string(), DesignSystemEntry), "Named themes. `default` is the base; others may `extends` another and supply a partial patch. Light/dark is NOT a theme — the functional tokens flip per appearance within each one, so `default` already has both. Only the default theme is built today; multi-theme output and a picker are not wired yet."),
|
|
2804
|
+
defaultTheme: desc(z.optional(z.string()), "Which `themes` entry to use (convention: \"default\"). Validated against the map; selecting a non-default theme is not wired yet."),
|
|
2805
|
+
defaultColorScheme: desc(z.optional(z.enum([
|
|
2806
|
+
"light",
|
|
2807
|
+
"dark",
|
|
2808
|
+
"system"
|
|
2809
|
+
])), "Initial appearance. `\"system\"` follows the OS via `prefers-color-scheme` and is what makes `<color-scheme-toggle>`'s \"System\" position resolve to anything — it removes the theme attribute, so without this the page falls through to light. It also gives a no-JS page the OS appearance. Defaults to `\"light\"`, because switching an existing site to `\"system\"` visibly flips it dark for dark-OS visitors.")
|
|
2810
|
+
}), "The design system: named themes plus the system-wide facts (which theme, which appearance). A bare design system, or a bare theme map, is accepted as shorthand for `{ themes: … }`.");
|
|
2481
2811
|
const LocaleSchema = z.object({
|
|
2482
2812
|
name: desc(z.string(), "Display name of the locale (e.g. \"English\")."),
|
|
2483
2813
|
tagline: desc(z.optional(z.string()), "Site tagline in this locale."),
|
|
@@ -2626,7 +2956,7 @@ const FontVariantSchema = z.object({
|
|
|
2626
2956
|
unicodeRange: z.optional(z.array(z.string())),
|
|
2627
2957
|
display: z.optional(FontDisplay)
|
|
2628
2958
|
});
|
|
2629
|
-
const
|
|
2959
|
+
const SiteFontSchema = z.object({
|
|
2630
2960
|
name: z.string(),
|
|
2631
2961
|
provider: FontProvider,
|
|
2632
2962
|
cssVariable: z.string().check(z.regex(/^--/)),
|
|
@@ -2775,6 +3105,35 @@ const TwitterSchema = z.object({
|
|
|
2775
3105
|
image: z.optional(ImageRefSchema)
|
|
2776
3106
|
});
|
|
2777
3107
|
/**
|
|
3108
|
+
* What `vitops notify` needs to tell search engines a deploy happened.
|
|
3109
|
+
*
|
|
3110
|
+
* Scoped by what search engines actually accept, which is narrower than it looks:
|
|
3111
|
+
* Google exposes **no** "request indexing" API (the Search Console button is not in
|
|
3112
|
+
* its API, and URL Inspection is read-only), and it removed the sitemap ping
|
|
3113
|
+
* endpoint in 2023. So the sanctioned set is: an accurate sitemap, a Search Console
|
|
3114
|
+
* sitemap resubmit, an IndexNow ping — which Google does *not* honour but Bing,
|
|
3115
|
+
* Yandex, Naver, Seznam and Yep do — and a read-only inspection pass to find out
|
|
3116
|
+
* what Google actually did.
|
|
3117
|
+
*
|
|
3118
|
+
* Facts only, the same rule the legal block follows: this records what the site has
|
|
3119
|
+
* (a key, a property, which pages matter), never what to say about it.
|
|
3120
|
+
*/
|
|
3121
|
+
const IndexNowSchema = z.object({
|
|
3122
|
+
key: desc(z.string().check(z.regex(/^[a-zA-Z0-9-]{8,128}$/)), "IndexNow key (8–128 chars, hex is conventional). NOT a secret — it is served publicly at `keyLocation` so the engine can verify you own the host. Generate one with `vitops notify --new-key`."),
|
|
3123
|
+
keyLocation: desc(z.optional(z.url()), "Absolute URL of the key file. Defaults to `<canonical>/<key>.txt`; set it only when the file lives elsewhere."),
|
|
3124
|
+
endpoint: desc(z.optional(z.url()), "IndexNow endpoint (default `https://api.indexnow.org/indexnow`). Any participating engine shares submissions with the rest, so one is normally enough.")
|
|
3125
|
+
});
|
|
3126
|
+
const SearchConsoleSchema = z.object({
|
|
3127
|
+
siteUrl: desc(z.string(), "The property exactly as Search Console identifies it — `sc-domain:acme.ca` for a domain property, or the URL-prefix form `https://acme.ca/`. A mismatch here is a 403, not a \"not found\"."),
|
|
3128
|
+
resubmitSitemap: desc(z.optional(z.boolean()), "Re-submit the sitemap through the Search Console API on each notify (default true when `searchConsole` is set). This is the automated equivalent of the manual resubmit in the UI.")
|
|
3129
|
+
});
|
|
3130
|
+
const IndexingSchema = z.object({
|
|
3131
|
+
sitemapUrl: desc(z.optional(z.url()), "The sitemap to submit and to diff for changed URLs. Defaults to `<canonical>/sitemap-index.xml`."),
|
|
3132
|
+
indexNow: desc(z.optional(IndexNowSchema), "IndexNow submission (Bing, Yandex, Naver, Seznam, Yep — not Google). Omit to skip the channel."),
|
|
3133
|
+
searchConsole: desc(z.optional(SearchConsoleSchema), "Google Search Console property. Needs a service-account credential in `VITOPS_GSC_SERVICE_ACCOUNT` or `GOOGLE_APPLICATION_CREDENTIALS` at run time — never put the key in this file."),
|
|
3134
|
+
priorityUrls: desc(z.optional(z.array(z.url())), "The pages whose indexing actually matters. `vitops notify --check` inspects these and exits non-zero if Google has not indexed one. Kept explicit because URL Inspection is quota-bound (2000/day), so checking every page is neither affordable nor informative.")
|
|
3135
|
+
});
|
|
3136
|
+
/**
|
|
2778
3137
|
* Which template set the generated legal documents are written against. This is
|
|
2779
3138
|
* a closed enum rather than a free string because an unrecognised value would
|
|
2780
3139
|
* otherwise silently fall back to the wrong body of law; `validateSite` rejects
|
|
@@ -2828,11 +3187,8 @@ const SiteConfigSchema = z.object({
|
|
|
2828
3187
|
splitRatio: desc(z.optional(z.number().check(z.gte(0), z.lte(1))), "Fraction of traffic sent to the variant (0–1)."),
|
|
2829
3188
|
variants: desc(z.optional(z.record(z.string(), AbVariantSchema)), "Named variants; each targets an environment and may deep-merge config `overrides`.")
|
|
2830
3189
|
})), "A/B testing: cookie-based split plus named variants whose `overrides` patch the config per environment."),
|
|
2831
|
-
designSystem:
|
|
2832
|
-
|
|
2833
|
-
defaultColorScheme: desc(z.optional(z.enum(["light", "dark"])), "Initial appearance to render before any user/system preference applies."),
|
|
2834
|
-
respectSystemPreference: desc(z.optional(z.boolean()), "Follow the user's OS light/dark preference."),
|
|
2835
|
-
fonts: desc(z.optional(z.array(FontSchema)), "Font LOADING (a serialisable projection of the Astro Fonts API: provider, weights, subsets, preload). Font TOKENS live in `designSystem.<theme>.fonts` and reference the same `cssVariable`."),
|
|
3190
|
+
designSystem: DesignSystemBlock,
|
|
3191
|
+
fonts: desc(z.optional(z.array(SiteFontSchema)), "Font LOADING (a serialisable projection of the Astro Fonts API: provider, weights, subsets, preload). Font TOKENS live in `designSystem.themes.<theme>.fonts` and reference the same `cssVariable`."),
|
|
2836
3192
|
tags: desc(z.optional(z.record(z.string(), z.unknown())), "Free-form content-tag taxonomy (site-specific; not interpreted by the generator)."),
|
|
2837
3193
|
postTypes: desc(z.optional(z.record(z.string(), z.unknown())), "Free-form content-type definitions (site-specific; not interpreted by the generator)."),
|
|
2838
3194
|
galleries: desc(z.optional(z.record(z.string(), GallerySchema)), "Named image galleries (title, tags, images with localisable alt/caption)."),
|
|
@@ -2849,13 +3205,20 @@ const SiteConfigSchema = z.object({
|
|
|
2849
3205
|
googleSiteVerification: z.optional(z.string()),
|
|
2850
3206
|
bingSiteVerification: z.optional(z.string()),
|
|
2851
3207
|
openGraph: z.optional(OpenGraphSchema),
|
|
2852
|
-
twitter: z.optional(TwitterSchema)
|
|
2853
|
-
|
|
3208
|
+
twitter: z.optional(TwitterSchema),
|
|
3209
|
+
indexing: desc(z.optional(IndexingSchema), "How `vitops notify` tells search engines about a deploy: which sitemap, IndexNow key, Search Console property, and which pages to verify afterwards.")
|
|
3210
|
+
})), "SEO defaults: title/description templates, robots policy, verification tokens, Open Graph + Twitter cards, and post-deploy indexing notification."),
|
|
2854
3211
|
analytics: desc(z.optional(z.object({
|
|
2855
3212
|
googleAnalyticsId: z.optional(z.string()),
|
|
2856
3213
|
plausibleDomain: z.optional(z.string()),
|
|
2857
|
-
googleTagManagerId: z.optional(z.string())
|
|
2858
|
-
|
|
3214
|
+
googleTagManagerId: z.optional(z.string()),
|
|
3215
|
+
clarityId: desc(z.optional(z.string()), "Microsoft Clarity project ID."),
|
|
3216
|
+
matomo: desc(z.optional(z.object({
|
|
3217
|
+
url: desc(z.string(), "Instance base URL (self-hosted, or `*.matomo.cloud`)."),
|
|
3218
|
+
siteId: desc(z.string(), "Site ID within that instance."),
|
|
3219
|
+
cookies: desc(z.optional(z.boolean()), "Default false — Matomo runs cookieless (`disableCookies`), which is what lets the cookie notice state positively that it sets none.")
|
|
3220
|
+
})), "Matomo instance.")
|
|
3221
|
+
})), "Analytics provider IDs (gated per environment via `environments.<env>.analytics`). Which provider is set is also what the generated privacy policy and cookie notice disclose — see `legal`."),
|
|
2859
3222
|
notifications: desc(z.optional(z.object({ email: z.optional(z.email()) })), "Where site notifications (e.g. form submissions) are sent."),
|
|
2860
3223
|
tracking: desc(z.optional(z.object({
|
|
2861
3224
|
enabled: z.optional(z.boolean()),
|
|
@@ -2889,9 +3252,11 @@ const SiteConfigSchema = z.object({
|
|
|
2889
3252
|
categories: z.optional(z.array(z.string()))
|
|
2890
3253
|
}))
|
|
2891
3254
|
})), "Legal pages: which documents exist, where they live, and the facts the generated prose asserts."),
|
|
2892
|
-
icons: desc(z.optional(z.
|
|
2893
|
-
ui: desc(z.optional(z.string()), "Icon set used for UI chrome (e.g. \"lucide\")."),
|
|
3255
|
+
icons: desc(z.optional(z.looseObject({
|
|
3256
|
+
ui: desc(z.optional(z.string()), "Icon set used for UI chrome (e.g. \"lucide\", \"ph\")."),
|
|
2894
3257
|
brand: desc(z.optional(z.string()), "Icon set used for brand marks."),
|
|
3258
|
+
weight: desc(z.optional(z.string()), "Weight for suffix-weighted sets like Phosphor (\"regular\" | \"bold\" | \"duotone\" | \"fill\" | \"light\" | \"thin\"). Ignored by sets that split weights across collections, e.g. Font Awesome."),
|
|
3259
|
+
sprite: desc(z.optional(z.boolean()), "Emit an SVG sprite (icons.svg) alongside the stylesheet, for consumers that cannot run an icon integration (Bricks/WordPress, EmDash renderers)."),
|
|
2895
3260
|
semantic: desc(z.optional(z.array(z.string())), "Named semantic icons to include."),
|
|
2896
3261
|
"fa7-solid": z.optional(z.array(z.string())),
|
|
2897
3262
|
"fa7-regular": z.optional(z.array(z.string())),
|
|
@@ -2900,7 +3265,8 @@ const SiteConfigSchema = z.object({
|
|
|
2900
3265
|
"fa7-brands": z.optional(z.array(z.string())),
|
|
2901
3266
|
"simple-icons": z.optional(z.array(z.string())),
|
|
2902
3267
|
"material-symbols": z.optional(z.array(z.string())),
|
|
2903
|
-
lucide: z.optional(z.array(z.string()))
|
|
3268
|
+
lucide: z.optional(z.array(z.string())),
|
|
3269
|
+
ph: z.optional(z.array(z.string()))
|
|
2904
3270
|
})), "Icon sets and the specific icons to bundle from each (keys are iconify collection names)."),
|
|
2905
3271
|
favicon: desc(z.optional(z.object({
|
|
2906
3272
|
source: desc(z.string(), "Source image (SVG/PNG) the favicon set is generated from."),
|
|
@@ -2944,17 +3310,24 @@ function validateSite(input) {
|
|
|
2944
3310
|
const errors = [];
|
|
2945
3311
|
const has = (m, k) => !!m && k in m;
|
|
2946
3312
|
if (!has(cfg.locales, cfg.defaultLocale)) errors.push(issue(["defaultLocale"], `defaultLocale "${cfg.defaultLocale}" is not in locales`));
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
3313
|
+
const themes = cfg.designSystem?.themes ?? {};
|
|
3314
|
+
const defaultTheme = cfg.designSystem?.defaultTheme;
|
|
3315
|
+
if (!has(themes, "default") && defaultTheme == null) errors.push(issue(["designSystem", "themes"], "themes must include a \"default\" entry"));
|
|
3316
|
+
if (defaultTheme != null && !has(themes, defaultTheme)) errors.push(issue(["designSystem", "defaultTheme"], `defaultTheme "${defaultTheme}" is not in designSystem.themes`));
|
|
3317
|
+
for (const [key, entry] of Object.entries(themes)) if (entry.extends != null && !has(themes, entry.extends)) errors.push(issue([
|
|
2950
3318
|
"designSystem",
|
|
3319
|
+
"themes",
|
|
2951
3320
|
key,
|
|
2952
3321
|
"extends"
|
|
2953
|
-
], `extends "${entry.extends}" is not a
|
|
2954
|
-
for (const key of Object.keys(
|
|
2955
|
-
resolveTheme(
|
|
3322
|
+
], `extends "${entry.extends}" is not a themes key`));
|
|
3323
|
+
for (const key of Object.keys(themes)) try {
|
|
3324
|
+
resolveTheme(themes, key);
|
|
2956
3325
|
} catch (e) {
|
|
2957
|
-
errors.push(issue([
|
|
3326
|
+
errors.push(issue([
|
|
3327
|
+
"designSystem",
|
|
3328
|
+
"themes",
|
|
3329
|
+
key
|
|
3330
|
+
], e.message));
|
|
2958
3331
|
}
|
|
2959
3332
|
if (cfg.primaryLocation != null && !has(cfg.locations, cfg.primaryLocation)) errors.push(issue(["primaryLocation"], `primaryLocation "${cfg.primaryLocation}" is not in locations`));
|
|
2960
3333
|
if (typeof cfg.contact === "string" && !has(cfg.locations, cfg.contact)) errors.push(issue(["contact"], `contact "${cfg.contact}" is not a locations key`));
|
|
@@ -2993,6 +3366,20 @@ function validateSite(input) {
|
|
|
2993
3366
|
if (resolvePrivacyContact(cfg) == null) errors.push(issue(["legal", "privacyPolicy"], "privacyPolicy.enabled requires a contact for privacy requests — set legal.privacyPolicy.privacyOfficer, contact, or organization.email/address"));
|
|
2994
3367
|
if (cfg.domains?.canonical == null) errors.push(issue(["legal", "privacyPolicy"], "privacyPolicy.enabled requires domains.canonical — the policy states where information is held"));
|
|
2995
3368
|
}
|
|
3369
|
+
const indexing = cfg.seo?.indexing;
|
|
3370
|
+
if (indexing != null && cfg.domains?.canonical == null) {
|
|
3371
|
+
if (indexing.sitemapUrl == null) errors.push(issue([
|
|
3372
|
+
"seo",
|
|
3373
|
+
"indexing",
|
|
3374
|
+
"sitemapUrl"
|
|
3375
|
+
], "seo.indexing needs domains.canonical to derive the sitemap URL — set one, or state sitemapUrl explicitly"));
|
|
3376
|
+
if (indexing.indexNow != null && indexing.indexNow.keyLocation == null) errors.push(issue([
|
|
3377
|
+
"seo",
|
|
3378
|
+
"indexing",
|
|
3379
|
+
"indexNow",
|
|
3380
|
+
"keyLocation"
|
|
3381
|
+
], "seo.indexing.indexNow needs domains.canonical to derive the key location — set one, or state keyLocation explicitly"));
|
|
3382
|
+
}
|
|
2996
3383
|
if (errors.length) return {
|
|
2997
3384
|
ok: false,
|
|
2998
3385
|
data: void 0,
|
|
@@ -3061,22 +3448,49 @@ function resolveTheme(designSystem, key) {
|
|
|
3061
3448
|
return out;
|
|
3062
3449
|
}
|
|
3063
3450
|
/**
|
|
3451
|
+
* Normalise the two `designSystem` shorthands to the canonical
|
|
3452
|
+
* `{ themes: { … } }` shape.
|
|
3453
|
+
*
|
|
3454
|
+
* Both are keyed off what CANNOT be a theme name in the other reading:
|
|
3455
|
+
* - `colors` → a bare `DesignSystem` was written inline.
|
|
3456
|
+
* - `themes` → already canonical.
|
|
3457
|
+
* - neither → the legacy bare theme map, from before the block gained
|
|
3458
|
+
* system-wide fields. Every config authored against the old schema takes this
|
|
3459
|
+
* branch, which is what keeps the change forgiving at runtime even though the
|
|
3460
|
+
* published JSON Schema moved.
|
|
3461
|
+
*/
|
|
3462
|
+
function normaliseDesignSystem(raw) {
|
|
3463
|
+
const ds = raw.designSystem;
|
|
3464
|
+
if (ds == null || typeof ds !== "object") return;
|
|
3465
|
+
if ("themes" in ds) return;
|
|
3466
|
+
raw.designSystem = "colors" in ds ? { themes: { default: ds } } : { themes: ds };
|
|
3467
|
+
}
|
|
3468
|
+
/**
|
|
3064
3469
|
* Resolve a raw (already-parsed) config into a validated `SiteConfig`:
|
|
3065
3470
|
* 1. strip YAML nulls,
|
|
3066
|
-
* 2.
|
|
3067
|
-
* 3.
|
|
3471
|
+
* 2. normalise the `designSystem` shorthands to `{ themes: … }`,
|
|
3472
|
+
* 3. apply the active A/B variant's `overrides` (by `siteEnv`),
|
|
3068
3473
|
* 4. validate (shape + cross-field).
|
|
3069
3474
|
* Pure — no YAML/env access. A loader supplies `siteEnv`.
|
|
3475
|
+
*
|
|
3476
|
+
* Steps 2 and 3 are in this order deliberately. Normalising first means an
|
|
3477
|
+
* `abTesting` override always addresses the canonical shape
|
|
3478
|
+
* (`designSystem.themes.<name>`), whatever shorthand the base config used. With
|
|
3479
|
+
* the merge first, an override's key path depended on how the base happened to be
|
|
3480
|
+
* written — so the same patch hit a different place in two configs that were
|
|
3481
|
+
* otherwise equivalent.
|
|
3070
3482
|
*/
|
|
3071
3483
|
function resolveSiteConfig(input, siteEnv = "production") {
|
|
3072
3484
|
const raw = stripNulls(input);
|
|
3485
|
+
normaliseDesignSystem(raw);
|
|
3073
3486
|
const envConfig = raw.environments?.[siteEnv];
|
|
3074
3487
|
if (envConfig?.variant) {
|
|
3075
3488
|
const overrides = raw.abTesting?.variants?.[envConfig.variant]?.overrides;
|
|
3076
|
-
if (overrides)
|
|
3489
|
+
if (overrides) {
|
|
3490
|
+
normaliseDesignSystem(overrides);
|
|
3491
|
+
deepMerge(raw, overrides);
|
|
3492
|
+
}
|
|
3077
3493
|
}
|
|
3078
|
-
const ds = raw.designSystem;
|
|
3079
|
-
if (ds != null && "colors" in ds) raw.designSystem = { default: ds };
|
|
3080
3494
|
const result = validateSite(raw);
|
|
3081
3495
|
if (!result.ok) {
|
|
3082
3496
|
const msg = result.errors.map((e) => ` ${e.path.join(".")}: ${e.message}`).join("\n");
|
|
@@ -3084,6 +3498,52 @@ function resolveSiteConfig(input, siteEnv = "production") {
|
|
|
3084
3498
|
}
|
|
3085
3499
|
return result.data;
|
|
3086
3500
|
}
|
|
3501
|
+
/**
|
|
3502
|
+
* Is this raw config a `SiteConfig` rather than a bare `design-system.json`?
|
|
3503
|
+
*
|
|
3504
|
+
* The discriminator is **total**, not a heuristic, and both halves are load-
|
|
3505
|
+
* bearing: `SiteConfigSchema` cannot validate without a `designSystem` (a config
|
|
3506
|
+
* lacking one fails `validateSite` with `themes must include a "default" entry`),
|
|
3507
|
+
* and `DesignSystemSchema` is strict, so a `designSystem` key in a design system
|
|
3508
|
+
* is an `unrecognized_keys` error. No document can be read as both, and no
|
|
3509
|
+
* document that is neither reaches here without failing validation anyway.
|
|
3510
|
+
*
|
|
3511
|
+
* Deliberately shape-based rather than filename-based: consumers name this file
|
|
3512
|
+
* whatever suits them (`company.json`, `site.json`, `vitops.config.json`), and a
|
|
3513
|
+
* rule keyed to a name would silently stop working the moment someone renamed it.
|
|
3514
|
+
*/
|
|
3515
|
+
function isSiteConfig(raw) {
|
|
3516
|
+
return typeof raw === "object" && raw != null && !Array.isArray(raw) && "designSystem" in raw;
|
|
3517
|
+
}
|
|
3518
|
+
/**
|
|
3519
|
+
* Resolve a raw config of either kind into the design system to build from.
|
|
3520
|
+
*
|
|
3521
|
+
* Every entry point that used to take a `design-system.json` goes through this,
|
|
3522
|
+
* so a consumer who keeps their tokens inside the larger site config points the
|
|
3523
|
+
* same option at that file instead of maintaining a second one. The site config
|
|
3524
|
+
* is handed back too, because the parts of generation that depend on site-level
|
|
3525
|
+
* facts (`designSystem.defaultColorScheme`, the legal documents, the icon
|
|
3526
|
+
* sprite) would otherwise need the same path declared a second time.
|
|
3527
|
+
*
|
|
3528
|
+
* The returned `designSystem` is **not** validated here — the caller does that,
|
|
3529
|
+
* so it can say which file and which theme the errors belong to. Validation of
|
|
3530
|
+
* the site config itself has already happened (`resolveSiteConfig` throws).
|
|
3531
|
+
*/
|
|
3532
|
+
function resolveInput(raw, opts = {}) {
|
|
3533
|
+
if (!isSiteConfig(raw)) {
|
|
3534
|
+
if (opts.theme != null) throw new Error(`theme "${opts.theme}" was requested, but this is a design-system.json — it holds one design system and no \`themes\` map. Point at a site config to select a theme.`);
|
|
3535
|
+
return { designSystem: raw };
|
|
3536
|
+
}
|
|
3537
|
+
const site = resolveSiteConfig(raw, opts.siteEnv);
|
|
3538
|
+
const themes = site.designSystem?.themes ?? {};
|
|
3539
|
+
const theme = opts.theme ?? site.designSystem?.defaultTheme ?? "default";
|
|
3540
|
+
if (!(theme in themes)) throw new Error(`designSystem.themes has no "${theme}" entry (found: ${Object.keys(themes).join(", ") || "none"})`);
|
|
3541
|
+
return {
|
|
3542
|
+
designSystem: resolveTheme(themes, theme),
|
|
3543
|
+
site,
|
|
3544
|
+
theme
|
|
3545
|
+
};
|
|
3546
|
+
}
|
|
3087
3547
|
/** Strip nulls (YAML `~`/`null`) → undefined so optional fields validate. */
|
|
3088
3548
|
function stripNulls(obj) {
|
|
3089
3549
|
if (obj === null) return void 0;
|
|
@@ -3137,6 +3597,30 @@ const KNOWN_PROCESSORS = {
|
|
|
3137
3597
|
privacyUrl: "https://plausible.io/privacy",
|
|
3138
3598
|
cookies: []
|
|
3139
3599
|
},
|
|
3600
|
+
clarity: {
|
|
3601
|
+
name: "Microsoft Clarity",
|
|
3602
|
+
purpose: "session replay and heatmaps",
|
|
3603
|
+
country: "the United States",
|
|
3604
|
+
privacyUrl: "https://privacy.microsoft.com/privacystatement",
|
|
3605
|
+
cookies: [
|
|
3606
|
+
"_clck",
|
|
3607
|
+
"_clsk",
|
|
3608
|
+
"MUID"
|
|
3609
|
+
],
|
|
3610
|
+
optOut: "https://privacy.microsoft.com/privacystatement"
|
|
3611
|
+
},
|
|
3612
|
+
matomo: {
|
|
3613
|
+
name: "Matomo",
|
|
3614
|
+
purpose: "website analytics",
|
|
3615
|
+
privacyUrl: "https://matomo.org/privacy-policy/",
|
|
3616
|
+
cookies: ["_pk_id", "_pk_ses"]
|
|
3617
|
+
},
|
|
3618
|
+
matomoCookieless: {
|
|
3619
|
+
name: "Matomo",
|
|
3620
|
+
purpose: "website analytics",
|
|
3621
|
+
privacyUrl: "https://matomo.org/privacy-policy/",
|
|
3622
|
+
cookies: []
|
|
3623
|
+
},
|
|
3140
3624
|
turnstile: {
|
|
3141
3625
|
name: "Cloudflare Turnstile",
|
|
3142
3626
|
purpose: "bot protection on forms",
|
|
@@ -3185,6 +3669,8 @@ function detectProcessorKeys(site) {
|
|
|
3185
3669
|
if (analytics?.googleAnalyticsId) keys.push("googleAnalytics");
|
|
3186
3670
|
if (analytics?.googleTagManagerId) keys.push("googleTagManager");
|
|
3187
3671
|
if (analytics?.plausibleDomain) keys.push("plausible");
|
|
3672
|
+
if (analytics?.clarityId) keys.push("clarity");
|
|
3673
|
+
if (analytics?.matomo) keys.push(analytics.matomo.cookies ? "matomo" : "matomoCookieless");
|
|
3188
3674
|
if (site.security?.turnstile?.siteKey) keys.push("turnstile");
|
|
3189
3675
|
const platform = site.deployment?.platform;
|
|
3190
3676
|
const hosting = platform && HOSTING_PATTERNS.find(([re]) => re.test(platform))?.[1];
|
|
@@ -3199,10 +3685,27 @@ function detectProcessorKeys(site) {
|
|
|
3199
3685
|
* an explicit declaration is a statement of fact we have no grounds to drop.
|
|
3200
3686
|
*/
|
|
3201
3687
|
function resolveProcessors(site) {
|
|
3202
|
-
const detected = detectProcessorKeys(site).map((k) => KNOWN_PROCESSORS[k]);
|
|
3688
|
+
const detected = detectProcessorKeys(site).map((k) => matomoCountry(KNOWN_PROCESSORS[k], site));
|
|
3203
3689
|
const declared = site.legal?.privacyPolicy?.processors ?? [];
|
|
3204
3690
|
return [...detected, ...declared];
|
|
3205
3691
|
}
|
|
3692
|
+
/**
|
|
3693
|
+
* Matomo is the one provider whose location the config actually knows.
|
|
3694
|
+
*
|
|
3695
|
+
* Matomo Cloud is hosted in the EU; a self-hosted instance is wherever the
|
|
3696
|
+
* consumer put it, which is very often their own infrastructure in their own
|
|
3697
|
+
* country. So the cross-border transfer sentence is asserted only for the case we
|
|
3698
|
+
* can see. Naming a transfer that isn't happening is the same class of error as
|
|
3699
|
+
* omitting one that is — the config records facts, and "we don't know" is a fact.
|
|
3700
|
+
*/
|
|
3701
|
+
function matomoCountry(processor, site) {
|
|
3702
|
+
if (processor.name !== "Matomo") return processor;
|
|
3703
|
+
const url = site.analytics?.matomo?.url ?? "";
|
|
3704
|
+
return /\.matomo\.cloud/i.test(url) ? {
|
|
3705
|
+
...processor,
|
|
3706
|
+
country: "the European Union"
|
|
3707
|
+
} : processor;
|
|
3708
|
+
}
|
|
3206
3709
|
|
|
3207
3710
|
//#endregion
|
|
3208
3711
|
//#region src/legal/derive.ts
|
|
@@ -3321,7 +3824,7 @@ function derivePolicyVars(site) {
|
|
|
3321
3824
|
const processors = resolveProcessors(site);
|
|
3322
3825
|
const fields = formFields(site);
|
|
3323
3826
|
const hasForms = fields.length > 0;
|
|
3324
|
-
const hasAnalytics =
|
|
3827
|
+
const hasAnalytics = Object.values(site.analytics ?? {}).some((v) => typeof v === "string" ? v.length > 0 : !!v);
|
|
3325
3828
|
processors.filter((p) => p.cookies?.length);
|
|
3326
3829
|
const consent = legal?.cookieConsent;
|
|
3327
3830
|
const contact = resolvePrivacyContact(site);
|
|
@@ -3333,15 +3836,18 @@ function derivePolicyVars(site) {
|
|
|
3333
3836
|
fields.some((f) => /newsletter|subscribe|mailing/i.test(f.name)) ? "register to receive our newsletter" : void 0
|
|
3334
3837
|
]);
|
|
3335
3838
|
const piiCollected = uniq([...hasForms ? fields.map((f) => fieldPhrase(f.name, f.type)) : ["your full name", "contact information, such as your address, telephone number, or e-mail address"], "any other personal information that you choose to submit to us"]);
|
|
3839
|
+
const hasSessionReplay = !!site.analytics?.clarityId;
|
|
3336
3840
|
const techInfo = uniq([
|
|
3337
3841
|
"your domain name",
|
|
3338
3842
|
"your numerical IP address",
|
|
3339
3843
|
hasAnalytics ? "the pages you view and the files you request" : void 0,
|
|
3340
3844
|
hasAnalytics ? "the type of browser and operating system you use" : void 0,
|
|
3341
|
-
hasAnalytics ? "the address of the site that referred you" : void 0
|
|
3845
|
+
hasAnalytics ? "the address of the site that referred you" : void 0,
|
|
3846
|
+
hasSessionReplay ? "a recording of how you interacted with a page, including mouse movement, scrolling and clicks" : void 0
|
|
3342
3847
|
]);
|
|
3343
3848
|
const techInfoPurposes = uniq([
|
|
3344
3849
|
hasAnalytics ? "measure the performance of our Site" : void 0,
|
|
3850
|
+
hasSessionReplay ? "see where visitors encounter difficulty using our Site" : void 0,
|
|
3345
3851
|
site.security?.turnstile?.siteKey ? "protect our Site and its forms from automated abuse" : void 0,
|
|
3346
3852
|
"better understand how visitors use our Site",
|
|
3347
3853
|
"improve our Site to better meet your needs"
|
|
@@ -3351,11 +3857,13 @@ function derivePolicyVars(site) {
|
|
|
3351
3857
|
"your IP address",
|
|
3352
3858
|
"the type of web browser and operating system used",
|
|
3353
3859
|
"the pages of the Site visited",
|
|
3354
|
-
ab?.enabled ? "which version of a page you were shown" : void 0
|
|
3860
|
+
ab?.enabled ? "which version of a page you were shown" : void 0,
|
|
3861
|
+
hasSessionReplay ? "how you moved through and interacted with a page" : void 0
|
|
3355
3862
|
]);
|
|
3356
3863
|
const cookiePurposes = uniq([
|
|
3357
3864
|
"keep the Site functioning as you move between pages",
|
|
3358
3865
|
hasAnalytics ? "understand how our Site is used, in aggregate" : void 0,
|
|
3866
|
+
hasSessionReplay ? "recognise a returning visit as part of the same session" : void 0,
|
|
3359
3867
|
ab?.enabled ? "keep you on a consistent version of the Site between visits" : void 0,
|
|
3360
3868
|
site.security?.turnstile?.siteKey ? "distinguish visitors from automated traffic" : void 0
|
|
3361
3869
|
]);
|
|
@@ -3980,6 +4488,118 @@ function generateLegal(site, opts = {}) {
|
|
|
3980
4488
|
return out;
|
|
3981
4489
|
}
|
|
3982
4490
|
|
|
4491
|
+
//#endregion
|
|
4492
|
+
//#region src/icons-sprite.ts
|
|
4493
|
+
/**
|
|
4494
|
+
* Build an SVG sprite from an icon `include` map.
|
|
4495
|
+
*
|
|
4496
|
+
* This is the delivery path for consumers that cannot run an icon integration:
|
|
4497
|
+
* Bricks/WordPress, EmDash's renderers, anything rendering HTML outside an Astro
|
|
4498
|
+
* build. Markup is `<svg><use href="…/icons.svg#ph--caret-down"/></svg>` — no
|
|
4499
|
+
* runtime JS and no network call, which is what keeps it inside the tier rules
|
|
4500
|
+
* (a runtime-fetching element would be behaviour JS with no fallback to enhance).
|
|
4501
|
+
*
|
|
4502
|
+
* The icon DATA comes from the `@iconify-json/*` collections, which stay optional
|
|
4503
|
+
* peers loaded dynamically: `@iconify-json/ph` alone is ~4.5 MB and the semantic
|
|
4504
|
+
* map spans five sets, so depending on them directly would be indefensible for
|
|
4505
|
+
* every consumer who never asks for a sprite.
|
|
4506
|
+
*/
|
|
4507
|
+
/**
|
|
4508
|
+
* `ph:caret-down` → `ph--caret-down`.
|
|
4509
|
+
*
|
|
4510
|
+
* The one place this grammar lives. `Icon.astro` mirrors it and `icon.test.ts`
|
|
4511
|
+
* guards the pair, because a drift here resolves `<use>` to nothing and renders
|
|
4512
|
+
* as an empty box rather than an error.
|
|
4513
|
+
*/
|
|
4514
|
+
function spriteId(qualified) {
|
|
4515
|
+
return qualified.replace(":", "--");
|
|
4516
|
+
}
|
|
4517
|
+
/**
|
|
4518
|
+
* Default loader: the collection JSON shipped by `@iconify-json/<prefix>`.
|
|
4519
|
+
*
|
|
4520
|
+
* Resolved through a `createRequire` rooted at the consumer's directory, then
|
|
4521
|
+
* imported by absolute path — a bare specifier would resolve against this
|
|
4522
|
+
* package instead, where the collections deliberately aren't installed.
|
|
4523
|
+
*/
|
|
4524
|
+
async function loadCollection(prefix, from) {
|
|
4525
|
+
try {
|
|
4526
|
+
const mod = await import(pathToFileURL(createRequire(pathToFileURL(join(from, "noop.js"))).resolve(`@iconify-json/${prefix}/icons.json`)).href, { with: { type: "json" } });
|
|
4527
|
+
return mod.default ?? mod;
|
|
4528
|
+
} catch {
|
|
4529
|
+
return null;
|
|
4530
|
+
}
|
|
4531
|
+
}
|
|
4532
|
+
async function buildIconSprite(o) {
|
|
4533
|
+
const from = o.resolveFrom ?? process.cwd();
|
|
4534
|
+
const load = o.loadSet ?? ((prefix) => loadCollection(prefix, from));
|
|
4535
|
+
const symbols = [];
|
|
4536
|
+
const ids = [];
|
|
4537
|
+
const missing = [];
|
|
4538
|
+
/** qualified name → its rendered body+viewBox, so an alias can reuse it. */
|
|
4539
|
+
const rendered = /* @__PURE__ */ new Map();
|
|
4540
|
+
for (const [prefix, names] of Object.entries(o.include)) {
|
|
4541
|
+
if (!names?.length) continue;
|
|
4542
|
+
const set = await load(prefix);
|
|
4543
|
+
if (!set) throw new Error(`[vitops icons] Cannot build the sprite: the "${prefix}" icon set is not installed. Run \`pnpm add -D @iconify-json/${prefix}\`, or drop it from your icons config.`);
|
|
4544
|
+
for (const name of names) {
|
|
4545
|
+
const data = getIconData(set, name);
|
|
4546
|
+
if (!data) {
|
|
4547
|
+
missing.push(`${prefix}:${name}`);
|
|
4548
|
+
continue;
|
|
4549
|
+
}
|
|
4550
|
+
const built = iconToSVG(data, { height: "none" });
|
|
4551
|
+
const body = replaceIDs(built.body);
|
|
4552
|
+
const id = spriteId(`${prefix}:${name}`);
|
|
4553
|
+
rendered.set(`${prefix}:${name}`, {
|
|
4554
|
+
viewBox: built.attributes.viewBox,
|
|
4555
|
+
body
|
|
4556
|
+
});
|
|
4557
|
+
ids.push(id);
|
|
4558
|
+
symbols.push(`<symbol id="${id}" viewBox="${built.attributes.viewBox}">${body}</symbol>`);
|
|
4559
|
+
}
|
|
4560
|
+
}
|
|
4561
|
+
for (const [alias, target] of Object.entries(o.aliases ?? {})) {
|
|
4562
|
+
const hit = rendered.get(target);
|
|
4563
|
+
if (!hit) {
|
|
4564
|
+
missing.push(`${target} (aliased as ${alias})`);
|
|
4565
|
+
continue;
|
|
4566
|
+
}
|
|
4567
|
+
ids.push(alias);
|
|
4568
|
+
symbols.push(`<symbol id="${alias}" viewBox="${hit.viewBox}">${hit.body}</symbol>`);
|
|
4569
|
+
}
|
|
4570
|
+
return {
|
|
4571
|
+
svg: `<svg xmlns="http://www.w3.org/2000/svg" style="display:none" aria-hidden="true">` + symbols.join("") + `</svg>\n`,
|
|
4572
|
+
ids,
|
|
4573
|
+
missing
|
|
4574
|
+
};
|
|
4575
|
+
}
|
|
4576
|
+
/**
|
|
4577
|
+
* One icon's renderable parts, for inlining into markup.
|
|
4578
|
+
*
|
|
4579
|
+
* Exists because `<Icon />` cannot go through astro-icon: its `./components`
|
|
4580
|
+
* entry is a TypeScript file that Vite must transform, so it can only be
|
|
4581
|
+
* reached by a statically-analysable import — and a static import is exactly
|
|
4582
|
+
* what made the optional peer mandatory for anyone rendering Popover, Details
|
|
4583
|
+
* or Drawer. Reading the collection directly sidesteps the peer entirely and
|
|
4584
|
+
* shares this file's loader with the sprite, so both paths draw the same glyph.
|
|
4585
|
+
*/
|
|
4586
|
+
async function loadIconSvg(qualified, opts) {
|
|
4587
|
+
const i = qualified.indexOf(":");
|
|
4588
|
+
if (i <= 0) return null;
|
|
4589
|
+
const prefix = qualified.slice(0, i);
|
|
4590
|
+
const name = qualified.slice(i + 1);
|
|
4591
|
+
const from = opts?.resolveFrom ?? process.cwd();
|
|
4592
|
+
const set = await (opts?.loadSet ?? ((p) => loadCollection(p, from)))(prefix);
|
|
4593
|
+
if (!set) return null;
|
|
4594
|
+
const data = getIconData(set, name);
|
|
4595
|
+
if (!data) return null;
|
|
4596
|
+
const built = iconToSVG(data, { height: "none" });
|
|
4597
|
+
return {
|
|
4598
|
+
viewBox: built.attributes.viewBox,
|
|
4599
|
+
body: replaceIDs(built.body)
|
|
4600
|
+
};
|
|
4601
|
+
}
|
|
4602
|
+
|
|
3983
4603
|
//#endregion
|
|
3984
4604
|
//#region src/generate.ts
|
|
3985
4605
|
/**
|
|
@@ -4026,6 +4646,47 @@ const targetOf = (key) => {
|
|
|
4026
4646
|
return i === -1 ? key : key.slice(0, i);
|
|
4027
4647
|
};
|
|
4028
4648
|
/**
|
|
4649
|
+
* The framework's container breakpoints. `sm-`/`md-`/`lg-`/`xl-` prefixes in the
|
|
4650
|
+
* css/bricks formats; `@sm:`/`@md:`/… in tailwind, where they are registered as
|
|
4651
|
+
* `--container-*` in `@theme` and Tailwind expands the variants itself.
|
|
4652
|
+
*/
|
|
4653
|
+
const BREAKPOINTS = [
|
|
4654
|
+
["sm", "30rem"],
|
|
4655
|
+
["md", "48rem"],
|
|
4656
|
+
["lg", "64rem"],
|
|
4657
|
+
["xl", "80rem"]
|
|
4658
|
+
];
|
|
4659
|
+
/**
|
|
4660
|
+
* Gap utilities over the space scale, emitted from one list into all three
|
|
4661
|
+
* formats — same arrangement as `roleColorUtilities`, for the same reason.
|
|
4662
|
+
*
|
|
4663
|
+
* These did not exist. `vitops docs css` advertised a `g` class that was never
|
|
4664
|
+
* emitted, so the honest answer for a css/bricks consumer was an inline
|
|
4665
|
+
* `style="gap: …"`, which is what this repo's own `index.html` does throughout.
|
|
4666
|
+
* Tailwind does not fill the hole either: the fluid space steps are deliberately
|
|
4667
|
+
* kept OUT of Tailwind's `--spacing-*` namespace (named keys there shadow the
|
|
4668
|
+
* size scales — `max-w-7xl` would resolve to `var(--spacing-7xl)`), so `gap-md`
|
|
4669
|
+
* is not something Tailwind can derive on its own. Measured against
|
|
4670
|
+
* tailwindcss@4.3.3: an explicit `@utility gap-md` is honoured, coexists with
|
|
4671
|
+
* the built-in numeric `gap-4`, and picks up variants (`@md:gap-md`).
|
|
4672
|
+
*
|
|
4673
|
+
* The whole matrix is emitted rather than a useful-looking subset: an undefined
|
|
4674
|
+
* step produces no rule and no error in either format, so a missing
|
|
4675
|
+
* `md-gap-x-2xl` would be indistinguishable from a working one.
|
|
4676
|
+
*/
|
|
4677
|
+
function gapUtilities(steps) {
|
|
4678
|
+
const FAMILIES = [
|
|
4679
|
+
["gap", "gap"],
|
|
4680
|
+
["gap-x", "column-gap"],
|
|
4681
|
+
["gap-y", "row-gap"]
|
|
4682
|
+
];
|
|
4683
|
+
return steps.flatMap(({ name }) => FAMILIES.map(([prefix, prop]) => ({
|
|
4684
|
+
cls: `${prefix}-${name}`,
|
|
4685
|
+
prop,
|
|
4686
|
+
value: `var(--space-${name})`
|
|
4687
|
+
})));
|
|
4688
|
+
}
|
|
4689
|
+
/**
|
|
4029
4690
|
* Every role colour utility, each class name emitted EXACTLY once.
|
|
4030
4691
|
*
|
|
4031
4692
|
* There is now a single axis. The target lives *inside* the token name, so
|
|
@@ -4104,6 +4765,23 @@ const TYPO_KEYMAP = {
|
|
|
4104
4765
|
"text-wrap": ["text-wrap", "tw"],
|
|
4105
4766
|
color: ["color", "color"]
|
|
4106
4767
|
};
|
|
4768
|
+
/**
|
|
4769
|
+
* Wrong spellings worth naming explicitly, because each is a plausible reading of
|
|
4770
|
+
* the key it isn't. `transform`/`decoration` were documented in the schema for a
|
|
4771
|
+
* while and shipped title-case navigation to production; `letter-spacing` and
|
|
4772
|
+
* `font-*` are the natural guesses for keys we chose to shorten.
|
|
4773
|
+
*/
|
|
4774
|
+
const ALSO_MEANT = {
|
|
4775
|
+
transform: "text-transform",
|
|
4776
|
+
decoration: "text-decoration",
|
|
4777
|
+
"letter-spacing": "tracking",
|
|
4778
|
+
wrap: "text-wrap",
|
|
4779
|
+
"font-family": "family",
|
|
4780
|
+
"font-size": "size",
|
|
4781
|
+
"font-weight": "weight",
|
|
4782
|
+
"font-style": "style",
|
|
4783
|
+
lineHeight: "line-height"
|
|
4784
|
+
};
|
|
4107
4785
|
const TYPO_IDENTITY = {
|
|
4108
4786
|
"font-style": "normal",
|
|
4109
4787
|
"letter-spacing": "normal",
|
|
@@ -4126,10 +4804,11 @@ const STATE_DEFAULT = {
|
|
|
4126
4804
|
saturate: "1",
|
|
4127
4805
|
sepia: "0",
|
|
4128
4806
|
"hue-rotate": "0deg",
|
|
4129
|
-
clip: "none"
|
|
4807
|
+
clip: "none",
|
|
4808
|
+
height: "auto"
|
|
4130
4809
|
};
|
|
4131
4810
|
const id = (key) => createHash("sha256").update(key).digest("hex").slice(0, 6);
|
|
4132
|
-
function build(ds, format, assetsDir) {
|
|
4811
|
+
function build(ds, format, assetsDir, buildOpts = {}) {
|
|
4133
4812
|
const BRICKS = format === "bricks";
|
|
4134
4813
|
const expandedPalette = expandPalette(ds.colors.palette);
|
|
4135
4814
|
const roleMap = ds.colors.roles;
|
|
@@ -4143,6 +4822,10 @@ function build(ds, format, assetsDir) {
|
|
|
4143
4822
|
const surfaceRole = scaleRoles.find((r) => r.role === "surface");
|
|
4144
4823
|
const focusRole = scaleRoles.find((r) => r.role === "ui-primary") ?? scaleRoles[0];
|
|
4145
4824
|
for (const w of ladderWarnings(expandedPalette)) console.warn(`[vitops] ${w}`);
|
|
4825
|
+
{
|
|
4826
|
+
const errors = monotonicityErrors(expandedPalette);
|
|
4827
|
+
if (errors.length) throw new Error(errors.join("\n"));
|
|
4828
|
+
}
|
|
4146
4829
|
{
|
|
4147
4830
|
const numericOf = (hueName) => expandedPalette[hueName]?.numeric ?? {};
|
|
4148
4831
|
const resolve = (val) => {
|
|
@@ -4206,6 +4889,9 @@ function build(ds, format, assetsDir) {
|
|
|
4206
4889
|
if (fb) {
|
|
4207
4890
|
css += `/* Functional role tokens (dark) */\n`;
|
|
4208
4891
|
css += `${DARK_SEL} {\n color-scheme: dark;\n${fb}}\n\n`;
|
|
4892
|
+
if (buildOpts.systemColorScheme) css += `/* Functional role tokens (dark) — OS preference, no explicit choice */
|
|
4893
|
+
@media (prefers-color-scheme: dark) {
|
|
4894
|
+
${SYSTEM_DARK_SEL} {\n color-scheme: dark;\n${fb.replace(/^ {2}/gm, " ")} }\n}\n\n`;
|
|
4209
4895
|
}
|
|
4210
4896
|
}
|
|
4211
4897
|
css += `/* Colour utilities — ${UTILITIES.join(", ")} */\n`;
|
|
@@ -4231,6 +4917,14 @@ function build(ds, format, assetsDir) {
|
|
|
4231
4917
|
const spaceSteps = ds.spaceScale ? buildScale(ds.spaceScale) : [];
|
|
4232
4918
|
const textMax = Object.fromEntries(typeSteps.map((s) => [`text-${s.name}`, s.max]));
|
|
4233
4919
|
const typographyFamilies = typography?.families ?? {};
|
|
4920
|
+
{
|
|
4921
|
+
const known = new Set(Object.keys(TYPO_KEYMAP));
|
|
4922
|
+
for (const [role, spec] of Object.entries(typography?.roles ?? {})) for (const key of Object.keys(spec)) {
|
|
4923
|
+
if (known.has(key)) continue;
|
|
4924
|
+
const near = ALSO_MEANT[key];
|
|
4925
|
+
console.warn(`[vitops] typography.roles.${role}: unknown key "${key}" — ignored` + (near ? `. Did you mean "${near}"?` : `. Recognised: ${[...known].join(", ")}.`));
|
|
4926
|
+
}
|
|
4927
|
+
}
|
|
4234
4928
|
const withScaleFallback = (v) => v.replace(/var\(\s*(--text-[\w-]+)\s*\)/g, (m, name) => textMax[name.slice(2)] ? `var(${name}, ${textMax[name.slice(2)]})` : m);
|
|
4235
4929
|
const roleDecls = (role, spec) => {
|
|
4236
4930
|
const out = {};
|
|
@@ -4248,10 +4942,26 @@ function build(ds, format, assetsDir) {
|
|
|
4248
4942
|
for (const [name, stack] of Object.entries(ds.fonts ?? {})) root[`--font-${name}`] = stack;
|
|
4249
4943
|
for (const s of typeSteps) root[`--text-${s.name}`] = s.value;
|
|
4250
4944
|
for (const s of spaceSteps) root[`--space-${s.name}`] = s.value;
|
|
4251
|
-
const css = `/* GENERATED font families + fluid type/space scales — do not edit by hand.
|
|
4945
|
+
const css = `/* GENERATED font families + fluid type/space scales — do not edit by hand.
|
|
4946
|
+
--font-* are STACKS ONLY; no @font-face is emitted here. Load webfonts with
|
|
4947
|
+
Astro's fonts: config and point the token at the family's cssVariable. */
|
|
4948
|
+
:root {\n${decls(root)}\n}\n`;
|
|
4252
4949
|
twTypeTokensCss = css;
|
|
4253
4950
|
generated["type-tokens.css"] = css;
|
|
4254
4951
|
}
|
|
4952
|
+
{
|
|
4953
|
+
const gaps = gapUtilities(spaceSteps);
|
|
4954
|
+
let sp = `/* GENERATED gap utilities — do not edit by hand. */\n`;
|
|
4955
|
+
if (gaps.length) {
|
|
4956
|
+
for (const { cls, prop, value } of gaps) sp += `.${cls} { ${prop}: ${value}; }\n`;
|
|
4957
|
+
for (const [bp, width] of BREAKPOINTS) {
|
|
4958
|
+
sp += `\n@container (min-width: ${width}) {\n`;
|
|
4959
|
+
for (const { cls, prop, value } of gaps) sp += ` .${bp}-${cls} { ${prop}: ${value}; }\n`;
|
|
4960
|
+
sp += `}\n`;
|
|
4961
|
+
}
|
|
4962
|
+
}
|
|
4963
|
+
generated["spacing.css"] = sp;
|
|
4964
|
+
}
|
|
4255
4965
|
let twPatternTokensCss = "";
|
|
4256
4966
|
{
|
|
4257
4967
|
const tDefaults = patterns?.defaults ?? {};
|
|
@@ -4286,7 +4996,6 @@ function build(ds, format, assetsDir) {
|
|
|
4286
4996
|
generated["typography.css"] = typo;
|
|
4287
4997
|
}
|
|
4288
4998
|
const stateRules = (sel, role, states, colorProp = "background-color") => {
|
|
4289
|
-
const sels = Array.isArray(sel) ? sel : [sel];
|
|
4290
4999
|
let out = "";
|
|
4291
5000
|
for (const [state, spec] of Object.entries(states)) {
|
|
4292
5001
|
const body = [];
|
|
@@ -4304,8 +5013,7 @@ function build(ds, format, assetsDir) {
|
|
|
4304
5013
|
}
|
|
4305
5014
|
if (spec.css && typeof spec.css === "object") for (const [k, v] of Object.entries(spec.css)) body.push(`${k}: ${v};`);
|
|
4306
5015
|
if (!body.length) continue;
|
|
4307
|
-
const
|
|
4308
|
-
const rule = `${sels.map((s) => `${s}${pseudo}`).join(",\n")} {\n ${body.join("\n ")}\n}\n`;
|
|
5016
|
+
const rule = `${sel(state === "hover" ? ":hover" : state === "active" ? ":active" : state === "focus-visible" ? ":focus-visible" : `:${state}`).join(",\n")} {\n ${body.join("\n ")}\n}\n`;
|
|
4309
5017
|
out += state === "hover" ? `@media (hover: hover) {\n${rule}}\n` : rule;
|
|
4310
5018
|
}
|
|
4311
5019
|
return out;
|
|
@@ -4316,8 +5024,9 @@ function build(ds, format, assetsDir) {
|
|
|
4316
5024
|
if (!p.base) continue;
|
|
4317
5025
|
const states = p.states ?? {};
|
|
4318
5026
|
const cls = p.class ?? (p.element ? void 0 : pname);
|
|
4319
|
-
const elementSel = (extra = "") => `:where(${[p.element, cls && `.${cls}`].filter(Boolean).join(", ")})${extra}`;
|
|
5027
|
+
const elementSel = (extra = "", pseudo = "") => `:where(${[p.element, cls && `.${cls}`].filter(Boolean).map((s) => `${s}${pseudo}`).join(", ")})${extra}`;
|
|
4320
5028
|
const defaultSel = p.element ? elementSel() : `.${cls}`;
|
|
5029
|
+
const defaultSelFor = (pseudo) => p.element ? [elementSel("", pseudo)] : [`.${cls}${pseudo}`];
|
|
4321
5030
|
const defaultRole = p.default_role ?? null;
|
|
4322
5031
|
const base = { ...p.base };
|
|
4323
5032
|
const fills = p.fill ?? (pname === "button" || pname === "badge" || base["background-color"] != null || base["background"] != null);
|
|
@@ -4336,12 +5045,13 @@ function build(ds, format, assetsDir) {
|
|
|
4336
5045
|
wrappedBase[prop] = sfx ? `var(--${sfx}-${pname}, ${val})` : String(val);
|
|
4337
5046
|
}
|
|
4338
5047
|
pat += decls(wrappedBase) + "\n}\n";
|
|
4339
|
-
pat += stateRules(
|
|
5048
|
+
pat += stateRules(defaultSelFor, defaultRole, states, colorProp);
|
|
4340
5049
|
for (const role of p.roles ?? []) {
|
|
4341
|
-
const
|
|
5050
|
+
const variantSelsFor = (pseudo) => p.element ? [elementSel(`.${role}`, pseudo), ...cls ? [`.${cls}-${role}${pseudo}`] : []] : [`.${cls}-${role}${pseudo}`];
|
|
5051
|
+
const variantSels = variantSelsFor("");
|
|
4342
5052
|
const variantColorDecl = fills ? `background-color: var(--color-bg-${role}-solid); color: var(--color-text-on-${role})` : `color: var(--color-text-${role})`;
|
|
4343
5053
|
pat += `${variantSels.join(",\n")} { ${variantColorDecl}; }\n`;
|
|
4344
|
-
pat += stateRules(
|
|
5054
|
+
pat += stateRules(variantSelsFor, role, states, colorProp);
|
|
4345
5055
|
}
|
|
4346
5056
|
pat += "\n";
|
|
4347
5057
|
}
|
|
@@ -4361,25 +5071,25 @@ function build(ds, format, assetsDir) {
|
|
|
4361
5071
|
for (const parts of anim.journeys?.compose ?? []) {
|
|
4362
5072
|
const d = {
|
|
4363
5073
|
"--_anim": parts.map((p) => `${p}-journey`).join(", "),
|
|
4364
|
-
"animation-range": "entry exit"
|
|
5074
|
+
"animation-range": "entry calc(50% + 10vh) exit 100%"
|
|
4365
5075
|
};
|
|
4366
5076
|
for (const p of parts) for (const [k, v] of Object.entries(base[p] ?? {})) d[`--${k}`] = asStr(v);
|
|
4367
5077
|
out += block(`${parts.join("-")}-journey`, d);
|
|
4368
5078
|
}
|
|
4369
5079
|
const STATES = [
|
|
4370
|
-
["hover", (s) => `.${s}:hover`],
|
|
4371
|
-
["focus", (s) => `.${s}:focus-visible`],
|
|
5080
|
+
["hover", (s) => `.${s}:hover, :hover > .${s}`],
|
|
5081
|
+
["focus", (s) => `.${s}:focus-visible, :focus-within > .${s}`],
|
|
4372
5082
|
["active", (s) => `.${s}.is-active, .${s}[data-active]`]
|
|
4373
5083
|
];
|
|
4374
5084
|
out += `\n/* State-flip variants — compose with .transition (hover-/focus-/active-<fx>). */\n`;
|
|
4375
5085
|
for (const [name, e] of Object.entries(anim.effects ?? {})) {
|
|
4376
|
-
if (e.kf !== "composite" && e.kf !== "paint") continue;
|
|
4377
5086
|
const props = {};
|
|
4378
5087
|
for (const [k, v] of Object.entries(e.vars ?? {})) {
|
|
4379
5088
|
const m = /^(.*)-(from|to)$/.exec(k);
|
|
4380
5089
|
if (m) (props[m[1]] ??= {})[m[2]] = asStr(v);
|
|
4381
5090
|
}
|
|
4382
5091
|
const rest = {};
|
|
5092
|
+
for (const [k, v] of Object.entries(e.css ?? {})) rest[k] = asStr(v);
|
|
4383
5093
|
for (const [p, ft] of Object.entries(props)) {
|
|
4384
5094
|
if (ft.from != null && ft.from !== STATE_DEFAULT[p]) rest[`--${p}-from`] = ft.from;
|
|
4385
5095
|
if (ft.to != null && ft.to !== STATE_DEFAULT[p]) rest[`--${p}-to`] = ft.to;
|
|
@@ -4602,7 +5312,8 @@ function build(ds, format, assetsDir) {
|
|
|
4602
5312
|
twPatternTokensCss,
|
|
4603
5313
|
patternsCss: pat,
|
|
4604
5314
|
assetsDir,
|
|
4605
|
-
utilities: UTILITIES
|
|
5315
|
+
utilities: UTILITIES,
|
|
5316
|
+
systemColorScheme: buildOpts.systemColorScheme === true
|
|
4606
5317
|
}),
|
|
4607
5318
|
designMd: format !== "design" ? "" : emitDesignMd({
|
|
4608
5319
|
ds,
|
|
@@ -4621,24 +5332,19 @@ function emitTailwind(ctx) {
|
|
|
4621
5332
|
const { ds, expandedPalette, scaleRoles, shadows, typeSteps, spaceSteps, typography, roleDecls, twTypeTokensCss, twPatternTokensCss, patternsCss, assetsDir, utilities } = ctx;
|
|
4622
5333
|
const cssDir = join(assetsDir, "css");
|
|
4623
5334
|
const nl = (arr) => arr.filter(Boolean).join("\n");
|
|
4624
|
-
const parts = ["/* GENERATED for Tailwind v4 (Astro) — do not edit by hand.\n Class reference: npx vitops docs classes · All topics: npx vitops docs */"];
|
|
5335
|
+
const parts = ["/* GENERATED for Tailwind v4 (Astro) — do not edit by hand.\n Class reference: npx vitops docs classes · All topics: npx vitops docs\n Fonts below are STACKS ONLY — this file loads no webfonts. Load them with\n Astro's fonts: config and point the token at the family's cssVariable. */"];
|
|
4625
5336
|
parts.push(`@import "tailwindcss";\n`);
|
|
4626
5337
|
const theme = [];
|
|
4627
5338
|
theme.push(` /* Hue scales → bg-<hue>-<step> text-* border-* (numeric, 50…950) */`);
|
|
4628
5339
|
for (const [name, hue] of Object.entries(expandedPalette)) for (const [n, hex] of Object.entries(hue.numeric)) theme.push(` --color-${name}-${n}: ${hex};`);
|
|
4629
|
-
theme.push(`\n /* Fonts → font-* */`);
|
|
5340
|
+
theme.push(`\n /* Fonts → font-* (stacks only; no @font-face is emitted — see header) */`);
|
|
4630
5341
|
for (const [name, stack] of Object.entries(ds.fonts ?? {})) theme.push(` --font-${name}: ${stack};`);
|
|
4631
5342
|
theme.push(`\n /* Fluid type scale → text-* */`);
|
|
4632
5343
|
for (const s of typeSteps) theme.push(` --text-${s.name}: ${s.value};`);
|
|
4633
5344
|
theme.push(`\n /* Drop-shadows → shadow-* */`);
|
|
4634
5345
|
for (const [name, value] of Object.entries(shadows ?? {})) theme.push(` --shadow-${name}: ${value};`);
|
|
4635
5346
|
theme.push(`\n /* Container breakpoints → @sm:/@md:/@lg:/@xl: */`);
|
|
4636
|
-
for (const [name, val] of
|
|
4637
|
-
["sm", "30rem"],
|
|
4638
|
-
["md", "48rem"],
|
|
4639
|
-
["lg", "64rem"],
|
|
4640
|
-
["xl", "80rem"]
|
|
4641
|
-
]) theme.push(` --container-${name}: ${val};`);
|
|
5347
|
+
for (const [name, val] of BREAKPOINTS) theme.push(` --container-${name}: ${val};`);
|
|
4642
5348
|
parts.push(`@theme {\n${theme.join("\n")}\n}\n`);
|
|
4643
5349
|
if (spaceSteps.length) parts.push(`/* Fluid space scale — var(--space-<name>) or p-(--space-<name>) */\n:root {\n` + spaceSteps.map((s) => ` --space-${s.name}: ${s.value};`).join("\n") + `\n}\n`);
|
|
4644
5350
|
if (scaleRoles.length) {
|
|
@@ -4660,8 +5366,16 @@ function emitTailwind(ctx) {
|
|
|
4660
5366
|
const surface = scaleRoles.find((r) => r.role === "surface");
|
|
4661
5367
|
if (surface) darkBlock += ` --overlay: color-mix(in oklch, var(--color-${surface.hue}-950) 60%, transparent);\n`;
|
|
4662
5368
|
}
|
|
4663
|
-
if (darkBlock)
|
|
5369
|
+
if (darkBlock) {
|
|
5370
|
+
parts.push(`/* Functional role tokens (dark) */\n${DARK_SEL} {\n color-scheme: dark;\n${darkBlock}}\n`);
|
|
5371
|
+
if (ctx.systemColorScheme) parts.push(`/* Functional role tokens (dark) — OS preference, no explicit choice */\n@media (prefers-color-scheme: dark) {\n ${SYSTEM_DARK_SEL} {\n color-scheme: dark;\n${darkBlock.replace(/^ {2}/gm, " ")} }\n}\n`);
|
|
5372
|
+
}
|
|
4664
5373
|
parts.push("/* State variant for the active flip (hover/focus-visible are built in). */\n@custom-variant is-active (&.is-active, &[data-active]);\n");
|
|
5374
|
+
{
|
|
5375
|
+
const roles = typography?.roles ?? {};
|
|
5376
|
+
const bindings = Object.entries(typography?.headings ?? {}).filter(([, r]) => roles[r]);
|
|
5377
|
+
if (bindings.length) parts.push(`/* Bare-element type roles (typography.headings) */\n@layer base {\n` + bindings.map(([tag, role]) => ` ${tag} {\n${decls(roleDecls(role, roles[role]), " ")}\n }`).join("\n") + `\n}\n`);
|
|
5378
|
+
}
|
|
4665
5379
|
const util = [];
|
|
4666
5380
|
if (scaleRoles.length) {
|
|
4667
5381
|
util.push(`/* Role colour utilities (the public API) */`);
|
|
@@ -4677,7 +5391,6 @@ function emitTailwind(ctx) {
|
|
|
4677
5391
|
for (const [k, v] of Object.entries(e.css ?? {})) d[k] = asStr(v);
|
|
4678
5392
|
for (const [k, v] of Object.entries(e.vars ?? {})) d[`--${k}`] = asStr(v);
|
|
4679
5393
|
util.push(`@utility ${name} {\n${decls(d)}\n}`);
|
|
4680
|
-
if (e.kf !== "composite" && e.kf !== "paint") continue;
|
|
4681
5394
|
const props = /* @__PURE__ */ new Set();
|
|
4682
5395
|
for (const k of Object.keys(e.vars ?? {})) {
|
|
4683
5396
|
const m = /^(.*)-(from|to)$/.exec(k);
|
|
@@ -4687,7 +5400,7 @@ function emitTailwind(ctx) {
|
|
|
4687
5400
|
util.push(`@utility flip-${name} {\n${decls(flip)}\n}`);
|
|
4688
5401
|
}
|
|
4689
5402
|
util.push(`\n/* Split — internal fractions (pair split with a ratio) */`);
|
|
4690
|
-
util.push(
|
|
5403
|
+
util.push(`@utility split-reverse {\n reading-flow: flex-visual;\n & > :first-child { order: 1; }\n}`);
|
|
4691
5404
|
for (const [a, b] of [
|
|
4692
5405
|
[1, 2],
|
|
4693
5406
|
[2, 1],
|
|
@@ -4697,7 +5410,12 @@ function emitTailwind(ctx) {
|
|
|
4697
5410
|
[4, 1],
|
|
4698
5411
|
[2, 3],
|
|
4699
5412
|
[3, 2]
|
|
4700
|
-
]) util.push(`@utility split-${a}-${b} {\n --_split-a: ${a};\n --_split-b: ${b};\n}`);
|
|
5413
|
+
]) util.push(`@utility split-${a}-${b} {\n flex-direction: row;\n --_split-a: ${a};\n --_split-b: ${b};\n}`);
|
|
5414
|
+
const gaps = gapUtilities(spaceSteps);
|
|
5415
|
+
if (gaps.length) {
|
|
5416
|
+
util.push(`\n/* Gap — the fluid space scale (var(--space-<name>)) */`);
|
|
5417
|
+
for (const { cls, prop, value } of gaps) util.push(`@utility ${cls} {\n ${prop}: ${value};\n}`);
|
|
5418
|
+
}
|
|
4701
5419
|
util.push(`\n/* Track placement (inside .centered) */`);
|
|
4702
5420
|
for (const track of [
|
|
4703
5421
|
"measure",
|
|
@@ -4705,6 +5423,28 @@ function emitTailwind(ctx) {
|
|
|
4705
5423
|
"spotlight",
|
|
4706
5424
|
"fullbleed"
|
|
4707
5425
|
]) util.push(`@utility ${track} {\n grid-column: ${track};\n}`);
|
|
5426
|
+
util.push(`\n/* Auto-fit grid — column count is content-driven, unlike .split */`);
|
|
5427
|
+
util.push("@utility grid-auto {\n display: grid;\n gap: var(--grid-gap, 1rem);\n grid-template-columns: repeat(auto-fit, minmax(var(--grid-min, 13rem), 1fr));\n &:is(ul, ol) > li + li { margin-block-start: 0; }\n}");
|
|
5428
|
+
util.push(`\n/* Vertical-rhythm overrides (mirrors layout.css) */`);
|
|
5429
|
+
for (const pair of [
|
|
5430
|
+
"h-p",
|
|
5431
|
+
"p-p",
|
|
5432
|
+
"p-h",
|
|
5433
|
+
"h-h",
|
|
5434
|
+
"p-list",
|
|
5435
|
+
"list-p",
|
|
5436
|
+
"li-li",
|
|
5437
|
+
"text-media",
|
|
5438
|
+
"media-text"
|
|
5439
|
+
]) util.push(`@utility m-${pair} {\n margin-top: calc(var(--rhythm-base) * var(--rhythm-${pair}));\n}`);
|
|
5440
|
+
for (const [name, mult] of [
|
|
5441
|
+
["0", null],
|
|
5442
|
+
["xs", "0.25"],
|
|
5443
|
+
["s", "0.5"],
|
|
5444
|
+
["m", "1"],
|
|
5445
|
+
["l", "1.5"],
|
|
5446
|
+
["xl", "2"]
|
|
5447
|
+
]) util.push(`@utility m-${name} {\n margin-top: ${mult == null ? "0" : `calc(var(--rhythm-base) * ${mult})`};\n}`);
|
|
4708
5448
|
parts.push(util.join("\n") + "\n");
|
|
4709
5449
|
parts.push(`/* ── Structure (mirrors src/css/layout.css) ── */
|
|
4710
5450
|
:root {
|
|
@@ -4727,6 +5467,11 @@ function emitTailwind(ctx) {
|
|
|
4727
5467
|
|
|
4728
5468
|
body {
|
|
4729
5469
|
container-type: inline-size;
|
|
5470
|
+
/* Named so \`patterns/scroll-target.css\`'s \`@container body (…)\` queries
|
|
5471
|
+
resolve — .toc-layout/.toc-sidebar/.toc-inline. Those partials are inlined
|
|
5472
|
+
into this bundle verbatim, so dropping the name (as this literal did) left
|
|
5473
|
+
the TOC permanently in its narrow layout in the tailwind format only. */
|
|
5474
|
+
container-name: body;
|
|
4730
5475
|
}
|
|
4731
5476
|
|
|
4732
5477
|
/* Class-level structure rules live in \`components\` so track/spacing utilities
|
|
@@ -4791,6 +5536,39 @@ body {
|
|
|
4791
5536
|
var(--region-space-max, 8rem)
|
|
4792
5537
|
);
|
|
4793
5538
|
}
|
|
5539
|
+
|
|
5540
|
+
/* \`.split\` is a PATTERN, so it belongs here rather than in an \`@utility\` — that
|
|
5541
|
+
is what makes \`flex-col\` beat it by layer instead of by a source-order race
|
|
5542
|
+
whose winner depends on Tailwind's property-based sort of custom utilities.
|
|
5543
|
+
Its ratio/reversal modifiers stay utilities. Mirrors layout.css, where each
|
|
5544
|
+
rule's rationale is written out; in short: \`min-inline-size: 0\` because a flex
|
|
5545
|
+
item's automatic minimum is its min-content size; the ratio as a flex BASIS
|
|
5546
|
+
rather than a grow factor, because grow shares out only the free space and a
|
|
5547
|
+
child's padding is not part of it; scoped to a two-child split because a ratio
|
|
5548
|
+
is a pair contract and a middle child with no basis would collapse to zero;
|
|
5549
|
+
\`box-sizing: border-box\` because a basis only sizes the border box on a
|
|
5550
|
+
border-box item (Tailwind's preflight sets it, the other formats' reset does). */
|
|
5551
|
+
.split {
|
|
5552
|
+
display: flex;
|
|
5553
|
+
flex-direction: row;
|
|
5554
|
+
}
|
|
5555
|
+
.split > * {
|
|
5556
|
+
flex: 1;
|
|
5557
|
+
box-sizing: border-box;
|
|
5558
|
+
min-inline-size: 0;
|
|
5559
|
+
}
|
|
5560
|
+
.split > :first-child {
|
|
5561
|
+
flex-grow: var(--_split-a, 1);
|
|
5562
|
+
}
|
|
5563
|
+
.split > :last-child {
|
|
5564
|
+
flex-grow: var(--_split-b, 1);
|
|
5565
|
+
}
|
|
5566
|
+
.split > :first-child:nth-last-child(2) {
|
|
5567
|
+
flex-basis: ${splitBasis("--_split-a")};
|
|
5568
|
+
}
|
|
5569
|
+
.split > :nth-child(2):last-child {
|
|
5570
|
+
flex-basis: ${splitBasis("--_split-b")};
|
|
5571
|
+
}
|
|
4794
5572
|
}
|
|
4795
5573
|
`);
|
|
4796
5574
|
const animPath = join(cssDir, "animation.css");
|
|
@@ -4854,9 +5632,9 @@ body {
|
|
|
4854
5632
|
if (depth === 0) {
|
|
4855
5633
|
const raw = css.slice(start, i + 1).trim();
|
|
4856
5634
|
const prelude = css.slice(start, preludeEnd).replace(/\/\*[\s\S]*?\*\//g, "").trim();
|
|
4857
|
-
const
|
|
5635
|
+
const name = /^\.([A-Za-z0-9_-]+)/.exec(prelude)?.[1];
|
|
4858
5636
|
if (/^@container\s*\(\s*min-width:/.test(prelude) && isVariantBlock(raw)) droppedVariantBlocks++;
|
|
4859
|
-
else if (
|
|
5637
|
+
else if (name && TW_CLASH.has(name) && !CLASH_KEEP.has(name)) droppedClashes.add(name);
|
|
4860
5638
|
else if (raw) kept.push(raw);
|
|
4861
5639
|
start = i + 1;
|
|
4862
5640
|
}
|
|
@@ -4871,25 +5649,60 @@ body {
|
|
|
4871
5649
|
return nl(parts) + "\n";
|
|
4872
5650
|
}
|
|
4873
5651
|
/**
|
|
5652
|
+
* Partials the tailwind format does NOT inline, because it re-creates them by
|
|
5653
|
+
* hand: the engine (`global.css` — Tailwind's preflight covers the reset;
|
|
5654
|
+
* `animation.css` — read directly and wrapped in `@layer components`), and the
|
|
5655
|
+
* two layout partials, whose utilities are re-emitted as `@utility` and whose
|
|
5656
|
+
* structure is re-emitted in the `@layer components` literal.
|
|
5657
|
+
*
|
|
5658
|
+
* **Every partial mapped to `vitops.utilities` must be listed here.** Inlining a
|
|
5659
|
+
* utility family lands it in tailwind's `@layer components`, the exact inversion
|
|
5660
|
+
* the layer split exists to remove — and it fails SILENTLY, because each of those
|
|
5661
|
+
* classes already exists as an `@utility` that wins on layer anyway. You would get
|
|
5662
|
+
* ~30 dead rules and no test failure. `format-parity.test.ts` asserts the
|
|
5663
|
+
* implication structurally rather than spot-checking the output.
|
|
5664
|
+
*
|
|
5665
|
+
* The implication is one-way: `layout.css` is skipped while sitting in
|
|
5666
|
+
* `vitops.components`, because its structure is re-emitted by hand.
|
|
5667
|
+
*/
|
|
5668
|
+
/**
|
|
5669
|
+
* Framework COMPONENTS whose class name collides with a Tailwind utility, and
|
|
5670
|
+
* which must therefore survive the `TW_CLASH` strip.
|
|
5671
|
+
*
|
|
5672
|
+
* The clash test matches a rule's leading `.<name>`, which is right for a
|
|
5673
|
+
* single-purpose utility (Tailwind ships its own) and wrong for a pattern that
|
|
5674
|
+
* merely shares the name. `patterns/sticky.css` opens `.sticky { … }` and was
|
|
5675
|
+
* being deleted from the tailwind bundle WHOLESALE — the `--_sticky-offset`
|
|
5676
|
+
* wiring, the z-index, and every `&.sticky--bottom`/`--inline-start` variant —
|
|
5677
|
+
* so a consumer writing `class="sticky sticky--bottom"` got bare
|
|
5678
|
+
* `position: sticky` and nothing else.
|
|
5679
|
+
*
|
|
5680
|
+
* `table` is here for the same reason but was never actually hit: `table.css`'s
|
|
5681
|
+
* prelude is `table,\n.table`, which doesn't start with `.`, so the regex missed
|
|
5682
|
+
* it by luck. Listing it makes that explicit rather than accidental.
|
|
5683
|
+
*/
|
|
5684
|
+
const CLASH_KEEP = new Set(["sticky", "table"]);
|
|
5685
|
+
const TAILWIND_SKIP = new Set([
|
|
5686
|
+
"global.css",
|
|
5687
|
+
"animation.css",
|
|
5688
|
+
"layout.css",
|
|
5689
|
+
"layout-utilities.css",
|
|
5690
|
+
"utilities.css"
|
|
5691
|
+
]);
|
|
5692
|
+
/**
|
|
4874
5693
|
* The component/structural CSS partials to inline into the Tailwind bundle, in
|
|
4875
|
-
* index.css cascade order. Everything the aggregator imports EXCEPT
|
|
4876
|
-
*
|
|
4877
|
-
* token layer (Tailwind builds its own via @theme).
|
|
5694
|
+
* index.css cascade order. Everything the aggregator imports EXCEPT `TAILWIND_SKIP`
|
|
5695
|
+
* and the generated token layer (Tailwind builds its own via @theme).
|
|
4878
5696
|
*/
|
|
4879
5697
|
function componentPartialOrder(cssDir) {
|
|
4880
5698
|
const indexPath = join(cssDir, "index.css");
|
|
4881
|
-
const skip = new Set([
|
|
4882
|
-
"global.css",
|
|
4883
|
-
"animation.css",
|
|
4884
|
-
"layout.css"
|
|
4885
|
-
]);
|
|
4886
5699
|
const order = [];
|
|
4887
5700
|
const seen = /* @__PURE__ */ new Set();
|
|
4888
5701
|
const text = existsSync(indexPath) ? readFileSync(indexPath, "utf8") : "";
|
|
4889
5702
|
for (const m of text.matchAll(/@import\s+['"]\.\/([^'"]+)['"]/g)) {
|
|
4890
5703
|
const rel = m[1];
|
|
4891
5704
|
if (rel.startsWith("generated/")) continue;
|
|
4892
|
-
if (
|
|
5705
|
+
if (TAILWIND_SKIP.has(rel)) continue;
|
|
4893
5706
|
if (!seen.has(rel) && existsSync(join(cssDir, rel))) {
|
|
4894
5707
|
seen.add(rel);
|
|
4895
5708
|
order.push(rel);
|
|
@@ -4939,9 +5752,21 @@ const CHUNK_LAYER = {
|
|
|
4939
5752
|
"generated/shadows.css": "vitops.utilities",
|
|
4940
5753
|
"generated/typography.css": "vitops.utilities",
|
|
4941
5754
|
"generated/animation-effects.css": "vitops.utilities",
|
|
5755
|
+
"generated/spacing.css": "vitops.utilities",
|
|
5756
|
+
"layout-utilities.css": "vitops.utilities",
|
|
4942
5757
|
"utilities.css": "vitops.utilities"
|
|
4943
5758
|
};
|
|
4944
5759
|
/**
|
|
5760
|
+
* A `.split` child's flex basis. The ratio is a BASIS rather than a grow factor
|
|
5761
|
+
* because grow shares out only the FREE space, which a child's padding is not
|
|
5762
|
+
* part of — so a padded column came out wider than its sibling by exactly its
|
|
5763
|
+
* horizontal padding. Must stay character-identical to layout.css; the parity
|
|
5764
|
+
* suite matches this expression against both.
|
|
5765
|
+
*/
|
|
5766
|
+
const splitBasis = (v) => `calc(var(${v}, 1) / (var(--_split-a, 1) + var(--_split-b, 1)) * 100%)`;
|
|
5767
|
+
/** The layer a partial's rules are wrapped in; unmapped partials are components. */
|
|
5768
|
+
const layerForPartial = (rel) => CHUNK_LAYER[rel] ?? "vitops.components";
|
|
5769
|
+
/**
|
|
4945
5770
|
* Assemble the full stylesheet by resolving index.css's @import order against the
|
|
4946
5771
|
* static partials (from assets) and the freshly generated token partials (in
|
|
4947
5772
|
* memory), then minify with lightningcss. No @import survives, so no disk
|
|
@@ -4970,7 +5795,7 @@ function bundleCss(generated, assetsDir, minify) {
|
|
|
4970
5795
|
const rel = m[1];
|
|
4971
5796
|
const body = rel.startsWith("generated/") ? generated[rel.slice(10)] ?? "" : readFileSync(join(cssDir, rel), "utf8");
|
|
4972
5797
|
if (!body.trim()) continue;
|
|
4973
|
-
chunks.push(`@layer ${
|
|
5798
|
+
chunks.push(`@layer ${layerForPartial(rel)} {\n${body}\n}`);
|
|
4974
5799
|
}
|
|
4975
5800
|
const merged = `/*! GENERATED by @getvitops/generator — do not edit by hand.
|
|
4976
5801
|
Class reference: npx vitops docs classes · All topics: npx vitops docs */
|
|
@@ -4986,21 +5811,39 @@ function bundleCss(generated, assetsDir, minify) {
|
|
|
4986
5811
|
map: res.map ? res.map.toString() : void 0
|
|
4987
5812
|
};
|
|
4988
5813
|
}
|
|
4989
|
-
|
|
4990
|
-
|
|
5814
|
+
/**
|
|
5815
|
+
* Read `input` as whichever config kind it is, and return the design system to
|
|
5816
|
+
* build from plus the site config if that is what it was.
|
|
5817
|
+
*
|
|
5818
|
+
* The design system is validated here rather than in `resolveInput` so the error
|
|
5819
|
+
* can name where it came from: a theme inside a site config reports its `themes`
|
|
5820
|
+
* path, which is the difference between "your config is wrong" and "your config
|
|
5821
|
+
* is wrong *here*" when the file is a few hundred lines of company facts.
|
|
5822
|
+
*/
|
|
5823
|
+
function loadInput(input, opts) {
|
|
5824
|
+
const resolved = resolveInput(typeof input === "string" ? JSON.parse(readFileSync(input, "utf8")) : input, opts);
|
|
5825
|
+
const result = validate(resolved.designSystem);
|
|
4991
5826
|
if (!result.ok) {
|
|
5827
|
+
const where = resolved.theme != null ? `design system at designSystem.themes.${resolved.theme}` : "design-system.json";
|
|
4992
5828
|
const lines = result.errors.slice(0, 12).map((e) => ` • ${e.path.join(".") || "(root)"}: ${e.message}`).join("\n");
|
|
4993
|
-
throw new Error(`Invalid
|
|
5829
|
+
throw new Error(`Invalid ${where}:\n${lines}`);
|
|
4994
5830
|
}
|
|
4995
5831
|
for (const w of result.warnings) console.warn(`[vitops] ${w}`);
|
|
4996
|
-
return
|
|
5832
|
+
return {
|
|
5833
|
+
ds: result.data,
|
|
5834
|
+
...resolved.site ? { site: resolved.site } : {}
|
|
5835
|
+
};
|
|
4997
5836
|
}
|
|
4998
5837
|
async function generate(options) {
|
|
4999
5838
|
const format = options.format ?? "bricks";
|
|
5000
5839
|
const outDir = options.outDir ?? "dist";
|
|
5001
5840
|
const assetsDir = options.assetsDir ?? DEFAULT_ASSETS;
|
|
5002
|
-
const ds =
|
|
5003
|
-
|
|
5841
|
+
const { ds, site: embedded } = loadInput(options.input, {
|
|
5842
|
+
...options.theme != null ? { theme: options.theme } : {},
|
|
5843
|
+
...options.siteEnv != null ? { siteEnv: options.siteEnv } : {}
|
|
5844
|
+
});
|
|
5845
|
+
const site = options.site != null ? loadSiteConfigFile(options.site, options.siteEnv) : embedded;
|
|
5846
|
+
const built = build(ds, format, assetsDir, { systemColorScheme: options.systemColorScheme ?? site?.designSystem?.defaultColorScheme === "system" });
|
|
5004
5847
|
mkdirSync(outDir, { recursive: true });
|
|
5005
5848
|
const written = [];
|
|
5006
5849
|
const put = (rel, content) => {
|
|
@@ -5039,9 +5882,30 @@ async function generate(options) {
|
|
|
5039
5882
|
const docs = generateDocs(ds, assetsDir);
|
|
5040
5883
|
for (const [rel, content] of Object.entries(docs)) put(join("docs", rel), content);
|
|
5041
5884
|
}
|
|
5042
|
-
if (
|
|
5043
|
-
const legal = generateLegal(
|
|
5885
|
+
if (site != null && format !== "design") {
|
|
5886
|
+
const legal = generateLegal(site, { output: "html" });
|
|
5044
5887
|
for (const [name, content] of Object.entries(legal)) put(join("legal", name), content);
|
|
5888
|
+
const icons = site.icons;
|
|
5889
|
+
if (icons?.sprite) {
|
|
5890
|
+
const { ui, brand, weight, semantic, sprite: _sprite, ...sets } = icons;
|
|
5891
|
+
const include = generateIconInclude({
|
|
5892
|
+
...ui ? { ui } : {},
|
|
5893
|
+
...brand ? { brand } : {},
|
|
5894
|
+
...weight ? { weight } : {},
|
|
5895
|
+
...semantic ? { semantic } : {},
|
|
5896
|
+
...sets
|
|
5897
|
+
});
|
|
5898
|
+
const aliases = {};
|
|
5899
|
+
for (const name of semantic ?? []) try {
|
|
5900
|
+
aliases[`icon-${name}`] = resolveIcon(name, ui ?? "fa7-solid", weight ? { weight } : {});
|
|
5901
|
+
} catch {}
|
|
5902
|
+
const built = await buildIconSprite({
|
|
5903
|
+
include,
|
|
5904
|
+
aliases
|
|
5905
|
+
});
|
|
5906
|
+
put("icons.svg", built.svg);
|
|
5907
|
+
if (built.missing.length) console.warn(`[vitops] icons.sprite: ${built.missing.length} icon(s) were not found and are absent from the sprite: ${built.missing.join(", ")}.`);
|
|
5908
|
+
}
|
|
5045
5909
|
}
|
|
5046
5910
|
return {
|
|
5047
5911
|
format,
|
|
@@ -5049,8 +5913,8 @@ async function generate(options) {
|
|
|
5049
5913
|
written
|
|
5050
5914
|
};
|
|
5051
5915
|
}
|
|
5052
|
-
function loadSiteConfigFile(input) {
|
|
5053
|
-
return resolveSiteConfig(typeof input === "string" ? JSON.parse(readFileSync(input, "utf8")) : input);
|
|
5916
|
+
function loadSiteConfigFile(input, siteEnv) {
|
|
5917
|
+
return resolveSiteConfig(typeof input === "string" ? JSON.parse(readFileSync(input, "utf8")) : input, siteEnv);
|
|
5054
5918
|
}
|
|
5055
5919
|
|
|
5056
5920
|
//#endregion
|
|
@@ -5280,19 +6144,22 @@ function defaultConfig() {
|
|
|
5280
6144
|
size: "var(--text-3xl)",
|
|
5281
6145
|
weight: 700,
|
|
5282
6146
|
"line-height": "1.05",
|
|
5283
|
-
tracking: "-0.03em"
|
|
6147
|
+
tracking: "-0.03em",
|
|
6148
|
+
"text-wrap": "balance"
|
|
5284
6149
|
},
|
|
5285
6150
|
heading: {
|
|
5286
6151
|
family: "display",
|
|
5287
6152
|
size: "var(--text-xl)",
|
|
5288
6153
|
weight: 600,
|
|
5289
|
-
"line-height": "1.3"
|
|
6154
|
+
"line-height": "1.3",
|
|
6155
|
+
"text-wrap": "balance"
|
|
5290
6156
|
},
|
|
5291
6157
|
body: {
|
|
5292
6158
|
family: "sans",
|
|
5293
6159
|
size: "var(--text-m)",
|
|
5294
6160
|
weight: 400,
|
|
5295
|
-
"line-height": "1.55"
|
|
6161
|
+
"line-height": "1.55",
|
|
6162
|
+
"text-wrap": "pretty"
|
|
5296
6163
|
}
|
|
5297
6164
|
},
|
|
5298
6165
|
headings: {
|
|
@@ -5316,7 +6183,7 @@ function defaultConfig() {
|
|
|
5316
6183
|
journeys: {
|
|
5317
6184
|
base: {
|
|
5318
6185
|
fade: { "opacity-from": 0 },
|
|
5319
|
-
slide: {}
|
|
6186
|
+
slide: { "translate-y-from": "var(--slide-distance, 2rem)" }
|
|
5320
6187
|
},
|
|
5321
6188
|
compose: [
|
|
5322
6189
|
["fade"],
|
|
@@ -5329,5 +6196,5 @@ function defaultConfig() {
|
|
|
5329
6196
|
}
|
|
5330
6197
|
|
|
5331
6198
|
//#endregion
|
|
5332
|
-
export { BASE_HOOK, CONTRAST, DARK_SEL, DOC_ORDER, DOC_SLUGS, DesignSystemSchema, JURISDICTIONS, KNOWN_PROCESSORS, NUMERIC_STEPS, REQUIRED_ROLES, ROLE_TOKEN_KEYS, SCHEMA_URL, SITE_SCHEMA_URL, SiteConfigSchema, TEMPLATES, TW_CLASH, deepMerge, defaultConfig, derivePolicyVars, detectProcessorKeys, enabledDocs, expandPalette, functionalRole, generate, generateDocs, generateLegal, jsonSchema, parseMarkdown, renderMarkdown, renderNodes, resolvePrivacyContact, resolveProcessors, resolveSiteConfig, resolveTheme, roleColorUtilities, roleHue, roleKind, siteJsonSchema, stripNulls, toContentNodes, toHtmlFragment, toPortableText, tokenClass, tokenVar, validate, validateSite };
|
|
6199
|
+
export { BASE_HOOK, CONTRAST, DARK_SEL, DOC_ORDER, DOC_SLUGS, DesignSystemSchema, JURISDICTIONS, KNOWN_PROCESSORS, NUMERIC_STEPS, REQUIRED_ROLES, ROLE_TOKEN_KEYS, SCHEMA_URL, SITE_SCHEMA_URL, SiteConfigSchema, SiteFontSchema, TEMPLATES, TW_CLASH, buildIconSprite, deepMerge, defaultConfig, derivePolicyVars, detectProcessorKeys, enabledDocs, expandPalette, functionalRole, generate, generateDocs, generateLegal, isSiteConfig, jsonSchema, loadIconSvg, parseMarkdown, renderMarkdown, renderNodes, resolveInput, resolvePrivacyContact, resolveProcessors, resolveSiteConfig, resolveTheme, roleColorUtilities, roleHue, roleKind, siteJsonSchema, spriteId, stripNulls, toContentNodes, toHtmlFragment, toPortableText, tokenClass, tokenVar, validate, validateSite };
|
|
5333
6200
|
//# sourceMappingURL=index.mjs.map
|