@getvitops/astro 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,481 @@
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
+
3
479
  ## 1.0.0
4
480
 
5
481
  ### Minor Changes
package/README.md CHANGED
@@ -50,7 +50,8 @@ Astro's module graph and Astro emits that link itself.
50
50
  | option | default | does |
51
51
  | --------------- | -------------------- | ----------------------------------------------------------------- |
52
52
  | `css` | off unless given | generate + auto-inject the design-system CSS |
53
- | `css.input` | `design-system.json` | source config |
53
+ | `css.input` | `design-system.json` | source config — a design-system.json, or a site config |
54
+ | `css.theme` | the config's default | `designSystem.themes` entry to build, for a site-config `input` |
54
55
  | `css.format` | `tailwind` | `tailwind` \| `css` \| `bricks` |
55
56
  | `css.out` | `src/styles` | directory the generated CSS is written to |
56
57
  | `css.inject` | `true` | inject the stylesheet into every SSR page |
@@ -58,6 +59,13 @@ Astro's module graph and Astro emits that link itself.
58
59
  | `favicon` | off unless given | `source`, `lowResSource`, `name`, `themeColor`, `backgroundColor` |
59
60
  | `sitemap` | off unless given | generate `sitemap-index.xml` via `@astrojs/sitemap` |
60
61
  | `seo` | off unless given | site-level defaults for `<Seo />` |
62
+ | `analytics` | off unless given | providers for `<Analytics />` (GA4, Clarity, Matomo, Plausible) |
63
+ | `consent` | off unless given | the consent gate + `<CookieConsent />` |
64
+
65
+ **`css.input` takes either config kind.** Point it at a `design-system.json`, or at the larger site
66
+ config that embeds one (`company.json` / `site.json`) — they're told apart by shape, so the file can
67
+ be called anything. A site config also supplies what `site`, `legal` and `fonts` would otherwise need
68
+ their own path for, so `legal: {}` and `fonts: true` become the whole declaration.
61
69
 
62
70
  Set `css.inject: false` when another integration adds routes that must not inherit the design system
63
71
  (e.g. EmDash's `/_emdash/admin`) — then import the generated file (`<out>/tailwind.css` or
@@ -130,8 +138,11 @@ web-component runtime); use both.
130
138
 
131
139
  Behaviour worth knowing:
132
140
 
133
- - **`titleTemplate` is skipped when a page's title already equals `siteName`**, so a homepage titled
134
- "Acme" emits `Acme`, not `Acme · Acme`. It never applies to `defaultTitle` either.
141
+ - **`titleTemplate` applies to `<title>` only.** `og:title`/`twitter:title` get the _untemplated_
142
+ title, because a social card already shows `og:site_name` and the domain — "Pricing · Acme" would
143
+ sit directly above "Acme". Pass `ogTitle` for a different social headline. The template is also
144
+ skipped when a page's title already equals `siteName`, so a homepage titled "Acme" emits `Acme`,
145
+ not `Acme · Acme`, and it never applies to `defaultTitle`.
135
146
  - **Canonical needs the `site` astro.config option.** Without it, canonical, `og:url` and any
136
147
  _relative_ `og:image` are omitted rather than derived from the request — a canonical built from a
137
148
  dev or preview origin can de-index you. The integration warns at build time. Absolute image URLs
@@ -155,6 +166,137 @@ both duplicates every one of them; the integration warns if you configure `seo`
155
166
  The merge logic is a pure function, exported as `resolveSeo(defaults, props, ctx)` if you need to
156
167
  drive it yourself.
157
168
 
169
+ ## Analytics
170
+
171
+ `<Analytics />` emits the tags for the providers you configure. Nothing touches the critical path,
172
+ and anything that sets cookies waits for consent.
173
+
174
+ ```js
175
+ // astro.config.mjs
176
+ vitops({
177
+ analytics: {
178
+ googleAnalytics: 'G-XXXXXXXXXX',
179
+ clarity: 'abcd1234',
180
+ matomo: { url: 'https://stats.acme.com', siteId: '1' },
181
+ plausible: 'acme.com',
182
+ strategy: 'idle',
183
+ },
184
+ consent: { policyUrl: '/legal/cookies' },
185
+ });
186
+ ```
187
+
188
+ ```astro
189
+ ---
190
+ import Analytics from '@getvitops/astro/Analytics.astro';
191
+ import CookieConsent from '@getvitops/astro/CookieConsent.astro';
192
+ ---
193
+ <head>
194
+ <Head />
195
+ <Analytics />
196
+ </head>
197
+ <body>
198
+ <slot />
199
+ <CookieConsent />
200
+ </body>
201
+ ```
202
+
203
+ | provider | sets cookies | consent category | notes |
204
+ | ---------------------- | ------------ | ---------------- | -------------------------------------------------------------------------------- |
205
+ | **Google Analytics** 4 | yes | `analytics` | `_ga`, `_ga_*`, `_gid`. `category: 'marketing'` if the property feeds Ads |
206
+ | **Microsoft Clarity** | yes | `analytics` | session replay + heatmaps; `_clck`, `_clsk`, `MUID` |
207
+ | **Matomo** | **no** | `necessary` | `disableCookies` by default; `cookies: true` opts in and moves it to `analytics` |
208
+ | **Plausible** | no | `necessary` | cookieless, ~1 KB |
209
+
210
+ **The category is derived, not declared.** It follows from whether the provider sets cookies, which
211
+ follows from that provider's own configuration. You can't mark Google Analytics `necessary` to skip
212
+ the banner — but you _can_ pick a genuinely cookieless provider and be done with it, which is the
213
+ choice the table is trying to make legible.
214
+
215
+ ### Loading
216
+
217
+ `strategy` decides when a tag runs. The default keeps analytics off the critical path entirely:
218
+
219
+ | `strategy` | loads |
220
+ | ---------------- | ---------------------------------------------------------------- |
221
+ | `idle` (default) | after `load`, on an idle callback (3s timeout) |
222
+ | `async` | immediately, with the vendor's own `async` semantics |
223
+ | `interaction` | on first pointer/key/scroll, or after 8s — whichever comes first |
224
+
225
+ `interaction` is the cheapest and the least accurate: a visitor who reads and leaves is counted only
226
+ by the 8s fallback. No `preconnect` is emitted for any of them — warming a third-party connection
227
+ during parse is exactly the cost `idle` exists to avoid.
228
+
229
+ ### How consent actually blocks a tag
230
+
231
+ Gated tags render as `<script type="text/plain">` with the URL on `data-src`. The browser never parses
232
+ the body and never fetches the library, so an undecided or declining visitor's page issues **no
233
+ third-party request at all**. Consent implemented by asking a tracker not to track is a promise; this
234
+ is a fact about the document.
235
+
236
+ For Google Analytics that means **basic consent mode, not advanced**: nothing reaches Google until the
237
+ visitor accepts, rather than loading immediately with signals denied to send cookieless pings. Fewer
238
+ modelled conversions, nothing to defend. Clarity is gated the same way and additionally receives
239
+ `clarity('consentv2', …)`, because Microsoft enforces the signal separately for EEA/UK/CH traffic.
240
+
241
+ ## Cookie consent
242
+
243
+ `consent: true` ships `@getvitops/core/consent` — a 2.3 KB gzipped, Lit-free bundle — and enables
244
+ `<CookieConsent />`.
245
+
246
+ **It is not an analytics feature.** The gate is general: mark anything `data-consent="<category>"`
247
+ and it waits on the same choice.
248
+
249
+ ```html
250
+ <script type="text/plain" data-vitops-tag data-consent="marketing" data-src="https://…"></script>
251
+ <iframe data-consent="marketing" data-consent-src="https://www.youtube.com/embed/…"></iframe>
252
+ ```
253
+
254
+ Categories are `necessary` (always granted), `analytics`, `marketing`, `preferences`. The banner
255
+ offers only the ones something is actually waiting on.
256
+
257
+ Anything else — A/B assignment, account personalisation, your own scripts — uses `window.vitopsConsent`:
258
+
259
+ ```js
260
+ window.vitopsConsent.subscribe((state) => {
261
+ if (window.vitopsConsent.granted('preferences')) restoreSavedLayout();
262
+ });
263
+ ```
264
+
265
+ `get()` · `granted(category)` · `needed()` · `set({ analytics: true })` · `acceptAll()` ·
266
+ `rejectAll()` · `reset()` · `open()` · `subscribe(fn)`. A `vitops:consent` event fires on `document`
267
+ at startup and on every change. Anything with `[data-consent-open]` reopens the banner, so a footer
268
+ "Cookie settings" link needs no JS of its own.
269
+
270
+ Behaviour worth knowing:
271
+
272
+ - **Nothing is stored until the visitor chooses.** No cookie, no localStorage. Showing the banner
273
+ can't be the thing that needs consent. An unreadable or wrong-version cookie re-prompts rather than
274
+ being read permissively.
275
+ - **Revoking clears cookies and reloads.** An already-executing tracker can't be unloaded any other
276
+ way. `<CookieConsent noReloadOnRevoke />` turns the reload off, at the cost of that tracker running
277
+ until the next navigation.
278
+ - **Rejecting is a decision** — the banner stays gone, it doesn't keep asking until told yes.
279
+ - **No geo detection.** The banner shows for everyone once enabled. Suppressing it from a timezone or
280
+ an IP guess fails toward _not asking_, which is the expensive direction to be wrong in.
281
+ - **With no JS, nothing happens and that's correct** — the gate never runs, so no gated tag loads and
282
+ no non-essential cookie is set. The banner stays hidden because there is nothing to consent to.
283
+ - **`<Analytics />` alone still uses the runtime** for scheduling, unless every provider is cookieless
284
+ _and_ `strategy: 'async'` — the one configuration that ships no consent JavaScript at all.
285
+
286
+ ### Keep it in step with your cookie notice
287
+
288
+ `vitops legal` derives the privacy policy and cookie notice from your **site config**, and
289
+ `getvitops({ analytics })` is a separate surface. Declare each provider in both:
290
+
291
+ ```jsonc
292
+ // site config — what the documents disclose
293
+ { "analytics": { "googleAnalyticsId": "G-XXXXXXXXXX", "clarityId": "abcd1234" } }
294
+ ```
295
+
296
+ Configure `legal` alongside `analytics` and the integration checks this for you, naming any provider
297
+ you'd otherwise be running without disclosing. It also warns when a cookie-setting provider is
298
+ configured with no `consent` gate.
299
+
158
300
  ## Components
159
301
 
160
302
  Thin authoring helpers that emit the framework's HTML/CSS patterns — **none require runtime JS**: