@getvitops/astro 0.9.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,699 @@
1
1
  # @getvitops/astro
2
2
 
3
+ ## 2.0.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4756788: Add `<Analytics />` and a general-purpose consent gate.
8
+
9
+ `getvitops({ analytics })` configures Google Analytics 4, Microsoft Clarity, Matomo and Plausible;
10
+ `<Analytics />` emits their tags. Nothing touches the critical path — `strategy` defaults to `'idle'`,
11
+ which loads every tag after `load` on an idle callback (`'async'` and `'interaction'` are the other
12
+ options), and no `preconnect` is emitted, since warming a third-party connection during parse is the
13
+ cost `idle` exists to avoid.
14
+
15
+ `getvitops({ consent })` adds the gate: `@getvitops/core/consent`, a 2.3 KB gzipped Lit-free bundle,
16
+ plus `<CookieConsent />`.
17
+
18
+ ```js
19
+ vitops({
20
+ analytics: { googleAnalytics: 'G-XXXXXXXXXX', plausible: 'acme.com' },
21
+ consent: { policyUrl: '/legal/cookies' },
22
+ });
23
+ ```
24
+
25
+ **Consent is not an analytics feature.** The gate is general — mark anything
26
+ `data-consent="<category>"` and it waits on the same choice, so A/B assignment, personalisation and
27
+ third-party embeds use it too, and a site can enable the banner with no analytics at all. Categories
28
+ are `necessary` / `analytics` / `marketing` / `preferences`, and `window.vitopsConsent` plus a
29
+ `vitops:consent` event on `document` are how your own code reads the answer.
30
+
31
+ **Which category a provider needs is derived, not declared.** It follows from whether that provider
32
+ sets cookies, which follows from its own config: Matomo runs cookieless by default (`disableCookies`)
33
+ and so needs no banner; `cookies: true` opts in and moves it behind `analytics`. You can't mark
34
+ Google Analytics `necessary` to skip the banner — but you can pick a genuinely cookieless provider.
35
+
36
+ Gated tags render as `<script type="text/plain">` with the URL on `data-src`, so an undecided or
37
+ declining visitor's page issues **no third-party request at all**. For Google Analytics that is basic
38
+ consent mode rather than Consent Mode v2 advanced: nothing reaches Google until the visitor accepts.
39
+ Clarity additionally receives `clarity('consentv2', …)`, because Microsoft enforces the signal
40
+ separately for EEA/UK/CH traffic. Nothing is stored until a choice is made — the banner can't be the
41
+ thing that needs consent — and revoking clears the provider's cookies and reloads, because an
42
+ already-executing tracker can't be unloaded any other way.
43
+
44
+ The banner is shown in the top layer via `popover="manual"`, like `.tooltip`: a plain fixed banner
45
+ resolves against the nearest containing block, and `body { container-type: inline-size }` — ordinary
46
+ in a framework whose breakpoints are container queries — would otherwise trap it mid-page.
47
+
48
+ **The site config gained `analytics.clarityId` and `analytics.matomo`**, so `vitops legal` discloses
49
+ them. Clarity and Matomo join the processor table with their real cookie names, cookieless Matomo
50
+ asserts positively that it sets none, and a Clarity site's privacy policy now describes session
51
+ replay rather than filing it under page-view analytics. Configure `legal` alongside `analytics` and
52
+ the Astro integration cross-checks the two, naming any provider you'd otherwise run without
53
+ disclosing; it also warns when a cookie-setting provider has no `consent` gate.
54
+
55
+ Existing configs are unaffected — both options are off unless provided. One behaviour change if you
56
+ use the ad-conversion tracking script: `packages/astro/src/scripts/tracking.ts` now waits for
57
+ `marketing` consent before writing its 90-day `_ac` click-ID cookie, when the gate is present. With
58
+ no gate on the page it behaves exactly as before.
59
+
60
+ - bf453b0: Animation families that never actually animated, and a driver that fired before you could see it.
61
+
62
+ Each of these looked correct in source and failed somewhere else — in the bundler, in hit-testing,
63
+ or in the difference between a time-based and a progress-based timeline. Nothing here needs a
64
+ markup change.
65
+
66
+ **Changed: entrances are now timed off the element's midpoint.** Every driver used to key off a
67
+ fraction of the element's _own height_, which meant the same class behaved differently on a 4rem
68
+ card and a full-bleed section — and on small elements it was over before they appeared
69
+ (`entry 20%` is about 17px of scroll for a 5.5rem tile). Motion now starts once the element's
70
+ **midpoint is 10% of the viewport in**, and a one-shot entrance completes at **25%**. This applies
71
+ to `animate-view` and to the `.is-active` observer that drives `animate-trigger` and the
72
+ `active-<fx>` transition variants, so everything on a page starts at the same moment on screen.
73
+
74
+ The pivot is `entry 50%` — the one point that means "midpoint on the viewport edge" for an element
75
+ of any height — plus a viewport length. Shift the window with `--anim-start` / `--anim-end`, or
76
+ replace it with `--anim-range`. `--stagger-range-step` is now a viewport length (`5vh`) to match.
77
+
78
+ **Fixed: every journey ran on a truncated range.** The generator emitted `animation-range: entry
79
+ exit`; lightningcss parsed the end of that shorthand as `exit 0%` rather than the spec's `exit
80
+ 100%`, so the bundle shipped `entry exit 0%`. Journeys reach their `100%` keyframe — the hidden
81
+ `from` state — as the element hits the top of the viewport, which is to say they faded out while
82
+ still fully on screen. Journeys now run `entry calc(50% + 10vh) → exit 100%`: they start on the same
83
+ midpoint pivot as everything else and keep the full entry → hold → exit arc, so the hold sits in the
84
+ middle of the crossing. `animation-effects.test.ts` asserts on the **bundled** css, because the
85
+ emitter was right the whole time.
86
+
87
+ **Fixed: `slide-journey` had no distance to travel.** `animations.journeys.base.slide` was `{}` in
88
+ both `defaultConfig()` and the shipped example, so the keyframe animated `translate: 0 → 0`. It now
89
+ declares `translate-y-from: var(--slide-distance, 2rem)` — the same var the `slide-up` effect uses,
90
+ so one knob tunes both. If your own config has an empty `slide` base, add the same line.
91
+
92
+ **Fixed: `reveal-*` on hover was unreachable.** A `hover-reveal-left` element rests at `clip-path:
93
+ inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting — so it had zero
94
+ hittable area and could never receive the hover that would reveal it. The state variants now match
95
+ the element **or its direct parent**, mirroring what `animation.css` already did for the trigger
96
+ driver (`:is(.is-active, [data-active]) > .animate-trigger`):
97
+
98
+ ```css
99
+ .hover-<fx>:hover, :hover > .hover-<fx> { … }
100
+ .focus-<fx>:focus-visible, :focus-within > .focus-<fx> { … }
101
+ ```
102
+
103
+ Specificity is unchanged (0-2-0), so nothing reorders. **Behaviour change:** a `hover-<fx>` element
104
+ that is a direct child of a hovered element now flips with its parent. Wrap it in an intermediate
105
+ element if you need the old element-only behaviour.
106
+
107
+ **Fixed: `.stagger` did nothing on a scroll-driven timeline.** It offsets children with
108
+ `animation-delay`, which is time-based and is ignored outright on a `view()` / `scroll()` timeline,
109
+ so `.stagger > .animate-view` arrived all at once. It now also offsets each child's view
110
+ `animation-range` by the same index, so one class works under both driver families — tune the
111
+ scroll-driven step with `--stagger-range-step` (default `5vh`). Journeys declare their own range and
112
+ opt out by construction.
113
+
114
+ Two smaller faults found alongside it: `@supports (--x: sibling-index())` is **always true** — any
115
+ token stream is a valid custom-property value — so the guard around `sibling-index()` guarded
116
+ nothing in both `.stagger` and `.subgrid`'s row index, and on an engine without support the
117
+ declaration went invalid rather than being skipped. Both now test a real property. And the CSS path
118
+ was 1-based while the JS fallback wrote 0-based, so the two disagreed by one step wherever both ran.
119
+
120
+ **Fixed: the pre-paint `<html>` class script was missing entirely.** `<Head />` now emits it again,
121
+ outside the `webComponents` block since it gates stylesheet behaviour rather than the element
122
+ runtime. It does two things `animation.css` depends on and that three other files documented as
123
+ already shipping:
124
+ - `no-js` → `js`, so `.animate-trigger` is paused only where JS can un-pause it;
125
+ - `.no-scroll-timeline` when `animation-timeline: view()` is unsupported, which is what cancels
126
+ `.animate-view` / `.animate-scroll`. Scroll-driven animations are deliberately **not** polyfilled,
127
+ so without this flag those elements sit at their `from` keyframe — `opacity: 0`, i.e. invisible
128
+ content — on any engine without support. It had been dropped in the move to publishable packages,
129
+ leaving the cancel rule as dead code.
130
+
131
+ **New: `hover-size-grow` and friends exist.** The layout effects (`size-grow`, `size-shrink`) were
132
+ the one family with no state variants, on the grounds that `.transition` doesn't cover `height`.
133
+ That wasn't a platform limit — `height: 0 → auto` just needs `interpolate-size: allow-keywords`,
134
+ which the framework already sets on `:root`, and which the `layout` **keyframe** depended on
135
+ equally. So the exclusion bought no portability; it only made `hover-size-grow` a class the docs
136
+ advertised and the stylesheet didn't define. `.transition` now declares `height` inside an
137
+ `@supports (interpolate-size: allow-keywords)` block, and the generator emits the full
138
+ `hover-`/`focus-`/`active-` set for layout effects, carrying each effect's own `overflow: clip` so a
139
+ collapsed box hides its content instead of spilling it. Where `interpolate-size` is missing, the
140
+ height simply doesn't transition — the same degradation the keyframe path has. (For the record:
141
+ `transition-behavior: allow-discrete` is _not_ the tool here — it only lets genuinely discrete
142
+ properties transition, and would snap at the midpoint.)
143
+
144
+ `.transition` switched from the `transition` shorthand to `transition-property` +
145
+ `transition-duration` + `transition-timing-function`, so the gated block can append `height` without
146
+ restating the list. If you override `.transition`'s timing, override the longhands.
147
+
148
+ The generated class reference (`vitops docs classes`) was the upstream source of that mismatch — it
149
+ listed every effect under "with the state prefixes above", including the layout ones. It now states
150
+ which stage needs the feature gate, when each driver plays, and how `stagger` composes with both.
151
+
152
+ - bf453b0: Anywhere the toolchain takes a config, that file may now be a `design-system.json` **or** the larger
153
+ site config that embeds one.
154
+
155
+ If you keep your tokens inside a `company.json` / `site.json`, you had to maintain a second file for
156
+ the tooling's sake — or duplicate your whole token set, because a site config must carry a complete
157
+ `designSystem`. Now you point the same option at the file you already have:
158
+
159
+ ```js
160
+ // astro.config.mjs
161
+ getvitops({ css: { input: 'company.json', format: 'css' } });
162
+ ```
163
+
164
+ ```
165
+ vitops generate --input company.json --format css --out dist
166
+ vitops lint --input company.json --src src
167
+ ```
168
+
169
+ The design system is taken from `designSystem.themes[theme]` with its `extends` chain resolved —
170
+ `defaultTheme`, else `default`, else whatever `--theme` / the `theme` option names. Nothing changes
171
+ for a plain `design-system.json`; the two are told apart by shape, not by filename, so you can call
172
+ the file anything.
173
+
174
+ **A site config also supplies the site-level facts generation reads**, so the path is declared once
175
+ rather than per option:
176
+ - `designSystem.defaultColorScheme: "system"` — emits the `prefers-color-scheme` block, which is
177
+ what makes `<color-scheme-toggle>`'s System position do anything.
178
+ - `legal.*.enabled` — renders the documents. `legal: {}` is now the whole declaration; `legal.input`
179
+ is optional.
180
+ - `icons.sprite` — builds `icons.svg`.
181
+ - `fonts` — `fonts: true` reads the families from it.
182
+
183
+ Each of those is already gated on a field in that config, so nothing new appears unless the config
184
+ asks for it. An explicit `site` option still wins when the two are genuinely different files.
185
+
186
+ Also:
187
+ - **`vitops validate` routes on the file's shape.** Pointed at a site config it used to report a
188
+ single `unrecognized_keys` for `designSystem` and nothing about the file's actual contents — a
189
+ wrong answer that reads like a right one. It now validates a site config as one, including the
190
+ cross-field integrity JSON Schema can't express, and checks every theme resolves to a complete
191
+ design system (`validateSite` only checked that the `extends` chain resolved, not what it resolved
192
+ to).
193
+ - **`generate()` gained `theme` and `siteEnv`.** The A/B variant for `siteEnv` is applied before the
194
+ theme is selected, so a variant can override tokens.
195
+ - **The theme editor's Save to source follows the design system into a site config.** Its patch is
196
+ design-system-relative, so the dev server merges into that subtree and writes only the surrounding
197
+ file whole. It locates the subtree in the raw on-disk object rather than the normalised one, so an
198
+ author who wrote either `designSystem` shorthand still gets their own keys edited.
199
+
200
+ - bf453b0: **Breaking (site config):** `designSystem` is now an object, and the toggle's "System" position works.
201
+
202
+ `<color-scheme-toggle>` has always shipped three segments, and "System" did nothing. It removes the
203
+ theme attribute, and with no `prefers-color-scheme` block in the generated CSS the page fell straight
204
+ through to light on every machine — so a third of the control was inert, and a no-JS page could never
205
+ follow the OS at all.
206
+
207
+ The fix is a second, opt-in copy of the dark delta under
208
+ `@media (prefers-color-scheme: dark)`, scoped to "no explicit choice has been made". An explicit
209
+ light choice still wins over a dark OS. It costs **+303 B gzipped** on the colour layer — the block
210
+ repeats only the tokens whose dark value differs, and two identical runs of text compress to almost
211
+ nothing. It is opt-in because switching it on visibly flips an existing site dark for dark-OS
212
+ visitors.
213
+
214
+ Turning it on is a site-level fact, which needed somewhere to live:
215
+
216
+ ```jsonc
217
+ "designSystem": {
218
+ "themes": { "default": { … }, "elegant": { "extends": "default", … } },
219
+ "defaultTheme": "default",
220
+ "defaultColorScheme": "system" // 'light' | 'dark' | 'system'
221
+ }
222
+ ```
223
+
224
+ **What changed.** `designSystem` was a bare map of themeName → design system, so _every_ key was a
225
+ theme name and there was nowhere to put a system-wide field without it colliding with a theme of that
226
+ name. The map moves under `themes`, and `defaultTheme` + `defaultColorScheme` move inside the block.
227
+ `respectSystemPreference` is gone — `defaultColorScheme: "system"` says the same thing, and the two
228
+ were always read together, so the incoherent combination is no longer expressible.
229
+
230
+ **Migration is automatic at runtime.** `resolveSiteConfig` accepts all three spellings — the
231
+ canonical shape, a bare theme map (the old schema), and a bare design system written inline. But
232
+ `site.schema.json` is published to a stable URL, so an editor pinned to `$schema` will flag the old
233
+ shape; update it when convenient.
234
+
235
+ One behavioural change worth knowing: shorthand normalisation now runs **before** the A/B variant
236
+ merge, not after. Previously an `abTesting` override's key path depended on which shorthand the base
237
+ config happened to use, so the same patch landed in different places in two otherwise-equivalent
238
+ configs. Overrides now always address `designSystem.themes.<name>`.
239
+
240
+ Also: `getvitops({ site: { input } })` gives the Astro integration one place to name the site config
241
+ — `legal`, `fonts` and the colour scheme all read from it — and `css.systemColorScheme` sets the
242
+ appearance directly for consumers who have no site config.
243
+
244
+ Light/dark remains **derived**, not a theme: `functionalRole()` builds both appearances from one ramp,
245
+ which is what lets the contrast contract check both at build time and what gives every consumer a
246
+ working dark mode without authoring one. `themes` is for authored variants.
247
+
248
+ - bf453b0: `getvitops({ fonts })` — load webfonts through Astro's Fonts API instead of hand-rolling it.
249
+
250
+ The design system names fonts and loads none of them, and there was no seam for the loading half —
251
+ so the only available path was installing a `@fontsource*` package and importing its CSS, which
252
+ renders correctly while silently giving up subsetting, preload, and the `size-adjust` /
253
+ `ascent-override` fallback metrics. That is a CLS regression that looks like a working setup. This
254
+ is the counterpart to `vitopsHosting()`: declare the family, let the integration wire it.
255
+
256
+ ```js
257
+ getvitops({
258
+ fonts: [
259
+ {
260
+ name: 'League Spartan',
261
+ provider: 'fontsource',
262
+ cssVariable: '--font-league-spartan',
263
+ weights: ['100 900'],
264
+ subsets: ['latin'],
265
+ preload: true,
266
+ },
267
+ ],
268
+ });
269
+ ```
270
+
271
+ ```jsonc
272
+ // design-system.json — the token points at the variable, not at a literal stack
273
+ "fonts": { "display": "var(--font-league-spartan), sans-serif" }
274
+ ```
275
+
276
+ `fonts` also takes a **string**: the path to a site config, whose `fonts` array has carried exactly
277
+ these declarations (provider / weights / subsets / preload) since it was written — nothing had ever
278
+ read it. Or `{ input, families, siteEnv }` for both.
279
+
280
+ `<Head />` now emits `<Font />` for each declared family, with `preload` driven by the declaration.
281
+ That half is not optional: Astro's `fonts:` config resolves the files, but `<Font />` is what puts
282
+ the `@font-face` on the page, so a declaration without `<Head />` in your layout loads nothing.
283
+
284
+ Notes:
285
+ - Independent of `css` — the wiring runs in `astro:config:setup`, not the Vite plugin.
286
+ - Only families declared here get a `<Font />`. One from your own `astro:config` `fonts:` array or
287
+ from another integration is left alone, because `<Font />` throws on a `cssVariable` Astro cannot
288
+ resolve. Astro concatenates the arrays, so the three coexist; two entries claiming one variable
289
+ is the collision that matters, and it now throws (within our set) or warns (against yours)
290
+ instead of silently dropping a family.
291
+ - `provider: 'adobe'` throws with instructions: `fontProviders.adobe({ id })` needs a key from the
292
+ environment, and a JSON declaration has nowhere to hold one.
293
+ - New exports from `@getvitops/generator`: `SiteFontSchema` and the `SiteFont` type.
294
+
295
+ - 04a51d8: Icons: one semantic vocabulary across icon sets, with the bundle derived from your source.
296
+
297
+ Name icons by meaning — `<Icon name="menu" />` resolves to `fa7-solid:bars`, `ph:list` or
298
+ `lucide:menu` depending on the set you configure, so swapping sets is a config edit rather than a
299
+ find-and-replace. A name containing `:` still passes through untouched, which is the escape hatch
300
+ for a set-specific glyph.
301
+
302
+ **New `icons` option on `getvitops()`.** Configures the sets once per site and, under
303
+ `output: 'server'`, derives astro-icon's `include` map by scanning your source — the list most
304
+ projects end up maintaining by hand. On a static build no `include` is passed at all, because
305
+ astro-icon is already zero-config there and a list could only drop a glyph the scan couldn't see.
306
+ Names you declare that don't resolve fail the build; names only the scan found just warn; names
307
+ computed at runtime are reported with file and line so you can declare them.
308
+
309
+ **New `<Icon />` component**, and a real fix with it: `Popover`, `Details` and `Drawer` imported
310
+ `astro-icon/components` at module scope, so the _optional_ peer was resolved whether or not an icon
311
+ ever rendered — hard-failing anyone who hadn't installed it. Engines now load dynamically. The
312
+ per-component `iconResolver` prop still works but is deprecated in favour of the integration option.
313
+
314
+ **New SVG sprite** (`icons.sprite: true` in your site config) for consumers that can't run an icon
315
+ integration — Bricks/WordPress, EmDash renderers, plain HTML. `<use href="…/icons.svg#ph--list">`,
316
+ no JavaScript and no icon-API request. Every semantic name also gets a set-independent `icon-<name>`
317
+ alias, so sprite markup survives an icon-set change. WordPress gets `vitops_icon()`, a
318
+ `[vitops_icon]` shortcode and a **Vitops → Icon** Bricks element.
319
+
320
+ **New `vitops icons`** command: reports which icons your source uses, which names couldn't be
321
+ resolved, and which are computed at runtime; `--sprite` builds the sprite, `--json` for CI.
322
+
323
+ **Renamed, and completed to all four directions.** Chevrons and arrows are the
324
+ one family whose _meaning is_ its direction, and that direction is physical, not
325
+ logical — a chevron in a details marker points down in every writing mode. So they
326
+ are named for where they point: `expand-more`/`expand-less` →
327
+ `chevron-down`/`chevron-up`, and `arrow-forward`/`arrow-back` →
328
+ `arrow-right`/`arrow-left`. `chevron-left`/`chevron-right` keep their names.
329
+ `arrow-up`/`arrow-down` are new, so both families now cover all four directions.
330
+ `lightning` is new.
331
+
332
+ If you passed any of the old names to `<Icon />`, `resolveIcon` or an `icons`
333
+ config, update them. They fail loudly rather than silently: an unresolvable
334
+ declared name throws, and `vitops icons` reports scanned ones.
335
+
336
+ **Fixed three Font Awesome mappings that named no real glyph** and so rendered an
337
+ empty box: `login`/`logout` were mapped to `login`/`logout` (Font Awesome calls
338
+ them `right-to-bracket`/`right-from-bracket`) and `backup` to `backup`
339
+ (`cloud-arrow-up`). Every value in all four UI sets is now checked against the
340
+ installed collection.
341
+
342
+ **Phosphor (`ph`) joins the semantic map**, with all 83 names verified against the real icon set.
343
+ Phosphor keeps every weight in one collection and varies the name (`list`, `list-bold`), unlike Font
344
+ Awesome's per-weight collections, so `resolveIcon` and `generateIconInclude` gained a `weight`
345
+ option for sets shaped that way.
346
+
347
+ Fixes: `site.icons` was a closed object, so any icon collection not in its hand-written key list was
348
+ silently dropped during validation — your config passed and the icons never bundled. It accepts any
349
+ collection name now.
350
+
351
+ - bf453b0: Detect JavaScript and scroll-driven timelines in CSS, so no-JS visitors stop getting invisible
352
+ content.
353
+
354
+ `animation.css` gated two rules on classes that had to be put on `<html>`: `no-js`, which the
355
+ framework expected an author to write into their own markup, and `.no-scroll-timeline`, which an
356
+ inline `<head>` snippet was supposed to set. **That snippet never shipped.** Three source comments
357
+ described it and nothing implemented it, so on every Astro and Bricks site the `:root:not(.no-js)`
358
+ gate matched unconditionally and `.animate-trigger` stayed `animation-play-state: paused` with no
359
+ `IntersectionObserver` coming to release it. An entrance animation that never runs is `opacity: 0`.
360
+
361
+ Both gates are now platform queries:
362
+
363
+ ```css
364
+ @media (scripting: enabled) {
365
+ .animate-trigger {
366
+ animation-play-state: paused;
367
+ }
368
+ /* …released by .is-active */
369
+ }
370
+
371
+ @supports not (animation-timeline: view()) {
372
+ :where(.animate-view, .animate-scroll) {
373
+ animation: none;
374
+ }
375
+ }
376
+ ```
377
+
378
+ Nothing to install, nothing to remember, and correct for consumers the old approach could not reach —
379
+ a Bricks site, or anyone rendering their own `<head>`. It also fails the right way round: an engine
380
+ that doesn't know either feature drops the block and the animation simply runs, costing an
381
+ enhancement rather than hiding the page.
382
+
383
+ **Nothing to do to adopt this.** Drop `class="no-js"` from your `<html>` and any script that removes
384
+ it if you like — both are inert now, not harmful. `<Head />` no longer emits a class-flipping script.
385
+
386
+ Support is baseline: `scripting` shipped in Firefox 113, Safari 17 and Chrome 120, all in 2023.
387
+
388
+ - bf453b0: Maskable favicons are now opaque, so they stop rendering as a logo in a black box.
389
+
390
+ `icon-mask.png` is declared `purpose: "maskable"` and `apple-touch-icon.png` is linked on every
391
+ page. Both sit a deliberately-inset logo on a larger canvas — that inset **is** the maskable safe
392
+ zone and was always correct — but the canvas was filled with `alpha: 0`. From any transparent
393
+ source that left 36% of `icon-mask.png` and 40% of `apple-touch-icon.png` transparent, on two files
394
+ whose entire contract is full bleed: the OS crops them to its own shape and composites the rest onto
395
+ whatever it likes, usually black. iOS discards alpha on the apple-touch-icon outright.
396
+
397
+ `backgroundColor` was already the obvious input for this and already plumbed — as far as the web
398
+ manifest, and no further. So the raster and the manifest disagreed about the very colour meant to
399
+ sit behind the icon.
400
+
401
+ Now:
402
+ - those two outputs are composited onto `backgroundColor`, defaulting to `#ffffff` — the same
403
+ default the manifest's `background_color` already used;
404
+ - `favicon.svg`, `icon-{16,32,192,512}.png` and `favicon.ico` keep the source's transparency, since
405
+ none of them is maskable;
406
+ - a transparent source with no `backgroundColor` set now warns, rather than silently inheriting
407
+ white — a dark logo would lose against it;
408
+ - `icon-192`/`icon-512` declare `purpose: "any"` explicitly. Omitting it was legal (unset means
409
+ "any"), but with a maskable present and nothing claiming "any", some launchers picked the maskable
410
+ — the one with the safe-zone inset — for slots that wanted a plain icon.
411
+
412
+ `vitops favicon` gained `--background <hex>`; it previously had no way to set this at all.
413
+ `getvitops({ favicon })` and the Vite plugin now forward the option they were already accepting.
414
+
415
+ - feae1a3: `<Seo />`: `titleTemplate` now applies to `<title>` only — `og:title` and `twitter:title` get the
416
+ untemplated page title.
417
+
418
+ A social card is self-contained and already shows `og:site_name` and the domain, so the templated
419
+ value put the brand on screen twice: `og:title="Installation · Acme"` sitting directly above
420
+ `og:site_name="Acme"`. `<title>` keeps the suffix, because a browser tab or a search result has
421
+ nothing else to disambiguate it.
422
+
423
+ ```html
424
+ <!-- before -->
425
+ <title>Installation · Acme</title>
426
+ <meta property="og:title" content="Installation · Acme" />
427
+ <meta property="og:site_name" content="Acme" />
428
+
429
+ <!-- after -->
430
+ <title>Installation · Acme</title>
431
+ <meta property="og:title" content="Installation" />
432
+ <meta property="og:site_name" content="Acme" />
433
+ ```
434
+
435
+ Adds an `ogTitle` prop for pages that want a genuinely different social headline, and exposes the
436
+ resolved value as `socialTitle` on `resolveSeo()`'s return. Nothing changes for sites that don't set
437
+ `titleTemplate` — the two values are identical there.
438
+
439
+ ### Patch Changes
440
+
441
+ - bf453b0: Fixed: a site on `css.format: 'css'` failed to build because of Tailwind.
442
+
443
+ `getvitops()` loads `@tailwindcss/vite` lazily, so a project that never sets `css.format:
444
+ 'tailwind'` should never touch it. But the specifier was a literal, which makes it statically
445
+ analysable — so the bundler **followed** the import regardless of the branch ever running, down
446
+ through `@tailwindcss/node` to `@tailwindcss/oxide`'s native `.node` binding, which it cannot parse.
447
+ The build died on `[UNLOADABLE_DEPENDENCY] … stream did not contain valid UTF-8`, naming a package
448
+ the project does not use and never asked for.
449
+
450
+ The specifier now goes through a constant with `/* @vite-ignore */`, matching how the other
451
+ optional peers in that file (`astro-icon`, `astro-iconset`) were already loaded. Optional has to
452
+ mean optional to the bundler too.
453
+
454
+ - Updated dependencies [4756788]
455
+ - Updated dependencies [bf453b0]
456
+ - Updated dependencies [bf453b0]
457
+ - Updated dependencies [bf453b0]
458
+ - Updated dependencies [bf453b0]
459
+ - Updated dependencies [bf453b0]
460
+ - Updated dependencies [bf453b0]
461
+ - Updated dependencies [bf453b0]
462
+ - Updated dependencies [bf453b0]
463
+ - Updated dependencies [04a51d8]
464
+ - Updated dependencies [bf453b0]
465
+ - Updated dependencies [bf453b0]
466
+ - Updated dependencies [bf453b0]
467
+ - Updated dependencies [bf453b0]
468
+ - Updated dependencies [bf453b0]
469
+ - Updated dependencies [bf453b0]
470
+ - Updated dependencies [bf453b0]
471
+ - Updated dependencies [bf453b0]
472
+ - Updated dependencies [bf453b0]
473
+ - Updated dependencies [bf453b0]
474
+ - @getvitops/generator@2.0.0
475
+ - @getvitops/core@2.0.0
476
+ - @getvitops/vite@2.0.0
477
+ - @getvitops/utils@2.0.0
478
+
479
+ ## 1.0.0
480
+
481
+ ### Minor Changes
482
+
483
+ - fd1a35b: Add `<Seo />` — page metadata for non-EmDash sites: `<title>`, description, canonical, Open Graph,
484
+ Twitter cards, robots, `article:*`, `hreflang` and verification tokens.
485
+
486
+ Site-level defaults go in the integration; pages pass only what differs.
487
+
488
+ ```js
489
+ // astro.config.mjs
490
+ vitops({
491
+ seo: {
492
+ siteName: 'Acme',
493
+ titleTemplate: '%s · Acme',
494
+ defaultDescription: 'We make the thing.',
495
+ openGraph: {
496
+ locale: 'en_CA',
497
+ image: { url: '/og.png', alt: 'Acme', width: 1200, height: 630 },
498
+ },
499
+ twitter: { site: '@acme' },
500
+ },
501
+ });
502
+ ```
503
+
504
+ ```astro
505
+ ---
506
+ import Seo from '@getvitops/astro/Seo.astro';
507
+ ---
508
+ <Seo title={title} description={description} image={cover} />
509
+ ```
510
+
511
+ **`<Seo />` owns `<title>` and `<meta name="description">` — remove them from your layout when you
512
+ adopt it.** It already computes the resolved title for `og:title`/`twitter:title`, and emitting those
513
+ in one place while the layout emits `<title>` in another is how the two drift apart. `<Head />` is
514
+ unaffected and still handles favicons, theme-color and the web-component runtime; use both.
515
+
516
+ Notable behaviour:
517
+ - `titleTemplate` is skipped when a page's title already equals `siteName`, so a homepage titled
518
+ "Acme" emits `Acme` rather than `Acme · Acme`. It never applies to `defaultTitle`.
519
+ - Canonical, `og:url` and relative `og:image` values need the `site` astro.config option. Without it
520
+ they're omitted rather than derived from the request URL — a canonical built from a dev or preview
521
+ origin can de-index you — and the integration warns at build time. Absolute image URLs still work.
522
+ - `robots` is omitted unless it says something; `index, follow` is what crawlers already assume. Use
523
+ `noindex`/`nofollow`/`noarchive`/`nocache`/`robotsExtras` per page, `robots` for a full override, or
524
+ `seo.robots` site-wide.
525
+ - `twitter:card` upgrades to `summary_large_image` whenever an image resolves.
526
+ - `hreflang` alternates are explicit only — pass `alternates`, including the current page. Nothing is
527
+ inferred from a locale list.
528
+ - No JSON-LD. The `./schemas/*` components take entity data and compose alongside it.
529
+
530
+ **On an EmDash site use `<EmDashHead>` instead** — it emits the same tags from the CMS, and rendering
531
+ both duplicates every one of them. The integration warns if `seo` is configured alongside `emdash()`.
532
+
533
+ The merge logic ships as the pure `resolveSeo(defaults, props, ctx)` if you need to drive it yourself.
534
+
535
+ - 20252c2: Add an opt-in `sitemap` option to `getvitops()`, and link the result from `<Head />`.
536
+
537
+ `sitemap: true` registers the official `@astrojs/sitemap` for you; pass an object to configure it
538
+ (`filter`, `customPages`, `changefreq`, `priority`, `i18n`, `entryLimit`, `filenameBase`,
539
+ `serialize`, …). `<Head />` gains a matching `<link rel="sitemap">`.
540
+
541
+ ```js
542
+ getvitops({ sitemap: true });
543
+ getvitops({ sitemap: { filter: (page) => !page.includes('/draft/') } });
544
+ ```
545
+
546
+ `@astrojs/sitemap` is an **optional peer** — install it yourself (`pnpm add -D @astrojs/sitemap`) and
547
+ the build fails with a message saying so if you don't, rather than silently emitting nothing. The
548
+ option also needs the `site` astro.config option, since a sitemap lists absolute URLs; without it the
549
+ option warns and skips. Note `@astrojs/sitemap` enumerates **prerendered** routes only, so on an
550
+ `output: 'server'` site you'll want `export const prerender = true` on the pages you want indexed, or
551
+ `sitemap.customPages`.
552
+
553
+ **On an EmDash site, leave it off** — EmDash serves its own database-driven `/sitemap.xml`, which also
554
+ covers on-demand pages a static sitemap can't. The option detects `emdash()` and skips with a warning.
555
+ If you want both, add `sitemap()` to your own `integrations` array; getvitops detects that too and
556
+ leaves yours in charge, which is also how you reach the few `@astrojs/sitemap` options this
557
+ integration doesn't mirror.
558
+
559
+ Also fixes the `virtual:getvitops/head` type declaration, which was missing the `editor` field that
560
+ `<Head />` already reads — a type error in consumer projects that don't set `skipLibCheck`.
561
+
562
+ - bb92a14: Add a fourth output format, `design`, that emits `DESIGN.md` — the agent-facing brief in
563
+ [google-labs-code/design.md](https://github.com/google-labs-code/design.md) format.
564
+
565
+ ```sh
566
+ vitops generate --format design --out . # DESIGN.md, and nothing else
567
+ vitops generate --format css,design # compose it with a stylesheet
568
+ ```
569
+
570
+ The file is YAML front matter carrying the tokens (`colors`, `typography`, `rounded`,
571
+ `spacing`, `components`, cross-referenced with `{group.token}`) followed by a prose body
572
+ carrying the rationale — colour model, fluid scales, layout vocabulary, elevation, shape
573
+ cascade, component tiers, do's and don'ts. Every section is rendered from your config, so
574
+ it cannot describe a system the other formats don't build. Point a coding agent, a Figma
575
+ import, or a designer at this one file when they don't have the toolchain; `vitops docs`
576
+ remains the richer reference for those who do.
577
+
578
+ It is emitted with `--out .` in mind: DESIGN.md conventionally lives at a repo root beside
579
+ `AGENTS.md`, not in a build directory.
580
+
581
+ Three things the spec cannot express, handled the same way every time and explained in the
582
+ emitted prose so the file is self-describing:
583
+ - **Fluid `clamp()` sizes** → the maximum (desktop) value, since a spec `Dimension` is a
584
+ bare number plus px/em/rem.
585
+ - **Dark mode** → light values only, with the automatic functional flip explained. Role
586
+ tokens are emitted as `{colors.<hue>-<step>}` references into the raw ramps rather than
587
+ flattened hexes, so the role → ramp lineage survives the export — flattening them is
588
+ exactly what breaks dark mode downstream.
589
+ - **A `50%` radius** → dropped from `rounded` (it is not a `Dimension`) and named in the
590
+ Shapes prose instead, so nothing is silently lost.
591
+
592
+ **New:** an optional `meta` key in `design-system.json` (`{ name, description }`) supplies
593
+ the brand name and the Overview paragraph. It affects no other format.
594
+
595
+ **New:** `StylesheetFormat` (`Exclude<Format, 'design'>`), exported from
596
+ `@getvitops/generator`. `@getvitops/astro`'s `css.format` now takes that narrower type —
597
+ `design` produces no stylesheet to inject, so passing it there is a type error rather than
598
+ a missing-file failure at build time. `vitops lint --format` is likewise restricted to the
599
+ three CSS formats. No change for anyone already passing `tailwind`, `css` or `bricks`.
600
+
601
+ - bb92a14: Generate legal documents from your site config
602
+
603
+ `vitops legal` renders a privacy policy, terms of service and cookie notice from a site
604
+ config, in markdown, HTML or EmDash Portable Text:
605
+
606
+ ```sh
607
+ vitops legal --out ./content # every enabled document, as markdown
608
+ vitops legal --doc privacy --format html # one document, as an HTML fragment
609
+ ```
610
+
611
+ The documents are **derived from your config**, not filled into a form. The analytics
612
+ provider they name is the one whose ID you set; the personal information they list is what
613
+ your configured forms actually collect; the countries they name come from the providers you
614
+ use. So a provider swap updates the policy on the next build, and the fix for a wrong policy
615
+ is a corrected config — hand-editing the output is overwritten.
616
+
617
+ Enable documents under `legal`, which gains the facts the prose asserts:
618
+
619
+ ```jsonc
620
+ {
621
+ "legal": {
622
+ "jurisdiction": "ca", // only 'ca' (PIPEDA) ships today
623
+ "privacyPolicy": {
624
+ "enabled": true,
625
+ "lastUpdated": "2026-08-01",
626
+ "retention": "24 months after our last contact with you",
627
+ // Third parties the config cannot imply. Analytics, Turnstile and your
628
+ // deploy platform are detected automatically — list only the rest.
629
+ "processors": [
630
+ {
631
+ "name": "Stripe",
632
+ "purpose": "payment processing",
633
+ "country": "the United States",
634
+ },
635
+ ],
636
+ },
637
+ "termsOfService": { "enabled": true },
638
+ "cookieConsent": {
639
+ "enabled": true,
640
+ "type": "opt-in",
641
+ "categories": ["Essential", "Analytics"],
642
+ },
643
+ },
644
+ }
645
+ ```
646
+
647
+ Delivery, by stack:
648
+ - **Any stack** — `vitops legal`. No integration code; prints to stdout without `--out`.
649
+ - **WordPress/Bricks** — `vitops generate --site <path>` also writes `dist/legal/*.html`, and
650
+ the theme loader now registers `[vitops_legal doc="privacy"]` to render one in a page. The
651
+ document updates on the next deploy with no action in WordPress.
652
+ - **Astro** — `getvitops({ legal: { input: 'site.json', out: 'src/content/legal' } })` writes
653
+ markdown into a content collection and re-renders when the site config changes. It needs a
654
+ `css` config (that is what registers the Vite plugin); without one, use the CLI.
655
+ - **EmDash** — `--format portable-text`, pasted into the admin.
656
+
657
+ Also new on the public API: `generateLegal()`, `renderMarkdown()`, `renderNodes()`,
658
+ `derivePolicyVars()`, `parseMarkdown()` / `toHtmlFragment()` / `toPortableText()`, and
659
+ `resolvePrivacyContact()`.
660
+
661
+ Two things to know before you publish anything this produces:
662
+ - **It is not legal advice.** Every document opens with a review banner saying so. The
663
+ bundled terms-of-service prose in particular is generic website boilerplate and
664
+ deliberately does not cover sales, refunds, subscriptions, accounts or user-generated
665
+ content — a site doing any of those needs clauses drafted for it.
666
+ - **It is only as true as your config.** A policy asserting things your site does not do is
667
+ worse than no policy. Check that the config describes reality before you ship the output.
668
+
669
+ `validateSite` now rejects a config that enables a privacy policy without a contact for
670
+ privacy requests or a `domains.canonical`, since both are interpolated into sentences that
671
+ would otherwise render blank.
672
+
673
+ ### Patch Changes
674
+
675
+ - bb92a14: Docs: import the Astro integration as `vitops`, and document the fourth output format.
676
+
677
+ Every example now reads `import vitops from '@getvitops/astro'` and calls `vitops({ … })`,
678
+ including the scaffolded `emdash` template. The default export is unchanged, so this is a
679
+ naming convention in the docs rather than an API change — existing configs that bind it as
680
+ `getvitops` keep working.
681
+
682
+ The `@getvitops/generator` and `@getvitops/cli` docs also describe the `design` format, which
683
+ was shipped without a mention in either package's output table: `--format design` writes a
684
+ single `DESIGN.md` and no CSS, so a run that composes it with a stylesheet wants its own
685
+ `--out` (the brief conventionally sits at a repo root, the stylesheet does not).
686
+
687
+ - Updated dependencies [bb92a14]
688
+ - Updated dependencies [bb92a14]
689
+ - Updated dependencies [bb92a14]
690
+ - Updated dependencies [bb92a14]
691
+ - Updated dependencies [eeb059f]
692
+ - @getvitops/generator@1.0.0
693
+ - @getvitops/vite@1.0.0
694
+ - @getvitops/utils@1.0.0
695
+ - @getvitops/core@1.0.0
696
+
3
697
  ## 0.9.0
4
698
 
5
699
  ### Minor Changes