@getvitops/generator 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,748 @@
1
1
  # @getvitops/generator
2
2
 
3
+ ## 2.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - bf453b0: The framework now ships a border-box reset.
8
+
9
+ ```css
10
+ *,
11
+ *::before,
12
+ *::after {
13
+ box-sizing: border-box;
14
+ }
15
+ ```
16
+
17
+ **This is a behavioural change for the `css` and `bricks` formats.** If your project does not
18
+ already set border-box — Tailwind's preflight does, as does normalize and almost every modern base
19
+ stylesheet — elements with padding or a border now measure their declared width inclusive of it, so
20
+ some boxes will come out narrower than before. The `tailwind` format is unaffected: preflight
21
+ already did this.
22
+
23
+ The framework used to refuse this on the grounds that a global reset silently reflows consumer
24
+ layouts, and that reasoning was sound when nothing was layered. Two things changed:
25
+ - **It is layered.** The rule lives in `vitops.base`, the lowest of the three framework layers, so
26
+ it loses to any unlayered consumer CSS. The opt-out is one rule in your own stylesheet:
27
+ `*, *::before, *::after { box-sizing: content-box }`.
28
+ - **Patterns are only correct under it.** `.split`'s ratio is a flex basis, which sizes the border
29
+ box; under content-box a padded column came out wider than its sibling by exactly its padding.
30
+ Stating the assumption once beats every pattern re-asserting it and the ones that forget being
31
+ quietly wrong.
32
+
33
+ - bf453b0: **Breaking (site config):** `designSystem` is now an object, and the toggle's "System" position works.
34
+
35
+ `<color-scheme-toggle>` has always shipped three segments, and "System" did nothing. It removes the
36
+ theme attribute, and with no `prefers-color-scheme` block in the generated CSS the page fell straight
37
+ through to light on every machine — so a third of the control was inert, and a no-JS page could never
38
+ follow the OS at all.
39
+
40
+ The fix is a second, opt-in copy of the dark delta under
41
+ `@media (prefers-color-scheme: dark)`, scoped to "no explicit choice has been made". An explicit
42
+ light choice still wins over a dark OS. It costs **+303 B gzipped** on the colour layer — the block
43
+ repeats only the tokens whose dark value differs, and two identical runs of text compress to almost
44
+ nothing. It is opt-in because switching it on visibly flips an existing site dark for dark-OS
45
+ visitors.
46
+
47
+ Turning it on is a site-level fact, which needed somewhere to live:
48
+
49
+ ```jsonc
50
+ "designSystem": {
51
+ "themes": { "default": { … }, "elegant": { "extends": "default", … } },
52
+ "defaultTheme": "default",
53
+ "defaultColorScheme": "system" // 'light' | 'dark' | 'system'
54
+ }
55
+ ```
56
+
57
+ **What changed.** `designSystem` was a bare map of themeName → design system, so _every_ key was a
58
+ theme name and there was nowhere to put a system-wide field without it colliding with a theme of that
59
+ name. The map moves under `themes`, and `defaultTheme` + `defaultColorScheme` move inside the block.
60
+ `respectSystemPreference` is gone — `defaultColorScheme: "system"` says the same thing, and the two
61
+ were always read together, so the incoherent combination is no longer expressible.
62
+
63
+ **Migration is automatic at runtime.** `resolveSiteConfig` accepts all three spellings — the
64
+ canonical shape, a bare theme map (the old schema), and a bare design system written inline. But
65
+ `site.schema.json` is published to a stable URL, so an editor pinned to `$schema` will flag the old
66
+ shape; update it when convenient.
67
+
68
+ One behavioural change worth knowing: shorthand normalisation now runs **before** the A/B variant
69
+ merge, not after. Previously an `abTesting` override's key path depended on which shorthand the base
70
+ config happened to use, so the same patch landed in different places in two otherwise-equivalent
71
+ configs. Overrides now always address `designSystem.themes.<name>`.
72
+
73
+ Also: `getvitops({ site: { input } })` gives the Astro integration one place to name the site config
74
+ — `legal`, `fonts` and the colour scheme all read from it — and `css.systemColorScheme` sets the
75
+ appearance directly for consumers who have no site config.
76
+
77
+ Light/dark remains **derived**, not a theme: `functionalRole()` builds both appearances from one ramp,
78
+ which is what lets the contrast contract check both at build time and what gives every consumer a
79
+ working dark mode without authoring one. `themes` is for authored variants.
80
+
81
+ - bf453b0: Utilities now beat patterns, in every format. Cascade layers are assigned by what a rule **is**,
82
+ not by which file it happens to live in.
83
+
84
+ `vitops.base → vitops.components → vitops.utilities` has existed for a while, and its whole purpose
85
+ is to let `class="card bg-danger-muted"` work without `!important`. But the layer was chosen per
86
+ **partial**, and `layout.css` was one file holding both the structural patterns (`.rhythm`,
87
+ `.centered`, `.split`) and roughly three quarters of the framework's utilities (`.m-*`, `.flex-*`,
88
+ `.items-*`, `.justify-*`, `.text-*`, `.split-<a>-<b>`, track placement). Unmapped, it defaulted to
89
+ `vitops.components` — so all of those utilities were shelved _below_ the patterns they are meant to
90
+ override, and quietly did nothing. `utilities.css` had the mirror-image problem: it held the
91
+ `.reveal` component family, which therefore outranked every pattern _and_ the display utilities.
92
+
93
+ Both are now split — `layout.css` / `layout-utilities.css`, and `patterns/reveal.css` — and the
94
+ classification is asserted across formats by a shared `LAYER_CONTRACT`, so the two halves cannot
95
+ drift apart again.
96
+
97
+ **What visibly changes.** Each of these was silent before and now takes effect. That is the point,
98
+ but if you wrote one of these combinations and never noticed it did nothing, you will see a change:
99
+
100
+ | markup | before | after |
101
+ | ---------------------------------------- | ------------------------------------------ | ---------------------- |
102
+ | `class="table text-center"` | `.table { text-align: start }` won | `.text-center` wins |
103
+ | `class="banner items-start"` | `.banner`'s centring won | the utility wins |
104
+ | `class="cluster-between justify-center"` | `.cluster-between` won | `.justify-center` wins |
105
+ | `class="icon justify-start"` | `.icon { justify-content: center }` won | `.justify-start` wins |
106
+ | `class="combobox flex-row"` | `.combobox { flex-direction: column }` won | `.flex-row` wins |
107
+ | `class="media items-center"` | `.media { align-items: flex-start }` won | `.items-center` wins |
108
+ | `<details>` first content child + `m-0` | `details.css`'s `> summary + *` margin won | `.m-0` wins |
109
+ | `class="split flex-col"` | worked, but on source order | works by layer |
110
+
111
+ The reveal family moves the other way, having been mis-shelved as a utility: `class="reveal hidden"`
112
+ and `class="reveal-fade block"` used to keep the reveal's own `display` and now lose to the display
113
+ utility. The duplicate `details::details-content` rules in `utilities.css` are deleted —
114
+ `patterns/details.css` was always the owner, and its selector is a superset — so the collapsed
115
+ content box is now `overflow: hidden` rather than `clip`.
116
+
117
+ **Removed: the bare `<bp>-split` classes** (`sm-split`, `md-split`, `lg-split`, `xl-split`).
118
+ `.split` is a pattern now, and `@utility` cannot live in a cascade layer — measured against
119
+ tailwindcss@4.3.3, it throws inside `@layer` _and_ inside a file imported with `layer(…)`, and a
120
+ `@custom-variant` can't reach a components-layer class either — so `@md:split` became impossible and
121
+ the css/bricks counterpart had to go with it. Use `md-flex-row` for "become a row at md"; it says
122
+ the same thing in every format. The one thing lost is resetting a bare ratio back to equal at a
123
+ breakpoint, which had no usages; apply the ratio at the breakpoint instead.
124
+
125
+ **Also fixed, in the tailwind format only** — three divergences found while auditing the layers:
126
+ - `body` was emitted with `container-type` but not `container-name: body`, so the
127
+ `@container body (…)` queries in the scroll/TOC patterns never matched: `.toc-layout` and
128
+ `.toc-sidebar` were stuck in their narrow layout.
129
+ - `grid-auto` was missing the `:is(ul, ol) > li + li` margin reset, so a `<ul class="grid-auto">`
130
+ inside `.rhythm` got a stray top margin on every item but the first.
131
+ - **`.sticky` was being deleted outright.** `sticky` is a Tailwind utility name, and the strip that
132
+ defers those to Tailwind matched a rule's leading class — taking `patterns/sticky.css` with it,
133
+ including `--sticky-offset`, the z-index wiring and every `.sticky--bottom` / `--inline-start` /
134
+ `--inline-end` variant. Components whose names collide with Tailwind utilities are now allowlisted.
135
+
136
+ **Migration.** If a layout utility on a patterned element now "suddenly works" and you preferred the
137
+ old result, remove the utility — it was never doing anything. To keep a pattern winning over the
138
+ framework's utilities, write the declaration in your own stylesheet: unlayered CSS still beats all
139
+ three framework layers.
140
+
141
+ ### Minor Changes
142
+
143
+ - 4756788: Add `<Analytics />` and a general-purpose consent gate.
144
+
145
+ `getvitops({ analytics })` configures Google Analytics 4, Microsoft Clarity, Matomo and Plausible;
146
+ `<Analytics />` emits their tags. Nothing touches the critical path — `strategy` defaults to `'idle'`,
147
+ which loads every tag after `load` on an idle callback (`'async'` and `'interaction'` are the other
148
+ options), and no `preconnect` is emitted, since warming a third-party connection during parse is the
149
+ cost `idle` exists to avoid.
150
+
151
+ `getvitops({ consent })` adds the gate: `@getvitops/core/consent`, a 2.3 KB gzipped Lit-free bundle,
152
+ plus `<CookieConsent />`.
153
+
154
+ ```js
155
+ vitops({
156
+ analytics: { googleAnalytics: 'G-XXXXXXXXXX', plausible: 'acme.com' },
157
+ consent: { policyUrl: '/legal/cookies' },
158
+ });
159
+ ```
160
+
161
+ **Consent is not an analytics feature.** The gate is general — mark anything
162
+ `data-consent="<category>"` and it waits on the same choice, so A/B assignment, personalisation and
163
+ third-party embeds use it too, and a site can enable the banner with no analytics at all. Categories
164
+ are `necessary` / `analytics` / `marketing` / `preferences`, and `window.vitopsConsent` plus a
165
+ `vitops:consent` event on `document` are how your own code reads the answer.
166
+
167
+ **Which category a provider needs is derived, not declared.** It follows from whether that provider
168
+ sets cookies, which follows from its own config: Matomo runs cookieless by default (`disableCookies`)
169
+ and so needs no banner; `cookies: true` opts in and moves it behind `analytics`. You can't mark
170
+ Google Analytics `necessary` to skip the banner — but you can pick a genuinely cookieless provider.
171
+
172
+ Gated tags render as `<script type="text/plain">` with the URL on `data-src`, so an undecided or
173
+ declining visitor's page issues **no third-party request at all**. For Google Analytics that is basic
174
+ consent mode rather than Consent Mode v2 advanced: nothing reaches Google until the visitor accepts.
175
+ Clarity additionally receives `clarity('consentv2', …)`, because Microsoft enforces the signal
176
+ separately for EEA/UK/CH traffic. Nothing is stored until a choice is made — the banner can't be the
177
+ thing that needs consent — and revoking clears the provider's cookies and reloads, because an
178
+ already-executing tracker can't be unloaded any other way.
179
+
180
+ The banner is shown in the top layer via `popover="manual"`, like `.tooltip`: a plain fixed banner
181
+ resolves against the nearest containing block, and `body { container-type: inline-size }` — ordinary
182
+ in a framework whose breakpoints are container queries — would otherwise trap it mid-page.
183
+
184
+ **The site config gained `analytics.clarityId` and `analytics.matomo`**, so `vitops legal` discloses
185
+ them. Clarity and Matomo join the processor table with their real cookie names, cookieless Matomo
186
+ asserts positively that it sets none, and a Clarity site's privacy policy now describes session
187
+ replay rather than filing it under page-view analytics. Configure `legal` alongside `analytics` and
188
+ the Astro integration cross-checks the two, naming any provider you'd otherwise run without
189
+ disclosing; it also warns when a cookie-setting provider has no `consent` gate.
190
+
191
+ Existing configs are unaffected — both options are off unless provided. One behaviour change if you
192
+ use the ad-conversion tracking script: `packages/astro/src/scripts/tracking.ts` now waits for
193
+ `marketing` consent before writing its 90-day `_ac` click-ID cookie, when the gate is present. With
194
+ no gate on the page it behaves exactly as before.
195
+
196
+ - bf453b0: Animation families that never actually animated, and a driver that fired before you could see it.
197
+
198
+ Each of these looked correct in source and failed somewhere else — in the bundler, in hit-testing,
199
+ or in the difference between a time-based and a progress-based timeline. Nothing here needs a
200
+ markup change.
201
+
202
+ **Changed: entrances are now timed off the element's midpoint.** Every driver used to key off a
203
+ fraction of the element's _own height_, which meant the same class behaved differently on a 4rem
204
+ card and a full-bleed section — and on small elements it was over before they appeared
205
+ (`entry 20%` is about 17px of scroll for a 5.5rem tile). Motion now starts once the element's
206
+ **midpoint is 10% of the viewport in**, and a one-shot entrance completes at **25%**. This applies
207
+ to `animate-view` and to the `.is-active` observer that drives `animate-trigger` and the
208
+ `active-<fx>` transition variants, so everything on a page starts at the same moment on screen.
209
+
210
+ The pivot is `entry 50%` — the one point that means "midpoint on the viewport edge" for an element
211
+ of any height — plus a viewport length. Shift the window with `--anim-start` / `--anim-end`, or
212
+ replace it with `--anim-range`. `--stagger-range-step` is now a viewport length (`5vh`) to match.
213
+
214
+ **Fixed: every journey ran on a truncated range.** The generator emitted `animation-range: entry
215
+ exit`; lightningcss parsed the end of that shorthand as `exit 0%` rather than the spec's `exit
216
+ 100%`, so the bundle shipped `entry exit 0%`. Journeys reach their `100%` keyframe — the hidden
217
+ `from` state — as the element hits the top of the viewport, which is to say they faded out while
218
+ still fully on screen. Journeys now run `entry calc(50% + 10vh) → exit 100%`: they start on the same
219
+ midpoint pivot as everything else and keep the full entry → hold → exit arc, so the hold sits in the
220
+ middle of the crossing. `animation-effects.test.ts` asserts on the **bundled** css, because the
221
+ emitter was right the whole time.
222
+
223
+ **Fixed: `slide-journey` had no distance to travel.** `animations.journeys.base.slide` was `{}` in
224
+ both `defaultConfig()` and the shipped example, so the keyframe animated `translate: 0 → 0`. It now
225
+ declares `translate-y-from: var(--slide-distance, 2rem)` — the same var the `slide-up` effect uses,
226
+ so one knob tunes both. If your own config has an empty `slide` base, add the same line.
227
+
228
+ **Fixed: `reveal-*` on hover was unreachable.** A `hover-reveal-left` element rests at `clip-path:
229
+ inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting — so it had zero
230
+ hittable area and could never receive the hover that would reveal it. The state variants now match
231
+ the element **or its direct parent**, mirroring what `animation.css` already did for the trigger
232
+ driver (`:is(.is-active, [data-active]) > .animate-trigger`):
233
+
234
+ ```css
235
+ .hover-<fx>:hover, :hover > .hover-<fx> { … }
236
+ .focus-<fx>:focus-visible, :focus-within > .focus-<fx> { … }
237
+ ```
238
+
239
+ Specificity is unchanged (0-2-0), so nothing reorders. **Behaviour change:** a `hover-<fx>` element
240
+ that is a direct child of a hovered element now flips with its parent. Wrap it in an intermediate
241
+ element if you need the old element-only behaviour.
242
+
243
+ **Fixed: `.stagger` did nothing on a scroll-driven timeline.** It offsets children with
244
+ `animation-delay`, which is time-based and is ignored outright on a `view()` / `scroll()` timeline,
245
+ so `.stagger > .animate-view` arrived all at once. It now also offsets each child's view
246
+ `animation-range` by the same index, so one class works under both driver families — tune the
247
+ scroll-driven step with `--stagger-range-step` (default `5vh`). Journeys declare their own range and
248
+ opt out by construction.
249
+
250
+ Two smaller faults found alongside it: `@supports (--x: sibling-index())` is **always true** — any
251
+ token stream is a valid custom-property value — so the guard around `sibling-index()` guarded
252
+ nothing in both `.stagger` and `.subgrid`'s row index, and on an engine without support the
253
+ declaration went invalid rather than being skipped. Both now test a real property. And the CSS path
254
+ was 1-based while the JS fallback wrote 0-based, so the two disagreed by one step wherever both ran.
255
+
256
+ **Fixed: the pre-paint `<html>` class script was missing entirely.** `<Head />` now emits it again,
257
+ outside the `webComponents` block since it gates stylesheet behaviour rather than the element
258
+ runtime. It does two things `animation.css` depends on and that three other files documented as
259
+ already shipping:
260
+ - `no-js` → `js`, so `.animate-trigger` is paused only where JS can un-pause it;
261
+ - `.no-scroll-timeline` when `animation-timeline: view()` is unsupported, which is what cancels
262
+ `.animate-view` / `.animate-scroll`. Scroll-driven animations are deliberately **not** polyfilled,
263
+ so without this flag those elements sit at their `from` keyframe — `opacity: 0`, i.e. invisible
264
+ content — on any engine without support. It had been dropped in the move to publishable packages,
265
+ leaving the cancel rule as dead code.
266
+
267
+ **New: `hover-size-grow` and friends exist.** The layout effects (`size-grow`, `size-shrink`) were
268
+ the one family with no state variants, on the grounds that `.transition` doesn't cover `height`.
269
+ That wasn't a platform limit — `height: 0 → auto` just needs `interpolate-size: allow-keywords`,
270
+ which the framework already sets on `:root`, and which the `layout` **keyframe** depended on
271
+ equally. So the exclusion bought no portability; it only made `hover-size-grow` a class the docs
272
+ advertised and the stylesheet didn't define. `.transition` now declares `height` inside an
273
+ `@supports (interpolate-size: allow-keywords)` block, and the generator emits the full
274
+ `hover-`/`focus-`/`active-` set for layout effects, carrying each effect's own `overflow: clip` so a
275
+ collapsed box hides its content instead of spilling it. Where `interpolate-size` is missing, the
276
+ height simply doesn't transition — the same degradation the keyframe path has. (For the record:
277
+ `transition-behavior: allow-discrete` is _not_ the tool here — it only lets genuinely discrete
278
+ properties transition, and would snap at the midpoint.)
279
+
280
+ `.transition` switched from the `transition` shorthand to `transition-property` +
281
+ `transition-duration` + `transition-timing-function`, so the gated block can append `height` without
282
+ restating the list. If you override `.transition`'s timing, override the longhands.
283
+
284
+ The generated class reference (`vitops docs classes`) was the upstream source of that mismatch — it
285
+ listed every effect under "with the state prefixes above", including the layout ones. It now states
286
+ which stage needs the feature gate, when each driver plays, and how `stagger` composes with both.
287
+
288
+ - bf453b0: Anywhere the toolchain takes a config, that file may now be a `design-system.json` **or** the larger
289
+ site config that embeds one.
290
+
291
+ If you keep your tokens inside a `company.json` / `site.json`, you had to maintain a second file for
292
+ the tooling's sake — or duplicate your whole token set, because a site config must carry a complete
293
+ `designSystem`. Now you point the same option at the file you already have:
294
+
295
+ ```js
296
+ // astro.config.mjs
297
+ getvitops({ css: { input: 'company.json', format: 'css' } });
298
+ ```
299
+
300
+ ```
301
+ vitops generate --input company.json --format css --out dist
302
+ vitops lint --input company.json --src src
303
+ ```
304
+
305
+ The design system is taken from `designSystem.themes[theme]` with its `extends` chain resolved —
306
+ `defaultTheme`, else `default`, else whatever `--theme` / the `theme` option names. Nothing changes
307
+ for a plain `design-system.json`; the two are told apart by shape, not by filename, so you can call
308
+ the file anything.
309
+
310
+ **A site config also supplies the site-level facts generation reads**, so the path is declared once
311
+ rather than per option:
312
+ - `designSystem.defaultColorScheme: "system"` — emits the `prefers-color-scheme` block, which is
313
+ what makes `<color-scheme-toggle>`'s System position do anything.
314
+ - `legal.*.enabled` — renders the documents. `legal: {}` is now the whole declaration; `legal.input`
315
+ is optional.
316
+ - `icons.sprite` — builds `icons.svg`.
317
+ - `fonts` — `fonts: true` reads the families from it.
318
+
319
+ Each of those is already gated on a field in that config, so nothing new appears unless the config
320
+ asks for it. An explicit `site` option still wins when the two are genuinely different files.
321
+
322
+ Also:
323
+ - **`vitops validate` routes on the file's shape.** Pointed at a site config it used to report a
324
+ single `unrecognized_keys` for `designSystem` and nothing about the file's actual contents — a
325
+ wrong answer that reads like a right one. It now validates a site config as one, including the
326
+ cross-field integrity JSON Schema can't express, and checks every theme resolves to a complete
327
+ design system (`validateSite` only checked that the `extends` chain resolved, not what it resolved
328
+ to).
329
+ - **`generate()` gained `theme` and `siteEnv`.** The A/B variant for `siteEnv` is applied before the
330
+ theme is selected, so a variant can override tokens.
331
+ - **The theme editor's Save to source follows the design system into a site config.** Its patch is
332
+ design-system-relative, so the dev server merges into that subtree and writes only the surrounding
333
+ file whole. It locates the subtree in the raw on-disk object rather than the normalised one, so an
334
+ author who wrote either `designSystem` shorthand still gets their own keys edited.
335
+
336
+ - bf453b0: `getvitops({ fonts })` — load webfonts through Astro's Fonts API instead of hand-rolling it.
337
+
338
+ The design system names fonts and loads none of them, and there was no seam for the loading half —
339
+ so the only available path was installing a `@fontsource*` package and importing its CSS, which
340
+ renders correctly while silently giving up subsetting, preload, and the `size-adjust` /
341
+ `ascent-override` fallback metrics. That is a CLS regression that looks like a working setup. This
342
+ is the counterpart to `vitopsHosting()`: declare the family, let the integration wire it.
343
+
344
+ ```js
345
+ getvitops({
346
+ fonts: [
347
+ {
348
+ name: 'League Spartan',
349
+ provider: 'fontsource',
350
+ cssVariable: '--font-league-spartan',
351
+ weights: ['100 900'],
352
+ subsets: ['latin'],
353
+ preload: true,
354
+ },
355
+ ],
356
+ });
357
+ ```
358
+
359
+ ```jsonc
360
+ // design-system.json — the token points at the variable, not at a literal stack
361
+ "fonts": { "display": "var(--font-league-spartan), sans-serif" }
362
+ ```
363
+
364
+ `fonts` also takes a **string**: the path to a site config, whose `fonts` array has carried exactly
365
+ these declarations (provider / weights / subsets / preload) since it was written — nothing had ever
366
+ read it. Or `{ input, families, siteEnv }` for both.
367
+
368
+ `<Head />` now emits `<Font />` for each declared family, with `preload` driven by the declaration.
369
+ That half is not optional: Astro's `fonts:` config resolves the files, but `<Font />` is what puts
370
+ the `@font-face` on the page, so a declaration without `<Head />` in your layout loads nothing.
371
+
372
+ Notes:
373
+ - Independent of `css` — the wiring runs in `astro:config:setup`, not the Vite plugin.
374
+ - Only families declared here get a `<Font />`. One from your own `astro:config` `fonts:` array or
375
+ from another integration is left alone, because `<Font />` throws on a `cssVariable` Astro cannot
376
+ resolve. Astro concatenates the arrays, so the three coexist; two entries claiming one variable
377
+ is the collision that matters, and it now throws (within our set) or warns (against yours)
378
+ instead of silently dropping a family.
379
+ - `provider: 'adobe'` throws with instructions: `fontProviders.adobe({ id })` needs a key from the
380
+ environment, and a JSON declaration has nowhere to hold one.
381
+ - New exports from `@getvitops/generator`: `SiteFontSchema` and the `SiteFont` type.
382
+
383
+ - bf453b0: New `gap-*` utilities over the fluid space scale, in all three formats.
384
+
385
+ `gap-<name>`, `gap-x-<name>` (column) and `gap-y-<name>` (row), for every step of your
386
+ `spaceScale` — `gap-2xs` … `gap-7xl` — each breakpoint-prefixable (`md-gap-l` in css/bricks,
387
+ `@md:gap-l` in tailwind).
388
+
389
+ There was no gap utility at all before. `vitops docs css` advertised a `g` class that was never
390
+ emitted, so the honest answer for a css or bricks consumer was an inline `style="gap: …"`. Tailwind
391
+ did not fill the hole either: the fluid steps are deliberately kept **out** of Tailwind's
392
+ `--spacing-*` namespace, because named keys there shadow the size scales (`max-w-7xl` would resolve
393
+ to `var(--spacing-7xl)` and collapse layouts), so `gap-l` was not something Tailwind could derive.
394
+ Measured against tailwindcss@4.3.3: the emitted `@utility gap-l` is honoured, accepts variants, and
395
+ coexists with the built-in numeric `gap-4`, which keeps Tailwind's own multiplier.
396
+
397
+ The whole matrix is emitted rather than a plausible subset. An undefined step produces no rule and
398
+ no error in either format, so a missing `md-gap-x-2xl` would be indistinguishable from a working
399
+ one.
400
+
401
+ In the css and bricks bundles these land in `vitops.utilities`, so `class="cluster gap-l"` beats
402
+ the pattern's own gap.
403
+
404
+ - 04a51d8: Icons: one semantic vocabulary across icon sets, with the bundle derived from your source.
405
+
406
+ Name icons by meaning — `<Icon name="menu" />` resolves to `fa7-solid:bars`, `ph:list` or
407
+ `lucide:menu` depending on the set you configure, so swapping sets is a config edit rather than a
408
+ find-and-replace. A name containing `:` still passes through untouched, which is the escape hatch
409
+ for a set-specific glyph.
410
+
411
+ **New `icons` option on `getvitops()`.** Configures the sets once per site and, under
412
+ `output: 'server'`, derives astro-icon's `include` map by scanning your source — the list most
413
+ projects end up maintaining by hand. On a static build no `include` is passed at all, because
414
+ astro-icon is already zero-config there and a list could only drop a glyph the scan couldn't see.
415
+ Names you declare that don't resolve fail the build; names only the scan found just warn; names
416
+ computed at runtime are reported with file and line so you can declare them.
417
+
418
+ **New `<Icon />` component**, and a real fix with it: `Popover`, `Details` and `Drawer` imported
419
+ `astro-icon/components` at module scope, so the _optional_ peer was resolved whether or not an icon
420
+ ever rendered — hard-failing anyone who hadn't installed it. Engines now load dynamically. The
421
+ per-component `iconResolver` prop still works but is deprecated in favour of the integration option.
422
+
423
+ **New SVG sprite** (`icons.sprite: true` in your site config) for consumers that can't run an icon
424
+ integration — Bricks/WordPress, EmDash renderers, plain HTML. `<use href="…/icons.svg#ph--list">`,
425
+ no JavaScript and no icon-API request. Every semantic name also gets a set-independent `icon-<name>`
426
+ alias, so sprite markup survives an icon-set change. WordPress gets `vitops_icon()`, a
427
+ `[vitops_icon]` shortcode and a **Vitops → Icon** Bricks element.
428
+
429
+ **New `vitops icons`** command: reports which icons your source uses, which names couldn't be
430
+ resolved, and which are computed at runtime; `--sprite` builds the sprite, `--json` for CI.
431
+
432
+ **Renamed, and completed to all four directions.** Chevrons and arrows are the
433
+ one family whose _meaning is_ its direction, and that direction is physical, not
434
+ logical — a chevron in a details marker points down in every writing mode. So they
435
+ are named for where they point: `expand-more`/`expand-less` →
436
+ `chevron-down`/`chevron-up`, and `arrow-forward`/`arrow-back` →
437
+ `arrow-right`/`arrow-left`. `chevron-left`/`chevron-right` keep their names.
438
+ `arrow-up`/`arrow-down` are new, so both families now cover all four directions.
439
+ `lightning` is new.
440
+
441
+ If you passed any of the old names to `<Icon />`, `resolveIcon` or an `icons`
442
+ config, update them. They fail loudly rather than silently: an unresolvable
443
+ declared name throws, and `vitops icons` reports scanned ones.
444
+
445
+ **Fixed three Font Awesome mappings that named no real glyph** and so rendered an
446
+ empty box: `login`/`logout` were mapped to `login`/`logout` (Font Awesome calls
447
+ them `right-to-bracket`/`right-from-bracket`) and `backup` to `backup`
448
+ (`cloud-arrow-up`). Every value in all four UI sets is now checked against the
449
+ installed collection.
450
+
451
+ **Phosphor (`ph`) joins the semantic map**, with all 83 names verified against the real icon set.
452
+ Phosphor keeps every weight in one collection and varies the name (`list`, `list-bold`), unlike Font
453
+ Awesome's per-weight collections, so `resolveIcon` and `generateIconInclude` gained a `weight`
454
+ option for sets shaped that way.
455
+
456
+ Fixes: `site.icons` was a closed object, so any icon collection not in its hand-written key list was
457
+ silently dropped during validation — your config passed and the icons never bundled. It accepts any
458
+ collection name now.
459
+
460
+ - bf453b0: A chroma-0 seed now produces an actual neutral instead of a pink one.
461
+
462
+ Beyond a ramp's outermost authored colour, chroma decays towards a small endpoint value (0.008
463
+ light / 0.015 dark) so the near-white and near-black ends keep a whisper of the hue. That was
464
+ written as a lerp _target_, and the interpolation factor reaches exactly 1 at steps 50 and 950 for
465
+ every anchor position — so the endpoints were those constants **unconditionally**. Seeds at chroma
466
+ 0.001, 0.002, 0.05 and 0.2 all produced a byte-identical `#f5f9fe` at step 50.
467
+
468
+ There was therefore no seed that yielded a plain neutral, and chroma 0 was the worst case: a true
469
+ achromatic colour has no hue (colorjs returns NaN), which collapses to 0 — red — so asking for grey
470
+ gave you `#fdf6f8`, a pink. A brand wanting plain white with grey panels had to discover `anchors`
471
+ to get there.
472
+
473
+ The endpoint is now a **ceiling** rather than a target, so a seed below it keeps its own chroma:
474
+
475
+ | seed chroma | step 50 before | step 50 after |
476
+ | ----------- | -------------- | ------------- |
477
+ | 0 | `#fdf6f8` 🩷 | `#f8f8f8` |
478
+ | 0.001 | `#f5f9fe` | `#f8f8f9` |
479
+ | 0.003 | `#f5f9fe` | `#f7f8fa` |
480
+ | 0.05 | `#f5f9fe` | `#f5f9fe` |
481
+
482
+ `seed: "#808080"` now gives a real grey ramp — `#f8f8f8` / `#eee` / `#808080` / `#2b2b2b` / `#181818`.
483
+
484
+ **No existing palette moves.** The ceiling only binds below itself, and every ordinary brand hue
485
+ sits above it; the repo's own `tokens.json` is byte-identical across this change.
486
+
487
+ - bf453b0: Reject palettes whose ramps don't darken from 50 to 950.
488
+
489
+ Anchors and `tones` are the one thing allowed off the shared lightness ladder — that is the point of
490
+ them. But nothing checked the result was still ordered, so an anchor could put a step _above_ the
491
+ one below it. Anchoring a navy at 600 gave `600 #2c3b4e` (L 0.348) sitting above `700 #4e5c6f`
492
+ (L 0.470): every hover from `bg-<role>-solid` to `-solid-bold` then got **lighter**, which is the
493
+ exact class of drift the fixed ladder was introduced to eliminate.
494
+
495
+ Nothing caught it. `ladderWarnings` compares each pinned step against _its own_ ladder rung, never
496
+ against its neighbours — so it warned about the deviation, called the ramp "Legal", and described
497
+ the wrong defect. And since its tolerance (0.03) is the same size as the ladder's own 50→100 gap,
498
+ two anchors could each sit within tolerance and still invert with no warning at all. The one test
499
+ covering monotonicity built its palette from anchor-free seeds, so it never exercised the path.
500
+
501
+ The build now fails, in the same place and for the same reason as the contrast contract: the order
502
+ is load-bearing (`snap` picks the nearest step by ladder lightness, the mode-stable solid family
503
+ hard-codes 600/700/800, the dark tables re-point steps on the same assumption), and an inversion is
504
+ always author-caused and therefore always actionable. The message names the pair, the consequence,
505
+ and where the colour you wrote actually belongs:
506
+
507
+ ```
508
+ colors.palette.navy: step 700 (#4e5c6f, L 0.470) is LIGHTER than step 600 (#2c3b4e, L 0.348).
509
+ A ramp must darken from 50 to 950 — inverted, a hover from `bg-<role>-solid` to `-solid-bold`
510
+ gets lighter, and the dark-mode tables re-point steps assuming the order.
511
+ #2c3b4e (pinned at 600) sits at L 0.348, nearest step 800.
512
+ ```
513
+
514
+ Sparse `tones` kits are compared over present steps only, and gamut-mapping noise is tolerated, so
515
+ neither reports a false inversion. `ladderWarnings` is unchanged and still a warning.
516
+
517
+ - bf453b0: `.split` gets a stacking convention, a reversal, and a ratio that actually holds.
518
+
519
+ **Fixed: a padded column silently broke the ratio.** `.split > *` was `flex: 1` — a zero basis plus
520
+ a grow factor — and `flex-grow` shares out only the _free_ space, which a child's padding is not
521
+ part of. So a padded column came out wider than its sibling by exactly its horizontal padding, and
522
+ `.split-1-2` quietly stopped being 1:2. Measured in Chrome: an equal `.split` 1000px wide with 40px
523
+ of padding a side on one child resolved 540/460, and `split-1-2` resolved 387/613. The ratio is now
524
+ a **flex basis**, which sizes the border box, so padding sits inside the share: 500/500 and 333/667.
525
+ Nothing to change in your markup — ratios that looked slightly off now land where they read.
526
+
527
+ Two consequences worth knowing. The basis applies only to a **two-child** split, because a ratio is
528
+ a pair contract (only `:first-child` and `:last-child` ever carried one) and with three or more
529
+ children a middle child would collapse to zero; those keep the previous behaviour. And the ratio
530
+ only holds on **border-box** children — a basis sizes the border box, and under content-box the
531
+ padding lands outside the share again, reproducing the exact defect. That is what the new global
532
+ border-box reset is for (see its own note); `.split > *` restates it so the pattern still holds if
533
+ you override the reset in a layer of your own.
534
+
535
+ **Fixed: long unbreakable content stretched a column.** `.split > *` now carries
536
+ `min-inline-size: 0`. A flex item's automatic minimum is its min-content size, so a URL, a code span
537
+ or a `<pre>` used to push its column past its share — and every consumer re-derived `min-w-0` to
538
+ stop it. It belongs to the pattern.
539
+
540
+ **Stacking is now a two-class idiom, using `flex-col` you already have.**
541
+
542
+ ```html
543
+ <div class="split flex-col md-split-1-2 gap-l"></div>
544
+ ```
545
+
546
+ Stacked below 48rem, 1:2 above. What happens when there isn't room for two columns was the question
547
+ `.split` never answered, so every project re-derived
548
+ `flex flex-col gap-… lg-flex-row lg-items-start lg-split`. The `sm-`/`md-`/`lg-`/`xl-` ratio classes
549
+ now assert `flex-direction: row`, which is what un-stacks the pair at the breakpoint. There is
550
+ deliberately **no** `.split-stack`: it would be `flex-col` under a second name.
551
+
552
+ `.flex-col` wins because `.split` is a **pattern** and every utility sits one cascade layer above
553
+ it — see the layering note in this release. `.md-split-1-2` then wins because a breakpoint-scoped
554
+ utility sorts after an unscoped one, which is stable in both formats. (`.split` itself now states
555
+ `flex-direction: row`, so a host that defaults an element to `column` — Bricks' block element —
556
+ can no longer stack it by accident, as long as that host's rule is itself layered.)
557
+
558
+ While stacked the ratio goes inert on its own and needs no reset: a percentage flex basis resolved
559
+ against an auto-height column container behaves as `content`. It does apply if you give the split a
560
+ definite `block-size`, which is a ratio in the block axis — what asking for one on a fixed-height
561
+ column means.
562
+
563
+ **New `.split-reverse`** (breakpoint-prefixable) — swaps the two panels. It is `order` on the first
564
+ child rather than `row-reverse`, so it reverses on **whichever axis the split is currently on**:
565
+ bare, it swaps the columns in a row _and_ the rows in a stack; scoped to a breakpoint
566
+ (`md-split-reverse`) it swaps only once there are two columns — put the media first in source and it
567
+ leads on mobile while sitting on the right at width. The ratio stays attached to the source-first
568
+ child, not to visual position.
569
+
570
+ > **Accessibility.** Reversing puts visual order out of step with DOM order, and focus order follows
571
+ > the DOM (WCAG 2.4.3 Focus Order) — so a keyboard user tabs through a reversed split in the order it
572
+ > is written, not the order it is seen. Put focusable content in **only one** of the two panels. The
573
+ > pattern declares `reading-flow: flex-visual`, which fixes this properly where it is supported;
574
+ > support is not yet broad enough to rely on, and an unknown property is inert, so treat it as an
575
+ > enhancement rather than a guarantee.
576
+
577
+ **New `.flex-row-reverse` / `.flex-col-reverse`** (breakpoint-prefixable) in the `css` and `bricks`
578
+ formats. These names were already in the list the `tailwind` format defers to Tailwind for, but
579
+ they were never defined here — so a mirrored row was inexpressible in framework classes in the two
580
+ formats with no fallback.
581
+
582
+ - bf453b0: `validate()` now rejects what the published JSON Schema already rejected.
583
+
584
+ Runtime validation and `schema.json` derive from the same zod schema, but they disagreed:
585
+ `toJSONSchema` emits `additionalProperties: false` in 16 places, while a plain `z.object` _strips_
586
+ unknown keys at runtime. An editor honouring `$schema` therefore flagged configs that
587
+ `vitops validate` called `✓ valid` — and the keys it waved through were being silently discarded at
588
+ generate time, which is exactly when you want to hear about them.
589
+
590
+ The worst case was a palette hue carrying both `seed` and `tones`: it validated, and the `tones`
591
+ were then ignored, so the authored brand colours simply never appeared.
592
+
593
+ **This is a breaking change for configs that were already wrong.** A config with a stray or
594
+ misspelled key now fails instead of silently dropping it. Both design systems this was tested
595
+ against (a live client site and the vitops site itself) validate unchanged.
596
+
597
+ Error messages got the attention too — a failed union previously reported a bare `Invalid input`
598
+ that named neither key:
599
+
600
+ ```
601
+ colors.palette.brand: a hue is either seeded (`seed`, with optional `anchors`) or fixed
602
+ (`tones`) — not both. Drop one: with both present the `tones` are ignored at generate time.
603
+ colors.palette.brand: unknown key "anchor". A hue takes `seed` + optional `anchors`, or `tones`.
604
+ spaceScale: unknown key "nope" — not part of the schema, so ignored at generate time.
605
+ ```
606
+
607
+ Note the site config (`resolveSiteConfig`) still strips unknown keys; aligning it is tracked
608
+ separately.
609
+
610
+ ### Patch Changes
611
+
612
+ - bf453b0: Fix a filled CTA's text turning dark on hover.
613
+
614
+ `<a class="cta">` and every `.cta-<role>` variant flipped from their `text-on-<role>` foreground to
615
+ the link colour while hovered — dark green text on a dark green button. The declaration was always
616
+ right; it was being out-cascaded.
617
+
618
+ Element patterns (`link`, `btn`) wrap their selector in `:where()` so any explicit class beats them
619
+ without `!important`. But the state pseudo was appended **outside** that wrapper, and a pseudo-class
620
+ outside `:where()` still carries weight — so `:where(a, .link):hover` was 0-1-0, exactly tying
621
+ `.cta-brand-primary`, and won on source order because `link` is emitted after `cta`. The pseudo now
622
+ goes inside: `:where(a:hover, .link:hover)` is a true 0-0-0 and loses to any class, which is what the
623
+ resting rule already promised.
624
+
625
+ This affects any element pattern layered under a class pattern — `<a class="cta">`, `<a class="badge">`,
626
+ `<button class="cta">` — in the `css` and `bricks` formats. Nothing else about the emitted rules
627
+ changes. If you worked around it with a hand-written `color` override on a CTA, you can drop it.
628
+
629
+ - bf453b0: Say plainly that `fonts` in `design-system.json` is stacks only and loads nothing.
630
+
631
+ The field's description was complete and self-consistent — "raw font stacks by name, emitted as
632
+ `--font-<name>` tokens" — and told you nothing about where the `@font-face` comes from. Following
633
+ it literally leads to installing a `@fontsource*` package and importing its CSS in a layout, which
634
+ renders correctly and silently gives up subsetting, preload, and the `size-adjust` /
635
+ `ascent-override` fallback metrics: a CLS regression that looks like a working setup.
636
+
637
+ The field is unchanged. What changed is that four surfaces now name the boundary and point at the
638
+ fix — declare the family in Astro's `fonts:` config and point the token at its `cssVariable`:
639
+ - the `fonts` description in the JSON Schema, and therefore `vitops docs authoring`
640
+ - `SKILL.md`, which previously never mentioned fonts at all
641
+ - the generated `tailwind.css` header and its `@theme` fonts comment
642
+ - the generated `type-tokens.css` header (css format)
643
+
644
+ - bf453b0: `vitops lint` now catches hand-written CSS that re-implements a framework primitive.
645
+
646
+ The existing linter finds classes that resolve to nothing — you named something and it isn't there.
647
+ This is the costlier inverse, where the code **works**: a centred container written by hand, a
648
+ two-column split behind a media query, a flex row. Nothing is broken, so nothing ever surfaces it,
649
+ and the design system quietly stops being where those decisions live.
650
+
651
+ Three rules to start, each requiring the combination that makes the intent unambiguous
652
+ (`margin-inline: auto` alone is not a centred track):
653
+
654
+ | Found | Suggested |
655
+ | ----------------------------------------------------------- | ------------------------------------------------------------------------- |
656
+ | `max-inline-size: var(--width-*)` + `margin-inline: auto` | `.centered`, widening a child with `breakout` / `spotlight` / `fullbleed` |
657
+ | `grid-template-columns: 1fr 2fr` inside a `min-width` query | `md-split-1-2` — or `@md:split-1-2` in the tailwind format |
658
+ | `display: flex` + `align-items: center` | `flex items-center`, or `.cluster-start` |
659
+
660
+ **Findings now carry a severity, and suggestions do not fail the command.** These are judgement
661
+ calls, and a reuse hint that broke CI on the day it shipped would be a worse defect than the drift it
662
+ reports. `--strict` promotes them for anyone who wants the ratchet. Everything the linter reported
663
+ before is an `error` and still exits 1.
664
+
665
+ `.css` files and `<style>` blocks are now read (the class linter only ever looked at markup). The
666
+ generated stylesheet is skipped by its `GENERATED … do not edit by hand` banner rather than by path —
667
+ the `css` format writes `styles.css` into `src/styles`, squarely inside what `--src` scans, and
668
+ linting it made the framework report `.split` as reinventing `.split`.
669
+
670
+ Two other gaps closed:
671
+ - **The format-spelling check ran in one direction only.** `md-split-1-2` in a tailwind project was
672
+ caught; `@md:split-1-2` in a css/bricks project was not, because variant stripping removed the
673
+ `@md:` regardless of format and left a bare class that matched nothing. The same silent no-op, in
674
+ the other direction, unreported. Stripping is format-aware now.
675
+ - **`grid-auto` and the whole `m-*` rhythm family were missing from the tailwind format entirely** —
676
+ not dropped via `TW_CLASH`, just never re-emitted when `layout.css` is skipped and a subset
677
+ re-emitted in its place. Unlike the `<bp>-` classes they have no Tailwind equivalent, and unlike a
678
+ misspelt class the linter cannot flag them, because they aren't anchored to your config. They emit
679
+ as `@utility` definitions now, keyed to the same `--rhythm-*` variables as the css format.
680
+
681
+ Run against this repo's own docs site, the rules found one real instance on the first pass; it is
682
+ fixed in the same change.
683
+
684
+ - bf453b0: Make `text-wrap` a stated decision on every type role, emit the `typography.headings` bindings in the tailwind format, and add the four `text-{wrap,nowrap,balance,pretty}` utilities.
685
+
686
+ `text-wrap: balance` for headings and `pretty` for copy belong to the **type role**, not to a
687
+ reset — the role already owns the property (`--<role>-tw`, editable in the live theme editor),
688
+ and `typography.headings` projects it onto bare `h1`/`body` so unclassed markup gets it with no
689
+ class at all. Three things stopped that working end to end.
690
+
691
+ **Omitting `text-wrap` was never "inherit".** Like `style`, `text-transform` and
692
+ `text-decoration`, it is emitted on every role at its identity value so that applying one role
693
+ class over another fully resets it. So a role that left it out emitted `text-wrap: wrap` and
694
+ **cancelled** the `pretty` it would otherwise inherit from a `body`-mapped role — captions and
695
+ footnotes quietly opted out of the thing they most benefit from. The behaviour is unchanged
696
+ (the reset is what makes role classes composable); what changed is that the schema description
697
+ now says so, and the shipped configs state the value on every role rather than leaving it to
698
+ the identity.
699
+
700
+ `vitops init` now scaffolds `balance` on `display`/`heading` and `pretty` on `body`. If you
701
+ already have a `design-system.json`, add `text-wrap` to each role deliberately — `balance` for
702
+ heading-like roles, `pretty` for copy, `wrap` for short single-line labels.
703
+
704
+ **The tailwind format dropped `typography.headings` entirely.** It emitted the
705
+ `@utility font-<role>` half of the typography layer and none of the bare-element bindings, so a
706
+ Tailwind consumer's `<h1>` and `<body>` carried no role styling at all — no family, no size, no
707
+ `text-wrap` — while the css and bricks formats styled them. They are now emitted into Tailwind's
708
+ `base` layer, which keeps `font-<role>` and the patterns able to override them.
709
+
710
+ **This visibly changes existing tailwind sites.** Elements named in your `typography.headings`
711
+ map start picking up their role's typography, which for most projects is the styling they were
712
+ missing — but if you have been compensating with your own `h1`/`body` rules, they now stack.
713
+ Your own unlayered CSS still wins; utilities layered by Tailwind will not. Either drop the
714
+ compensating rules or remove the entry from `typography.headings`.
715
+
716
+ **New utilities** `text-wrap`, `text-nowrap`, `text-balance`, `text-pretty` (css/bricks only —
717
+ Tailwind ships these natively, so they are in `TW_CLASH` and the tailwind bundle drops our
718
+ copies). They are the per-element escape hatch for markup that carries no role class, such as a
719
+ Bricks-authored heading.
720
+
721
+ - bf453b0: Warn on unrecognised `typography.roles` keys, and stop the schema documenting two that never worked.
722
+
723
+ The role key set is closed, and an unrecognised key is **dropped** — the role still renders, just
724
+ without the declaration you asked for. The schema description made that worse in two directions at
725
+ once: it advertised `transform` and `decoration`, when the generator only accepts `text-transform`
726
+ and `text-decoration`, and it claimed unknown keys were "passed through" when they are discarded. So
727
+ the two documented short forms were exactly the two that silently did nothing. In one case that
728
+ shipped title-case navigation to production across two deploys.
729
+
730
+ The behaviour is unchanged — unknown keys are still ignored, because `transform` is a real CSS
731
+ property and emitting it would break layout rather than do nothing. What changed:
732
+ - the generator now warns per role and key, naming the intended spelling where there is an obvious
733
+ one (`transform` → `text-transform`, `letter-spacing` → `tracking`, `font-size` → `size`, …) and
734
+ otherwise listing the recognised set;
735
+ - the schema description names the real keys and says unrecognised ones are ignored, which
736
+ propagates to `vitops docs authoring` and to editor hovers.
737
+
738
+ Recognised keys, for reference: `family`, `size`, `weight`, `style`, `line-height`, `tracking`,
739
+ `text-transform`, `text-decoration`, `text-wrap`, `color`.
740
+
741
+ - Updated dependencies [04a51d8]
742
+ - Updated dependencies [bf453b0]
743
+ - Updated dependencies [bf453b0]
744
+ - @getvitops/utils@2.0.0
745
+
3
746
  ## 1.0.0
4
747
 
5
748
  ### Major Changes