@getvitops/astro 8.1.0 → 8.2.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +824 -33
  2. package/dist/config-adapters-CbTywgEY.mjs +71 -0
  3. package/dist/config-adapters-CbTywgEY.mjs.map +1 -0
  4. package/dist/exports/tracking.d.mts +1 -1
  5. package/dist/exports/tracking.mjs +1 -1
  6. package/dist/index.d.mts +65 -48
  7. package/dist/index.d.mts.map +1 -1
  8. package/dist/index.mjs +240 -32
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/middleware.d.mts +23 -0
  11. package/dist/middleware.d.mts.map +1 -0
  12. package/dist/middleware.mjs +34 -0
  13. package/dist/middleware.mjs.map +1 -0
  14. package/dist/routes/conversion.d.mts +131 -16
  15. package/dist/routes/conversion.d.mts.map +1 -1
  16. package/dist/routes/conversion.mjs +133 -28
  17. package/dist/routes/conversion.mjs.map +1 -1
  18. package/dist/{tracking-CmSfxWC3.mjs → tracking-Cb3A0BrC.mjs} +3 -4
  19. package/dist/tracking-Cb3A0BrC.mjs.map +1 -0
  20. package/dist/{tracking-DdEKExlE.d.mts → tracking-D2xOzjFD.d.mts} +6 -26
  21. package/dist/tracking-D2xOzjFD.d.mts.map +1 -0
  22. package/package.json +12 -8
  23. package/src/ads.ts +52 -16
  24. package/src/analytics.ts +98 -11
  25. package/src/components/Analytics.astro +5 -1
  26. package/src/components/CookieConsent.astro +2 -1
  27. package/src/components/Icon.astro +8 -1
  28. package/src/components/ReviewCardBody.astro +5 -2
  29. package/src/components/Tracking.astro +2 -1
  30. package/src/schemas/Article.astro +2 -1
  31. package/src/schemas/BookActions.astro +2 -1
  32. package/src/schemas/Breadcrumb.astro +2 -1
  33. package/src/schemas/Carousel.astro +2 -1
  34. package/src/schemas/CourseList.astro +2 -1
  35. package/src/schemas/Dataset.astro +2 -1
  36. package/src/schemas/DiscussionForum.astro +2 -1
  37. package/src/schemas/EducationQA.astro +2 -1
  38. package/src/schemas/EmployerAggregateRating.astro +2 -1
  39. package/src/schemas/Event.astro +2 -1
  40. package/src/schemas/FAQ.astro +2 -1
  41. package/src/schemas/FactCheck.astro +2 -1
  42. package/src/schemas/ImageMetadata.astro +2 -1
  43. package/src/schemas/JobPosting.astro +2 -1
  44. package/src/schemas/LocalBusiness.astro +2 -1
  45. package/src/schemas/MathSolver.astro +2 -1
  46. package/src/schemas/MovieCarousel.astro +2 -1
  47. package/src/schemas/Organization.astro +2 -1
  48. package/src/schemas/Product.astro +2 -1
  49. package/src/schemas/ProfilePage.astro +2 -1
  50. package/src/schemas/QAPage.astro +2 -1
  51. package/src/schemas/Recipe.astro +2 -1
  52. package/src/schemas/Review.astro +2 -1
  53. package/src/schemas/SoftwareApp.astro +2 -1
  54. package/src/scripts/tracking.ts +114 -21
  55. package/src/seo.ts +6 -5
  56. package/src/tracking.ts +6 -10
  57. package/virtual.d.ts +17 -1
  58. package/dist/tracking-CmSfxWC3.mjs.map +0 -1
  59. package/dist/tracking-DdEKExlE.d.mts.map +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,799 @@
1
1
  # @getvitops/astro
2
2
 
3
+ ## 8.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 65a13cc: **New: `site.experiments`** — runtime, per-visitor A/B content experiments for a page that
8
+ must stay at one URL (unlike `site.abTesting`'s existing build-time variant merge, which is
9
+ for a full design-system/CSS-level test via two manual deploys — `site.experiments` is the
10
+ opposite granularity: one page, one cookie, content branched in the page itself). Keyed by
11
+ an open experiment name, so more than one can run at once, each with its own consent
12
+ category, cookie name/lifetime, split ratio and pair of variant names.
13
+
14
+ **New `vitopsAbTesting()` middleware** (`@getvitops/astro/middleware`) — the first Astro
15
+ middleware this toolchain ships. Wire it into your own `src/middleware.ts`:
16
+
17
+ ```ts
18
+ import { vitopsAbTesting } from '@getvitops/astro/middleware';
19
+ import config from '../site.json' with { type: 'json' };
20
+
21
+ export const onRequest = vitopsAbTesting({
22
+ experiments: config.site.experiments ?? {},
23
+ });
24
+ ```
25
+
26
+ It assigns and cookie-pins a visitor to one of an experiment's two variants on first visit,
27
+ by weighted random (`splitRatio`) — and never touches an already-set cookie, so a recognized
28
+ query parameter named after the cookie itself (`?_ab_<key>=<variant>`) reliably forces that
29
+ variant, even overriding an existing assignment. Combined with `splitRatio: 0`, this is a
30
+ blue/green-style "100% on control, but a shared link reveals the other variant" mode with no
31
+ special-cased logic.
32
+
33
+ A page reads its own experiment's cookie (`Astro.cookies.get(cookieName)?.value`) and
34
+ renders whichever of its two hand-written blocks matches — there's no config-schema
35
+ representation of "the variant content," which is what keeps this lightweight regardless of
36
+ how many experiments are configured.
37
+
38
+ **`<Analytics />` gained a reporting hook**: each configured provider's bootstrap now
39
+ attaches an enabled experiment's assignment to the real pageview via that provider's own
40
+ mechanism — Plausible via `plausible.init({customProperties})`, Matomo via
41
+ `setCustomDimension` (needs a new `site.analytics.matomo.abVariantDimensions` entry — the
42
+ dimension must exist in your Matomo instance first), GA4 via a runtime-merged
43
+ `gtag('config', …)` call. A no-op when `site.experiments` is unset — zero behavior change
44
+ for every existing site.
45
+
46
+ Also new: `experimentDimensionWarnings(site)` (`@getvitops/generator`) — a build-time warning
47
+ (not a hard error) when an enabled experiment's site also configures Matomo but has no
48
+ matching `abVariantDimensions` entry.
49
+
50
+ **Not included in this pass, deliberately**: a mechanism for a fully prerendered
51
+ (`output: 'static'`) page — this needs a genuinely separate Cloudflare Worker Route + two
52
+ pre-built static variants, real but bounded follow-up work. Everything above applies to an
53
+ on-demand-rendered page (the current EmDash templates' default), which needs zero new
54
+ deploy infrastructure.
55
+
56
+ - 89265af: **New package: `@getvitops/config`** — the site-config provider blocks (`site.crm`, `site.ads`,
57
+ `site.reviews`, `site.seo.indexing`, `site.searchConsole`, `site.notifications`, `site.tracking`,
58
+ `site.security`, `site.deployment`) as zod schemas and inferred types, with the closed provider lists
59
+ (`AD_PROVIDERS`, `CRM_PROVIDERS`, `BIGIN_DATA_CENTERS`) on a zod-free `./providers` entry. It joins the
60
+ `fixed` group.
61
+
62
+ Why: `@getvitops/utils` holds the provider code but could not import its configuration types — the schema
63
+ lived in `@getvitops/generator`, a layer above — so it kept a hand-copied mirror beside each module,
64
+ an adapter converting into it, and a test holding the copy to the schema. The mirrors are gone.
65
+
66
+ - **`@getvitops/generator`**: `AD_PROVIDERS`, `CRM_PROVIDERS`, `BIGIN_DATA_CENTERS`, `AdProvider` and
67
+ `CrmProviderConfig` are re-exported unchanged. No import path changes.
68
+ - **`@getvitops/utils`**: the config types in `./crm`, `./ads`, `./indexing`, `./onboarding`, `./reviews`,
69
+ `./tracking` and `./notify` (`CrmConfig`, `HubSpotConfig`, `BiginConfig`, `JobberConfig`,
70
+ `IndexNowConfig`, `SearchConsoleConfig`, `ReviewsConfig`, `ReviewLocationConfig`, `TrackingConfig`,
71
+ `NotificationsConfig`, `EmailChannelConfig`, `DomainSetup`, `AdPropertySetup`) are now inferred from the
72
+ schema. Same names, same fields, but **optional fields now follow zod's inference**, so a hand-written
73
+ object literal typed against them may need `| undefined` handled. `AdPropertySetup` gains the fields the
74
+ schema always had (`conversionActionId`, `reporting`).
75
+ - **`@getvitops/astro`**: `GetvitopsAdProperty` and `GetvitopsTrackingOptions` are aliases of the schema
76
+ types. `toCrmConfig()` and `toGoogleAdsConfig()` are **deprecated** — they returned their argument's
77
+ field unchanged; pass `config.site.crm` / `config.site.ads?.google` directly.
78
+ - **`@getvitops/cli`**: `vitops ads` now passes `conversionActionId` and `reporting` through to the planner;
79
+ it previously copied a hand-picked subset of the block and dropped them.
80
+
81
+ Formats reached: none — no CSS change.
82
+
83
+ - 0574388: Add `site.crm` — push form submissions into HubSpot, Zoho Bigin, or Jobber.
84
+
85
+ Client sites need form submissions to land in the client's CRM. The options available before this
86
+ — a HubSpot/Bigin/Jobber iframe or JS embed — fail on three counts: the markup can't be
87
+ white-labelled against this design system, nothing can operate on the submission before it leaves
88
+ the page, and the submission never touched `createConversionRoute()`, so `_ac` ad-click attribution
89
+ was lost and no conversion notification was planned.
90
+
91
+ This adds one server-side path for all three CRMs, hung off the conversion route that already
92
+ exists. `createConversionRoute()` gets a new `crm` option (`{ config, credentials, fetch?,
93
+ timeoutMs? }`); credentials arrive as a function of `locals`, exactly like the existing `binding`
94
+ option, and are never config. A failed CRM write never fails the request and is folded into the
95
+ notification email's new "Delivery" section, so a broken CRM token is never silent.
96
+
97
+ New `@getvitops/utils/crm` subpath: `deliverToCrm()`, `mapSubmission()`, `deriveLastName()` (Bigin
98
+ requires a last name; this derives one from an explicit field, a full name, or an email address,
99
+ refusing rather than fabricating one), `submitToHubSpot()`, `submitToBigin()`, `submitToJobber()`.
100
+ `identifySubmitter()` was promoted out of `notify`'s private name-guessing cascade into
101
+ `@getvitops/utils/tracking`, since the CRM mapper needed the whole identity (first/last name,
102
+ email, phone, company), not just a display name.
103
+
104
+ Jobber is GraphQL, not REST, and answers `200 OK` for failures — a rejected `clientCreate` returns
105
+ `userErrors[]`, a malformed request returns top-level `errors[]`. `submitToJobber()` classifies on
106
+ the response body, not just the status. Its refresh tokens also rotate by default: **Refresh Token
107
+ Rotation must be turned off** in the app's Developer Center settings, since a Worker reading a
108
+ static `VITOPS_JOBBER_REFRESH_TOKEN` cannot persist a rotated one — a rotated token is detected at
109
+ exchange time and reported with the fix, rather than surfacing an hour later as an opaque 401.
110
+ Attribution and unmapped extra fields render into a best-effort note by default (works on every
111
+ Jobber plan); a resubmitting visitor creates a second Jobber client unless `crm.jobber.dedupe: true`
112
+ is set once its lookup query has been verified against a live account (defaults `false` pending
113
+ that verification — see `jobber.ts`'s docblock).
114
+
115
+ New `site.crm` config block (`provider`, `hubspot`/`bigin`/`jobber`, `fieldMap`, `trackingFields`)
116
+ and two CLI commands: `vitops crm check` (fetches the live HubSpot property list or Bigin field
117
+ list and reports every mapped name that doesn't exist there — HubSpot rejects an unknown field
118
+ outright, Bigin silently drops it; Jobber has nothing live to check yet, since its mapped values
119
+ currently render as note text) and `vitops crm setup` (creates missing HubSpot properties; Bigin
120
+ and Jobber have no such API, so both always print a manual reminder).
121
+
122
+ The generated privacy policy now names HubSpot, Zoho Bigin, or Jobber when `site.crm` configures
123
+ one. Bigin gets the same cross-border-transfer derivation Matomo Cloud already gets: a
124
+ `dataCenter: 'ca'` asserts no transfer outside Canada, any other data centre does. Jobber's
125
+ operator (Octopusapp Inc. d/b/a Jobber) is Canadian, but its own privacy policy names the United
126
+ States as a processing location too, so the policy always asserts a cross-border transfer for
127
+ Jobber regardless of config — a Canadian operator is not read as Canadian-only storage.
128
+
129
+ Credentials: `VITOPS_HUBSPOT_ACCESS_TOKEN` for HubSpot; `VITOPS_ZOHO_CLIENT_ID` /
130
+ `VITOPS_ZOHO_CLIENT_SECRET` / `VITOPS_ZOHO_REFRESH_TOKEN` for Bigin; `VITOPS_JOBBER_CLIENT_ID` /
131
+ `VITOPS_JOBBER_CLIENT_SECRET` / `VITOPS_JOBBER_REFRESH_TOKEN` for Jobber. Never config.
132
+
133
+ No consumer-facing change in `@getvitops/astro` beyond the new `crm` route option and a build
134
+ warning when `site.crm` is configured with no route answering `TRACKING_ENDPOINT` — CSS/Bricks/
135
+ tailwind outputs are unaffected.
136
+
137
+ - 8d8aad7: **New: server-side Google Ads offline conversion upload.** Set
138
+ `site.ads.google.reporting: "server"` and a matching
139
+ `conversionActionId` (a numeric id or full
140
+ `customers/…/conversionActions/…` resource name — **not** the same value
141
+ as `conversionLabel`, which is the opaque label the web tag uses and
142
+ which the Ads API's upload endpoint doesn't accept) and
143
+ `createConversionRoute`'s new `googleAdsOffline` option uploads the
144
+ conversion via `ConversionUploadService.UploadClickConversions` instead
145
+ of relying on the visitor's browser to fire `gtag.js`. An action with no
146
+ matching `conversionActionId` keeps firing client-side regardless of
147
+ `reporting`, so turning it on before the id is filled in never silently
148
+ drops a conversion. `@getvitops/astro/routes` exports a matching
149
+ `toGoogleAdsConfig(config)` adapter, alongside `toCrmConfig`.
150
+
151
+ Structurally typed against Google's documented REST shape, not verified
152
+ against a live Ads account — no developer-token/MCC access exists
153
+ anywhere in this toolchain's own setup to test against. Wrapped so a
154
+ wrong guess degrades to a stated failure reason (folded into the
155
+ conversion email's "Delivery" section, same as a failed CRM write) rather
156
+ than breaking, the same caveat `@getvitops/utils/crm`'s Jobber executor
157
+ already carries for its own unverified surfaces.
158
+
159
+ `vitops ads lint` now flags a `reporting: "server"` property whose
160
+ configured actions have no matching `conversionActionId` — not a
161
+ failure, since that action keeps firing client-side, but worth surfacing
162
+ rather than leaving someone to wonder why nothing changed.
163
+
164
+ Not built: `vitops ads provision` (upserting conversion actions via
165
+ `ConversionActionService` and printing back both the label and the
166
+ resource id) — same credential blocker, and deliberately left unscoped
167
+ until there's a live account to verify the request/response shape
168
+ against.
169
+
170
+ - 0574388: Fire a Google Ads conversion on a tel:/mailto: click, and fix a rate-limit bug on the conversion
171
+ route that could silently drop one of the two.
172
+
173
+ `site.ads.google.conversionLabel` used to be accepted by the config schema and typed all the way
174
+ through to the rendered tag, but nothing actually read it — no `gtag('event', 'conversion', …)`
175
+ was ever emitted. When set, the click tracker `<Tracking />` inlines now fires the conversion at
176
+ the moment a visitor taps a `tel:` or `mailto:` link, using the `AW-…/label` target Google Ads
177
+ expects. A `conversionLabel` that can't safely land inside the inline bootstrap script (the same
178
+ check `pixelId`/`accountId` already get) is dropped with a warning rather than firing something
179
+ malformed — the ad tag itself still loads.
180
+
181
+ Also: `createConversionRoute()`'s rate limiter previously keyed on IP address alone, so a call
182
+ beacon and an email-click beacon from the same visitor, seconds apart, competed for one window —
183
+ the second could 429 and vanish with no error anywhere. It's now keyed on IP _and_ event type, so
184
+ distinct conversion types never starve each other; repeats of the same type are still throttled
185
+ exactly as before.
186
+
187
+ No config changes — a site with `site.ads.google.conversionLabel` already set picks this up
188
+ automatically on the next build.
189
+
190
+ - 2a0a559: The Astro integration now warns when `site.locales`/`site.defaultLocale` disagree with
191
+ `astro.config.mjs`'s own `i18n` block — naming which locale codes are missing from each side, and
192
+ when the two `defaultLocale`s differ. EmDash reads its locale list straight from `i18n`, while our
193
+ own SEO/legal code reads `site.locales`, so the two had no way to notice they'd drifted apart. A
194
+ warning, not a build error — like the existing ads/analytics/consent consistency checks, either
195
+ side can legitimately be configured without the other, so only a genuine disagreement is flagged.
196
+ - 0574388: Track `mailto:` link clicks as conversions, alongside the existing `tel:` link tracking.
197
+
198
+ Before this, the capture script only beaconed `tel:` clicks; a `mailto:` link on the same page was
199
+ invisible to attribution — a visitor who emailed the business instead of calling generated no
200
+ conversion event at all. `<Tracking />`'s inlined script now listens for both, sending `{ event:
201
+ 'email', email }` via `navigator.sendBeacon` the same way it already sends `{ event: 'call', phone
202
+ }`.
203
+
204
+ `ConversionEvent['type']` gains an `'email'` member alongside `'form'`/`'call'`, with a new
205
+ `email` field. `createConversionRoute()` handles it exactly like a call: no honeypot, Turnstile or
206
+ CRM checks (there are no form fields to validate or map), a 204 response, and a notification email
207
+ subject reading `[Email Click] Visitor emailing ...`.
208
+
209
+ No config changes and no new options — a site with `<Tracking />` already mounted picks this up
210
+ automatically the next time it rebuilds.
211
+
212
+ - dde3df3: **`site.ads.google.conversionLabel` now accepts a per-action map** —
213
+ `{ call?, email?, form? }` — instead of one bare string shared by every
214
+ interaction. A bare string still works (back-compat, treated as `call`), but a
215
+ call and an email click used to report as the **same** Google Ads conversion
216
+ action, indistinguishable in the account; each action now gets its own
217
+ `send_to` target.
218
+
219
+ **New: `vitopsFormConversion(formEl)`**, exposed as `window.vitopsFormConversion`
220
+ by `<Tracking />`'s capture script — call it from your own fetch-based form
221
+ submit handler once the POST to `createConversionRoute()` succeeds, to fire the
222
+ `form` Google Ads conversion action client-side (mirroring the existing
223
+ tel:/mailto: click handler, which now fires `call`/`email` instead of sharing
224
+ one label). Idempotent per form element. There's deliberately no server-side
225
+ equivalent for a no-JS `<form method="post">` submission — that's a full-page
226
+ redirect with no script to fire from, and a real server-side Google Ads
227
+ conversion needs the Ads API's click-conversion upload (developer token + MCC
228
+ access), tracked separately in the vNext backlog.
229
+
230
+ **Fixed: the conversion route's rate limiter no longer trusts the client's
231
+ raw `event` field.** It used to key on whatever string a client sent
232
+ (`` `${ip}:${body.event}` ``), so varying that value on every request let a
233
+ form submission dodge the per-IP rate limit entirely — only `'call'`/`'email'`
234
+ were ever recognized, and anything else got its own fresh bucket. The key now
235
+ uses the server's own classification (`call` | `email` | `form`), closing that
236
+ bypass without changing the existing call-vs-email-don't-compete behavior.
237
+
238
+ **New: `site.notifications.actionUrl`** — an optional action-link template
239
+ added to the conversion-notification email as an "Actions" section.
240
+ `{clickId}` is replaced with the ad click ID captured on `_ac` (gclid, fbclid,
241
+ etc.); the section is omitted when the event has no click ID to interpolate,
242
+ rather than emailing a broken link.
243
+
244
+ - e5bc937: **Security: JSON-LD can no longer break out of its `<script>` tag.** All 24 `schemas/*.astro`
245
+ components inlined `JSON.stringify(jsonLd)` as-is, so a review, FAQ answer or forum post containing
246
+ `</script><script>…` ran as script on the page. They now go through `jsonLdScript()`, which escapes
247
+ `<`, `>`, `&`, U+2028 and U+2029 (still valid JSON — consumers and search engines parse the same
248
+ document). Sites rendering any user-supplied text through a schema component should upgrade promptly.
249
+
250
+ **`createConversionRoute()` hardening** — behaviour changes:
251
+
252
+ - The body-size cap is now enforced on the stream itself, not only on `Content-Length`; a chunked
253
+ request with no header is refused with 413 once it passes `maxBodyBytes`.
254
+ - **Breaking for non-Cloudflare hosts:** `X-Forwarded-For` is no longer used as the rate-limit key by
255
+ default, since off Cloudflare a client can set it freely and mint a new bucket per request. Pass
256
+ `trustForwardedFor: true` when running behind a proxy that overwrites it. Without
257
+ `CF-Connecting-IP` or that opt-in there is no IP and rate limiting is skipped. No change on
258
+ Cloudflare Workers.
259
+
260
+ `@getvitops/emdash`: the link/button block (`vitops.actionLink`) drops hrefs with a scheme other
261
+ than `http`, `https`, `mailto` or `tel` (e.g. `javascript:`, `data:`) and renders nothing for them.
262
+
263
+ Formats reached: none — no CSS change. Other packages in the `fixed` group: No consumer-facing
264
+ change in this package.
265
+
266
+ - 7fd5f8f: **`site.seo` is now the single source of site-level SEO defaults** (ADR-0002). Until now `site.seo`
267
+ declared `titleTemplate`, verification tokens, `openGraph` and `twitter` that nothing read, while
268
+ `<Seo />` took a second, differently-shaped vocabulary through `vitops({ seo })` that no other stack
269
+ could see.
270
+
271
+ - `SeoSchema` moves to `@getvitops/config` and gains what only the integration had: `siteName`,
272
+ `defaultTitle`, `defaultDescription`, `openGraph.localeAlternates` and `openGraph.image` as
273
+ `{ src, alt, width, height, type }`. Text fields are localisable.
274
+ - `@getvitops/astro` exports `resolveSeoDefaults(config, { locale, env })`. The integration reads
275
+ `site.seo` from the site config it already loads; `siteName` falls back to `organization.name`, and
276
+ the environment's `robots` wins over `site.seo.robots`, the same fact `vitops robots` and
277
+ `search notify` read.
278
+ - `vitops({ seo })` keeps working unchanged and is now an **override** merged over the config, field by
279
+ field (`openGraph` and `twitter` merge key by key).
280
+ - **Removed from the config schema** (nothing read them): `descriptionTemplate` → use
281
+ `defaultDescription`; `openGraph.siteName`/`title`/`description` and
282
+ `twitter.title`/`description`/`image` → set per page on `<Seo />`, or the site-wide values above.
283
+ A config that still carries one now fails `vitops validate` with an unrecognised-key error.
284
+ - `openGraph.type` is now `website | article | profile`.
285
+
286
+ - 3bbb8c3: Add `toNotifyContext(config, locale?)` and `toCrmConfig(config)`, exported from
287
+ `@getvitops/astro/routes` beside `createConversionRoute()`. Builds the `context`/`crm.config`
288
+ options straight from a resolved `Config` instead of hand-writing a `NotifyContext` literal —
289
+ `conversion.ts`'s own docblock used to state this helper "does not exist."
290
+
291
+ `organization.name` is localizable; `toNotifyContext` resolves it for a given locale (default
292
+ `site.defaultLocale`), falling back to `legalName` when neither locale nor default resolves to
293
+ anything. `locationEmails` is ordered primary location first, then every other location with an
294
+ `email` set, in `organization.locations`' own key order.
295
+
296
+ ### Patch Changes
297
+
298
+ - c3c1bcb: **New: the design-system editor, mounted inside the EmDash admin.** `vitopsEmdash({
299
+ designSystemEditor: { config } })` (`config` is your `site.json`, e.g. `import config from
300
+ './site.json' with { type: 'json' }`) adds a `/design-system` admin page mounting
301
+ `<wc-theme-editor>` — the same live token editor `@getvitops/vite`'s dev server has always
302
+ offered, now usable by the client directly, no dev server required. An edit is visible
303
+ site-wide immediately (a runtime `:root` override, served via a new plugin API route and
304
+ `page:fragments`) and — once `site.deployment.repo`/`branch` and a `VITOPS_GITHUB_TOKEN`
305
+ secret are set (see the scaffold's `SETUP.html`) — auto-commits to your `dev` branch, so it
306
+ reaches the git-tracked `site.json` through your repo's normal review path rather than
307
+ becoming a second, unreviewed source of truth. Until that's configured, the live half still
308
+ works; only the auto-commit half is skipped (and says so in the admin page). The save route
309
+ runs the exact same accessibility (APCA contrast) gate `generate()` enforces at build time,
310
+ synchronously, before persisting — nothing non-compliant ever goes live even momentarily.
311
+
312
+ Requires `@astrojs/react` + `react` + `react-dom` (already scaffold dependencies, for
313
+ EmDash's own admin) and `@getvitops/core` (added as a new scaffold dependency this release).
314
+ Declares two new plugin capabilities when enabled: `network:request` (for the GitHub
315
+ auto-commit) and `hooks.page-fragments:register` (for the live override) — reviewed once at
316
+ plugin-install time.
317
+
318
+ **Breaking for `@getvitops/emdash`**: `vitopsEmdash()`'s `hooks.page-fragments:register`
319
+ capability is now also granted whenever `designSystemEditor` is set, independent of the
320
+ existing `scripts: 'fragments'` option (which governs something unrelated — web-component
321
+ script delivery). No change if you don't use `designSystemEditor`.
322
+
323
+ Also new, supporting the above:
324
+
325
+ - `@getvitops/generator`: `validateColorContract()` and `buildReverseIndex()` (both
326
+ `/tokens` subpath, Worker-safe) — the monotonicity/contrast gate and the
327
+ `design-manifest.json` reverse-index, each extracted from `generate()`'s `build()` into
328
+ standalone, non-throwing functions. `patchToOverrideCss()` turns a design-system patch
329
+ into the `:root` override `<style>` block. `designSystemPath`/`getAt`/`setAt`/
330
+ `mergePatch`/`formatConfig` moved from `@getvitops/vite` into `@getvitops/generator/config`
331
+ (re-exported from `@getvitops/vite` unchanged) — one "patch a config file's design-system
332
+ subtree" implementation shared by the dev endpoint and the new plugin route. New
333
+ `site.deployment.repo`/`branch` config fields.
334
+ - `@getvitops/utils`: new `/git` subpath — `commitFile()`, a Worker-safe (fetch-only)
335
+ wrapper over GitHub's Contents API.
336
+ - `@getvitops/core`: `<wc-theme-editor>` gains a `save-endpoint` attribute (default
337
+ unchanged — `/__vitops/design-system`) so the same element can target the new plugin
338
+ route instead of the dev-only Vite endpoint.
339
+
340
+ - 0574388: Bump `emdash`/`@emdash-cms/cloudflare` peers to `>=1.0.0 <2` and `vite-plus` to `^1.0.0`, following
341
+ both projects' GA releases.
342
+
343
+ **Breaking for `@getvitops/emdash` consumers on EmDash 0.x**: the peer range no longer admits
344
+ `emdash@0.31.x`/`@emdash-cms/cloudflare@0.31.x`. Upgrade `emdash` and `@emdash-cms/cloudflare` to
345
+ `^1.0.1` first — nothing in `@getvitops/emdash`'s own plugin surface (`admin.portableTextBlocks`,
346
+ the `hooks.page-fragments:register` capability, `definePlugin()`) changed against EmDash 1.0, so no
347
+ code here needed to change, only the declared range.
348
+
349
+ The `emdash` scaffold template (`vp create @getvitops:emdash`) picks up several fixes alongside
350
+ the version bump:
351
+
352
+ - `tailwindcss`/`@tailwindcss/vite` are now real dependencies — the template selected
353
+ `css.format: 'tailwind'` without installing either, which threw on build.
354
+ - `site.json` now sets `site.icons.sprite: true` plus the full `semantic` list the
355
+ `vitops.actionLink` block's icon picker offers — previously its sprite icons pointed at a file
356
+ the build never generated.
357
+ - `astro.config.mjs` sets `migrations: { runtime: 'check', dev: 'auto' }`, and
358
+ `site-deploy-emdash.yml` runs EmDash 1.0's documented `build → status → apply → deploy → check`
359
+ migration sequence explicitly, rather than relying solely on first-request auto-migration. This
360
+ needs a new GitHub Environment variable, `EMDASH_TARGET_FINGERPRINT` (one per environment),
361
+ which only a human can set — run `emdash migrate --status` against the real deployed database,
362
+ review the printed identity, then `gh variable set EMDASH_TARGET_FINGERPRINT --env <env> --body
363
+ <value>`. Deploys fail clearly on the migration-apply step until it's set.
364
+ - `lint.options.typeAware`/`typeCheck` in the template's own `vite.config.ts` are commented out,
365
+ not deleted: vite-plus 1.0 only honours that option in a project's actual root config, and hard
366
+ errors on it in a nested one (which this file is, sitting inside the `design-system` monorepo).
367
+ Uncomment it once scaffolded into its own repo.
368
+
369
+ No config schema changes. `@getvitops/generator` also switches from the `zod/mini` subpath import
370
+ to the standalone `@zod/mini` package (zod 4.5+; same API) and fixes `validateConfig()` silently
371
+ losing the offending key name on an `unrecognized_keys` error (e.g. a typo'd `site.ads` platform
372
+ now names the bad key instead of reporting a bare "Invalid input").
373
+
374
+ Also fixed: `vp check`'s `tsconfig-error` for `packages/create/templates/emdash/tsconfig.json`
375
+ (`extends: "astro/tsconfigs/strict"`, unresolvable from inside this monorepo since the template
376
+ has no `node_modules` of its own — a pre-existing, structural gap, newly surfaced as a hard error
377
+ by oxlint 1.85's deeper config discovery, and blocking on every commit since this repo's pre-commit
378
+ hook runs `vp check --fix`). Fixed with a new `scripts/link-template-deps.mjs`, wired as the root
379
+ `postinstall` script: symlinks `astro`/`emdash`/`@emdash-cms/cloudflare` into the template's own
380
+ `node_modules/` (resolved fresh from sibling packages that already depend on them for real, not a
381
+ hardcoded `.pnpm` hash) and writes a minimal `astro:content` ambient shim at `.astro/types.d.ts`
382
+ (the same path `astro sync` would generate for real). Both are gitignored and only ever exist
383
+ inside this monorepo's copy of the template — a real scaffold installs its own real copies instead.
384
+ Considered and rejected first: inlining astro's strict preset (drift risk against astro's own
385
+ evolving default) and making the template a real pnpm workspace member (would change how its
386
+ `@getvitops/*: "latest"` deps resolve locally, which `templates.test.ts` exists to protect).
387
+
388
+ **Known gap, not fixed here**: `packages/astro/src/components/container.test.ts` (the
389
+ `AstroContainer`-based render tests) fails to even load under vite-plus 1.0's Vite 8 — direct
390
+ `.astro` imports in a Vitest file need the Astro Vite plugin registered, which nothing in this
391
+ repo's test config actually does (Astro's own docs call for `getViteConfig()`, which doesn't
392
+ compose with vite-plus's own `defineConfig`). A type-level shim
393
+ (`packages/astro/src/components/astro-modules.d.ts`) fixes `vp check`'s type-aware pass, but the
394
+ runtime `vp test` failure for this one file is still open.
395
+
396
+ - 1a3e811: **Fixed: `<Icon />` no longer crashes a Cloudflare Workers SSR page.** It statically imported
397
+ `loadIconSvg`/`spriteId` from `@getvitops/generator`'s root entry, which also re-exports
398
+ `generate.ts` — reaching lightningcss, whose native binary Workers cannot `require()`.
399
+ Rendering `<Icon>` anywhere in an SSR project 500'd every request, regardless of
400
+ `icons.engine`, since the import itself (not just calling the function) pulled lightningcss
401
+ into the bundle. Fixed by publishing a new Worker-safe `@getvitops/generator/icons-sprite`
402
+ subpath (`buildIconSprite`/`loadIconSvg`/`spriteId`, no lightningcss in its import graph) and
403
+ switching `<Icon />` to import from there.
404
+ - 0f8c018: **New: `resolveOrganizationName(config, locale?)`**, exported from `@getvitops/astro/routes`
405
+ alongside `toNotifyContext`/`toCrmConfig` — the same `organization.name` → `legalName` →
406
+ `undefined` fallback chain `toNotifyContext` already used for its `organizationName` field,
407
+ now usable standalone anywhere a build-time-only display name is needed (favicon generation,
408
+ a scaffold's `astro.config.mjs`, a `Layout.astro` fallback). `toNotifyContext` is unchanged —
409
+ it now delegates to this helper internally.
410
+
411
+ The emdash scaffold uses it to remove a three-way duplicated `'My EmDash Site'` string:
412
+ `astro.config.mjs`'s favicon `name` and `Layout.astro`'s title fallback both previously
413
+ hardcoded it independently of `site.json`'s `organization.name`. EmDash's own
414
+ `getSiteSettings().title` (set in the admin) still wins at render time when it's set — it's a
415
+ runtime-only fact that can't be read at build time at all — this only replaces the two
416
+ hardcoded _fallback_ strings.
417
+
418
+ - 7f4f50e: `@getvitops/utils/url` exports `safeHref()` (a Worker-safe scheme allowlist: http, https, mailto, tel and relative), shared by the EmDash blocks and the Astro components. It now also guards the EmDash image-compare block, the review avatar and the cookie-notice policy link. `nodesToHtml`/`toHtml` throw on invalid tag or attribute names and on `on*` event-handler attributes, and drop `href`/`src`-style values that fail `safeHref`. EmDash cards link text now names the card for screen readers.
419
+ - 2ef2d41: **`resolveOrganizationName()` accepts a plain `import site from './site.json'`.** It was typed
420
+ against the full `Config`, which a JSON import cannot satisfy — TypeScript widens its literals
421
+ (`colors.utilities` becomes `string[]`, not the schema's union) — so every scaffolded site's
422
+ `Layout.astro` failed `astro check`. It now takes `OrganizationNameSource`, the slice it actually
423
+ reads (`organization.name` / `legalName`, `site.defaultLocale`); a resolved `Config` still
424
+ satisfies it, so nothing that compiled before stops compiling.
425
+
426
+ **Both templates now typecheck and lint clean on a fresh scaffold**, found by the new `scaffold`
427
+ CI job (it installs each template against this checkout's packed packages):
428
+
429
+ - `site.json` declares `neutral` and `surface` as `{ "hue": "ink", "kind": "surface" }`. As bare
430
+ hue strings they were chromatic roles, which emit no bare `bg-neutral` and no
431
+ `text-neutral-muted`, so the template's own footer class resolved to nothing and `vitops lint
432
+ --strict` (run by the template's pre-commit hook) failed. **Existing sites are unaffected** —
433
+ this changes only what a new scaffold starts with; if your own `site.json` has the same two
434
+ lines and uses `text-neutral-*` or `bg-surface*`, change them the same way.
435
+ - The `emdash` template's `index.astro` and `[...slug].astro` cast `page.data.title`, which is
436
+ `unknown` until `emdash types` has run against a live site, so `astro check` passed only after
437
+ the first `pnpm dev`.
438
+
439
+ No consumer-facing change in `@getvitops/core`, `@getvitops/generator`, `@getvitops/utils`,
440
+ `@getvitops/cli` or `@getvitops/vite`.
441
+
442
+ - fdc3f7e: `<wc-marquee>` adds a "Pause animation" button beside the track (WCAG 2.2.2), and the marquee also pauses while focus is inside it (`data-paused` and `:focus-within`). Pattern CSS: all formats. `<wc-consent>` moves focus into the banner when a visitor opens it from a settings control and returns it on close; an unprompted banner still doesn't take focus. `<wc-image-compare>`'s before/after panes are `role="group"` so their labels are announced. The conversion route format-checks `phone`/`email` beacon payloads and imports the Worker-safe `@getvitops/utils/content` instead of the utils root barrel.
443
+ - Updated dependencies [65a13cc]
444
+ - Updated dependencies [67e2088]
445
+ - Updated dependencies [89265af]
446
+ - Updated dependencies [0574388]
447
+ - Updated dependencies [c3c1bcb]
448
+ - Updated dependencies [0574388]
449
+ - Updated dependencies [734ab60]
450
+ - Updated dependencies [1a8231d]
451
+ - Updated dependencies [779d22e]
452
+ - Updated dependencies [0574388]
453
+ - Updated dependencies [8d8aad7]
454
+ - Updated dependencies [0574388]
455
+ - Updated dependencies [1a3e811]
456
+ - Updated dependencies [0574388]
457
+ - Updated dependencies [dde3df3]
458
+ - Updated dependencies [d9f213b]
459
+ - Updated dependencies [7f4f50e]
460
+ - Updated dependencies [7fd5f8f]
461
+ - Updated dependencies [ceb8310]
462
+ - Updated dependencies [88b465a]
463
+ - Updated dependencies [fdc3f7e]
464
+ - @getvitops/generator@8.2.0
465
+ - @getvitops/config@8.2.0
466
+ - @getvitops/utils@8.2.0
467
+ - @getvitops/core@8.2.0
468
+ - @getvitops/vite@8.2.0
469
+
470
+ ## 8.2.0-alpha.0
471
+
472
+ ### Minor Changes
473
+
474
+ - 65a13cc: **New: `site.experiments`** — runtime, per-visitor A/B content experiments for a page that
475
+ must stay at one URL (unlike `site.abTesting`'s existing build-time variant merge, which is
476
+ for a full design-system/CSS-level test via two manual deploys — `site.experiments` is the
477
+ opposite granularity: one page, one cookie, content branched in the page itself). Keyed by
478
+ an open experiment name, so more than one can run at once, each with its own consent
479
+ category, cookie name/lifetime, split ratio and pair of variant names.
480
+
481
+ **New `vitopsAbTesting()` middleware** (`@getvitops/astro/middleware`) — the first Astro
482
+ middleware this toolchain ships. Wire it into your own `src/middleware.ts`:
483
+
484
+ ```ts
485
+ import { vitopsAbTesting } from '@getvitops/astro/middleware';
486
+ import config from '../site.json' with { type: 'json' };
487
+
488
+ export const onRequest = vitopsAbTesting({
489
+ experiments: config.site.experiments ?? {},
490
+ });
491
+ ```
492
+
493
+ It assigns and cookie-pins a visitor to one of an experiment's two variants on first visit,
494
+ by weighted random (`splitRatio`) — and never touches an already-set cookie, so a recognized
495
+ query parameter named after the cookie itself (`?_ab_<key>=<variant>`) reliably forces that
496
+ variant, even overriding an existing assignment. Combined with `splitRatio: 0`, this is a
497
+ blue/green-style "100% on control, but a shared link reveals the other variant" mode with no
498
+ special-cased logic.
499
+
500
+ A page reads its own experiment's cookie (`Astro.cookies.get(cookieName)?.value`) and
501
+ renders whichever of its two hand-written blocks matches — there's no config-schema
502
+ representation of "the variant content," which is what keeps this lightweight regardless of
503
+ how many experiments are configured.
504
+
505
+ **`<Analytics />` gained a reporting hook**: each configured provider's bootstrap now
506
+ attaches an enabled experiment's assignment to the real pageview via that provider's own
507
+ mechanism — Plausible via `plausible.init({customProperties})`, Matomo via
508
+ `setCustomDimension` (needs a new `site.analytics.matomo.abVariantDimensions` entry — the
509
+ dimension must exist in your Matomo instance first), GA4 via a runtime-merged
510
+ `gtag('config', …)` call. A no-op when `site.experiments` is unset — zero behavior change
511
+ for every existing site.
512
+
513
+ Also new: `experimentDimensionWarnings(site)` (`@getvitops/generator`) — a build-time warning
514
+ (not a hard error) when an enabled experiment's site also configures Matomo but has no
515
+ matching `abVariantDimensions` entry.
516
+
517
+ **Not included in this pass, deliberately**: a mechanism for a fully prerendered
518
+ (`output: 'static'`) page — this needs a genuinely separate Cloudflare Worker Route + two
519
+ pre-built static variants, real but bounded follow-up work. Everything above applies to an
520
+ on-demand-rendered page (the current EmDash templates' default), which needs zero new
521
+ deploy infrastructure.
522
+
523
+ - 0574388: Add `site.crm` — push form submissions into HubSpot, Zoho Bigin, or Jobber.
524
+
525
+ Client sites need form submissions to land in the client's CRM. The options available before this
526
+ — a HubSpot/Bigin/Jobber iframe or JS embed — fail on three counts: the markup can't be
527
+ white-labelled against this design system, nothing can operate on the submission before it leaves
528
+ the page, and the submission never touched `createConversionRoute()`, so `_ac` ad-click attribution
529
+ was lost and no conversion notification was planned.
530
+
531
+ This adds one server-side path for all three CRMs, hung off the conversion route that already
532
+ exists. `createConversionRoute()` gets a new `crm` option (`{ config, credentials, fetch?,
533
+ timeoutMs? }`); credentials arrive as a function of `locals`, exactly like the existing `binding`
534
+ option, and are never config. A failed CRM write never fails the request and is folded into the
535
+ notification email's new "Delivery" section, so a broken CRM token is never silent.
536
+
537
+ New `@getvitops/utils/crm` subpath: `deliverToCrm()`, `mapSubmission()`, `deriveLastName()` (Bigin
538
+ requires a last name; this derives one from an explicit field, a full name, or an email address,
539
+ refusing rather than fabricating one), `submitToHubSpot()`, `submitToBigin()`, `submitToJobber()`.
540
+ `identifySubmitter()` was promoted out of `notify`'s private name-guessing cascade into
541
+ `@getvitops/utils/tracking`, since the CRM mapper needed the whole identity (first/last name,
542
+ email, phone, company), not just a display name.
543
+
544
+ Jobber is GraphQL, not REST, and answers `200 OK` for failures — a rejected `clientCreate` returns
545
+ `userErrors[]`, a malformed request returns top-level `errors[]`. `submitToJobber()` classifies on
546
+ the response body, not just the status. Its refresh tokens also rotate by default: **Refresh Token
547
+ Rotation must be turned off** in the app's Developer Center settings, since a Worker reading a
548
+ static `VITOPS_JOBBER_REFRESH_TOKEN` cannot persist a rotated one — a rotated token is detected at
549
+ exchange time and reported with the fix, rather than surfacing an hour later as an opaque 401.
550
+ Attribution and unmapped extra fields render into a best-effort note by default (works on every
551
+ Jobber plan); a resubmitting visitor creates a second Jobber client unless `crm.jobber.dedupe: true`
552
+ is set once its lookup query has been verified against a live account (defaults `false` pending
553
+ that verification — see `jobber.ts`'s docblock).
554
+
555
+ New `site.crm` config block (`provider`, `hubspot`/`bigin`/`jobber`, `fieldMap`, `trackingFields`)
556
+ and two CLI commands: `vitops crm check` (fetches the live HubSpot property list or Bigin field
557
+ list and reports every mapped name that doesn't exist there — HubSpot rejects an unknown field
558
+ outright, Bigin silently drops it; Jobber has nothing live to check yet, since its mapped values
559
+ currently render as note text) and `vitops crm setup` (creates missing HubSpot properties; Bigin
560
+ and Jobber have no such API, so both always print a manual reminder).
561
+
562
+ The generated privacy policy now names HubSpot, Zoho Bigin, or Jobber when `site.crm` configures
563
+ one. Bigin gets the same cross-border-transfer derivation Matomo Cloud already gets: a
564
+ `dataCenter: 'ca'` asserts no transfer outside Canada, any other data centre does. Jobber's
565
+ operator (Octopusapp Inc. d/b/a Jobber) is Canadian, but its own privacy policy names the United
566
+ States as a processing location too, so the policy always asserts a cross-border transfer for
567
+ Jobber regardless of config — a Canadian operator is not read as Canadian-only storage.
568
+
569
+ Credentials: `VITOPS_HUBSPOT_ACCESS_TOKEN` for HubSpot; `VITOPS_ZOHO_CLIENT_ID` /
570
+ `VITOPS_ZOHO_CLIENT_SECRET` / `VITOPS_ZOHO_REFRESH_TOKEN` for Bigin; `VITOPS_JOBBER_CLIENT_ID` /
571
+ `VITOPS_JOBBER_CLIENT_SECRET` / `VITOPS_JOBBER_REFRESH_TOKEN` for Jobber. Never config.
572
+
573
+ No consumer-facing change in `@getvitops/astro` beyond the new `crm` route option and a build
574
+ warning when `site.crm` is configured with no route answering `TRACKING_ENDPOINT` — CSS/Bricks/
575
+ tailwind outputs are unaffected.
576
+
577
+ - 0574388: Fire a Google Ads conversion on a tel:/mailto: click, and fix a rate-limit bug on the conversion
578
+ route that could silently drop one of the two.
579
+
580
+ `site.ads.google.conversionLabel` used to be accepted by the config schema and typed all the way
581
+ through to the rendered tag, but nothing actually read it — no `gtag('event', 'conversion', …)`
582
+ was ever emitted. When set, the click tracker `<Tracking />` inlines now fires the conversion at
583
+ the moment a visitor taps a `tel:` or `mailto:` link, using the `AW-…/label` target Google Ads
584
+ expects. A `conversionLabel` that can't safely land inside the inline bootstrap script (the same
585
+ check `pixelId`/`accountId` already get) is dropped with a warning rather than firing something
586
+ malformed — the ad tag itself still loads.
587
+
588
+ Also: `createConversionRoute()`'s rate limiter previously keyed on IP address alone, so a call
589
+ beacon and an email-click beacon from the same visitor, seconds apart, competed for one window —
590
+ the second could 429 and vanish with no error anywhere. It's now keyed on IP _and_ event type, so
591
+ distinct conversion types never starve each other; repeats of the same type are still throttled
592
+ exactly as before.
593
+
594
+ No config changes — a site with `site.ads.google.conversionLabel` already set picks this up
595
+ automatically on the next build.
596
+
597
+ - 2a0a559: The Astro integration now warns when `site.locales`/`site.defaultLocale` disagree with
598
+ `astro.config.mjs`'s own `i18n` block — naming which locale codes are missing from each side, and
599
+ when the two `defaultLocale`s differ. EmDash reads its locale list straight from `i18n`, while our
600
+ own SEO/legal code reads `site.locales`, so the two had no way to notice they'd drifted apart. A
601
+ warning, not a build error — like the existing ads/analytics/consent consistency checks, either
602
+ side can legitimately be configured without the other, so only a genuine disagreement is flagged.
603
+ - 0574388: Track `mailto:` link clicks as conversions, alongside the existing `tel:` link tracking.
604
+
605
+ Before this, the capture script only beaconed `tel:` clicks; a `mailto:` link on the same page was
606
+ invisible to attribution — a visitor who emailed the business instead of calling generated no
607
+ conversion event at all. `<Tracking />`'s inlined script now listens for both, sending `{ event:
608
+ 'email', email }` via `navigator.sendBeacon` the same way it already sends `{ event: 'call', phone
609
+ }`.
610
+
611
+ `ConversionEvent['type']` gains an `'email'` member alongside `'form'`/`'call'`, with a new
612
+ `email` field. `createConversionRoute()` handles it exactly like a call: no honeypot, Turnstile or
613
+ CRM checks (there are no form fields to validate or map), a 204 response, and a notification email
614
+ subject reading `[Email Click] Visitor emailing ...`.
615
+
616
+ No config changes and no new options — a site with `<Tracking />` already mounted picks this up
617
+ automatically the next time it rebuilds.
618
+
619
+ - dde3df3: **`site.ads.google.conversionLabel` now accepts a per-action map** —
620
+ `{ call?, email?, form? }` — instead of one bare string shared by every
621
+ interaction. A bare string still works (back-compat, treated as `call`), but a
622
+ call and an email click used to report as the **same** Google Ads conversion
623
+ action, indistinguishable in the account; each action now gets its own
624
+ `send_to` target.
625
+
626
+ **New: `vitopsFormConversion(formEl)`**, exposed as `window.vitopsFormConversion`
627
+ by `<Tracking />`'s capture script — call it from your own fetch-based form
628
+ submit handler once the POST to `createConversionRoute()` succeeds, to fire the
629
+ `form` Google Ads conversion action client-side (mirroring the existing
630
+ tel:/mailto: click handler, which now fires `call`/`email` instead of sharing
631
+ one label). Idempotent per form element. There's deliberately no server-side
632
+ equivalent for a no-JS `<form method="post">` submission — that's a full-page
633
+ redirect with no script to fire from, and a real server-side Google Ads
634
+ conversion needs the Ads API's click-conversion upload (developer token + MCC
635
+ access), tracked separately in the vNext backlog.
636
+
637
+ **Fixed: the conversion route's rate limiter no longer trusts the client's
638
+ raw `event` field.** It used to key on whatever string a client sent
639
+ (`` `${ip}:${body.event}` ``), so varying that value on every request let a
640
+ form submission dodge the per-IP rate limit entirely — only `'call'`/`'email'`
641
+ were ever recognized, and anything else got its own fresh bucket. The key now
642
+ uses the server's own classification (`call` | `email` | `form`), closing that
643
+ bypass without changing the existing call-vs-email-don't-compete behavior.
644
+
645
+ **New: `site.notifications.actionUrl`** — an optional action-link template
646
+ added to the conversion-notification email as an "Actions" section.
647
+ `{clickId}` is replaced with the ad click ID captured on `_ac` (gclid, fbclid,
648
+ etc.); the section is omitted when the event has no click ID to interpolate,
649
+ rather than emailing a broken link.
650
+
651
+ - 3bbb8c3: Add `toNotifyContext(config, locale?)` and `toCrmConfig(config)`, exported from
652
+ `@getvitops/astro/routes` beside `createConversionRoute()`. Builds the `context`/`crm.config`
653
+ options straight from a resolved `Config` instead of hand-writing a `NotifyContext` literal —
654
+ `conversion.ts`'s own docblock used to state this helper "does not exist."
655
+
656
+ `organization.name` is localizable; `toNotifyContext` resolves it for a given locale (default
657
+ `site.defaultLocale`), falling back to `legalName` when neither locale nor default resolves to
658
+ anything. `locationEmails` is ordered primary location first, then every other location with an
659
+ `email` set, in `organization.locations`' own key order.
660
+
661
+ ### Patch Changes
662
+
663
+ - c3c1bcb: **New: the design-system editor, mounted inside the EmDash admin.** `vitopsEmdash({
664
+ designSystemEditor: { config } })` (`config` is your `site.json`, e.g. `import config from
665
+ './site.json' with { type: 'json' }`) adds a `/design-system` admin page mounting
666
+ `<wc-theme-editor>` — the same live token editor `@getvitops/vite`'s dev server has always
667
+ offered, now usable by the client directly, no dev server required. An edit is visible
668
+ site-wide immediately (a runtime `:root` override, served via a new plugin API route and
669
+ `page:fragments`) and — once `site.deployment.repo`/`branch` and a `VITOPS_GITHUB_TOKEN`
670
+ secret are set (see the scaffold's `SETUP.html`) — auto-commits to your `dev` branch, so it
671
+ reaches the git-tracked `site.json` through your repo's normal review path rather than
672
+ becoming a second, unreviewed source of truth. Until that's configured, the live half still
673
+ works; only the auto-commit half is skipped (and says so in the admin page). The save route
674
+ runs the exact same accessibility (APCA contrast) gate `generate()` enforces at build time,
675
+ synchronously, before persisting — nothing non-compliant ever goes live even momentarily.
676
+
677
+ Requires `@astrojs/react` + `react` + `react-dom` (already scaffold dependencies, for
678
+ EmDash's own admin) and `@getvitops/core` (added as a new scaffold dependency this release).
679
+ Declares two new plugin capabilities when enabled: `network:request` (for the GitHub
680
+ auto-commit) and `hooks.page-fragments:register` (for the live override) — reviewed once at
681
+ plugin-install time.
682
+
683
+ **Breaking for `@getvitops/emdash`**: `vitopsEmdash()`'s `hooks.page-fragments:register`
684
+ capability is now also granted whenever `designSystemEditor` is set, independent of the
685
+ existing `scripts: 'fragments'` option (which governs something unrelated — web-component
686
+ script delivery). No change if you don't use `designSystemEditor`.
687
+
688
+ Also new, supporting the above:
689
+ - `@getvitops/generator`: `validateColorContract()` and `buildReverseIndex()` (both
690
+ `/tokens` subpath, Worker-safe) — the monotonicity/contrast gate and the
691
+ `design-manifest.json` reverse-index, each extracted from `generate()`'s `build()` into
692
+ standalone, non-throwing functions. `patchToOverrideCss()` turns a design-system patch
693
+ into the `:root` override `<style>` block. `designSystemPath`/`getAt`/`setAt`/
694
+ `mergePatch`/`formatConfig` moved from `@getvitops/vite` into `@getvitops/generator/config`
695
+ (re-exported from `@getvitops/vite` unchanged) — one "patch a config file's design-system
696
+ subtree" implementation shared by the dev endpoint and the new plugin route. New
697
+ `site.deployment.repo`/`branch` config fields.
698
+ - `@getvitops/utils`: new `/git` subpath — `commitFile()`, a Worker-safe (fetch-only)
699
+ wrapper over GitHub's Contents API.
700
+ - `@getvitops/core`: `<wc-theme-editor>` gains a `save-endpoint` attribute (default
701
+ unchanged — `/__vitops/design-system`) so the same element can target the new plugin
702
+ route instead of the dev-only Vite endpoint.
703
+
704
+ - 0574388: Bump `emdash`/`@emdash-cms/cloudflare` peers to `>=1.0.0 <2` and `vite-plus` to `^1.0.0`, following
705
+ both projects' GA releases.
706
+
707
+ **Breaking for `@getvitops/emdash` consumers on EmDash 0.x**: the peer range no longer admits
708
+ `emdash@0.31.x`/`@emdash-cms/cloudflare@0.31.x`. Upgrade `emdash` and `@emdash-cms/cloudflare` to
709
+ `^1.0.1` first — nothing in `@getvitops/emdash`'s own plugin surface (`admin.portableTextBlocks`,
710
+ the `hooks.page-fragments:register` capability, `definePlugin()`) changed against EmDash 1.0, so no
711
+ code here needed to change, only the declared range.
712
+
713
+ The `emdash` scaffold template (`vp create @getvitops:emdash`) picks up several fixes alongside
714
+ the version bump:
715
+ - `tailwindcss`/`@tailwindcss/vite` are now real dependencies — the template selected
716
+ `css.format: 'tailwind'` without installing either, which threw on build.
717
+ - `site.json` now sets `site.icons.sprite: true` plus the full `semantic` list the
718
+ `vitops.actionLink` block's icon picker offers — previously its sprite icons pointed at a file
719
+ the build never generated.
720
+ - `astro.config.mjs` sets `migrations: { runtime: 'check', dev: 'auto' }`, and
721
+ `site-deploy-emdash.yml` runs EmDash 1.0's documented `build → status → apply → deploy → check`
722
+ migration sequence explicitly, rather than relying solely on first-request auto-migration. This
723
+ needs a new GitHub Environment variable, `EMDASH_TARGET_FINGERPRINT` (one per environment),
724
+ which only a human can set — run `emdash migrate --status` against the real deployed database,
725
+ review the printed identity, then `gh variable set EMDASH_TARGET_FINGERPRINT --env <env> --body
726
+ <value>`. Deploys fail clearly on the migration-apply step until it's set.
727
+ - `lint.options.typeAware`/`typeCheck` in the template's own `vite.config.ts` are commented out,
728
+ not deleted: vite-plus 1.0 only honours that option in a project's actual root config, and hard
729
+ errors on it in a nested one (which this file is, sitting inside the `design-system` monorepo).
730
+ Uncomment it once scaffolded into its own repo.
731
+
732
+ No config schema changes. `@getvitops/generator` also switches from the `zod/mini` subpath import
733
+ to the standalone `@zod/mini` package (zod 4.5+; same API) and fixes `validateConfig()` silently
734
+ losing the offending key name on an `unrecognized_keys` error (e.g. a typo'd `site.ads` platform
735
+ now names the bad key instead of reporting a bare "Invalid input").
736
+
737
+ Also fixed: `vp check`'s `tsconfig-error` for `packages/create/templates/emdash/tsconfig.json`
738
+ (`extends: "astro/tsconfigs/strict"`, unresolvable from inside this monorepo since the template
739
+ has no `node_modules` of its own — a pre-existing, structural gap, newly surfaced as a hard error
740
+ by oxlint 1.85's deeper config discovery, and blocking on every commit since this repo's pre-commit
741
+ hook runs `vp check --fix`). Fixed with a new `scripts/link-template-deps.mjs`, wired as the root
742
+ `postinstall` script: symlinks `astro`/`emdash`/`@emdash-cms/cloudflare` into the template's own
743
+ `node_modules/` (resolved fresh from sibling packages that already depend on them for real, not a
744
+ hardcoded `.pnpm` hash) and writes a minimal `astro:content` ambient shim at `.astro/types.d.ts`
745
+ (the same path `astro sync` would generate for real). Both are gitignored and only ever exist
746
+ inside this monorepo's copy of the template — a real scaffold installs its own real copies instead.
747
+ Considered and rejected first: inlining astro's strict preset (drift risk against astro's own
748
+ evolving default) and making the template a real pnpm workspace member (would change how its
749
+ `@getvitops/*: "latest"` deps resolve locally, which `templates.test.ts` exists to protect).
750
+
751
+ **Known gap, not fixed here**: `packages/astro/src/components/container.test.ts` (the
752
+ `AstroContainer`-based render tests) fails to even load under vite-plus 1.0's Vite 8 — direct
753
+ `.astro` imports in a Vitest file need the Astro Vite plugin registered, which nothing in this
754
+ repo's test config actually does (Astro's own docs call for `getViteConfig()`, which doesn't
755
+ compose with vite-plus's own `defineConfig`). A type-level shim
756
+ (`packages/astro/src/components/astro-modules.d.ts`) fixes `vp check`'s type-aware pass, but the
757
+ runtime `vp test` failure for this one file is still open.
758
+
759
+ - 1a3e811: **Fixed: `<Icon />` no longer crashes a Cloudflare Workers SSR page.** It statically imported
760
+ `loadIconSvg`/`spriteId` from `@getvitops/generator`'s root entry, which also re-exports
761
+ `generate.ts` — reaching lightningcss, whose native binary Workers cannot `require()`.
762
+ Rendering `<Icon>` anywhere in an SSR project 500'd every request, regardless of
763
+ `icons.engine`, since the import itself (not just calling the function) pulled lightningcss
764
+ into the bundle. Fixed by publishing a new Worker-safe `@getvitops/generator/icons-sprite`
765
+ subpath (`buildIconSprite`/`loadIconSvg`/`spriteId`, no lightningcss in its import graph) and
766
+ switching `<Icon />` to import from there.
767
+ - 0f8c018: **New: `resolveOrganizationName(config, locale?)`**, exported from `@getvitops/astro/routes`
768
+ alongside `toNotifyContext`/`toCrmConfig` — the same `organization.name` → `legalName` →
769
+ `undefined` fallback chain `toNotifyContext` already used for its `organizationName` field,
770
+ now usable standalone anywhere a build-time-only display name is needed (favicon generation,
771
+ a scaffold's `astro.config.mjs`, a `Layout.astro` fallback). `toNotifyContext` is unchanged —
772
+ it now delegates to this helper internally.
773
+
774
+ The emdash scaffold uses it to remove a three-way duplicated `'My EmDash Site'` string:
775
+ `astro.config.mjs`'s favicon `name` and `Layout.astro`'s title fallback both previously
776
+ hardcoded it independently of `site.json`'s `organization.name`. EmDash's own
777
+ `getSiteSettings().title` (set in the admin) still wins at render time when it's set — it's a
778
+ runtime-only fact that can't be read at build time at all — this only replaces the two
779
+ hardcoded _fallback_ strings.
780
+
781
+ - Updated dependencies [65a13cc]
782
+ - Updated dependencies [67e2088]
783
+ - Updated dependencies [0574388]
784
+ - Updated dependencies [c3c1bcb]
785
+ - Updated dependencies [0574388]
786
+ - Updated dependencies [0574388]
787
+ - Updated dependencies [0574388]
788
+ - Updated dependencies [1a3e811]
789
+ - Updated dependencies [0574388]
790
+ - Updated dependencies [dde3df3]
791
+ - Updated dependencies [d9f213b]
792
+ - @getvitops/generator@8.2.0-alpha.0
793
+ - @getvitops/utils@8.2.0-alpha.0
794
+ - @getvitops/core@8.2.0-alpha.0
795
+ - @getvitops/vite@8.2.0-alpha.0
796
+
3
797
  ## 8.1.0
4
798
 
5
799
  ### Patch Changes
@@ -52,7 +846,6 @@
52
846
 
53
847
  **Breaking (`css`/`bricks` and `tailwind` formats — `.lightbox-dialog`'s markup
54
848
  contract changed):**
55
-
56
849
  - `<Gallery />` now emits ONE `<dialog class="lightbox-dialog">` per gallery,
57
850
  wrapping a `<Carousel />`, instead of one dialog per image. A `.gallery__trigger`
58
851
  is now a plain `<a href="#slide-id">`, not a `command="show-modal"` button.
@@ -93,7 +886,6 @@
93
886
  ### Minor Changes
94
887
 
95
888
  - Add Google Business Profile reviews: fetch, cache, render.
96
-
97
889
  - **`vitops reviews`** (new CLI command) fetches a client's GBP reviews (legacy v4 API — the only
98
890
  one that has them) and writes them to `.vitops/reviews.json`, meant to be committed so a build
99
891
  never needs a live Google credential. `--dry` prints the plan; `--check` diffs against the cache
@@ -128,7 +920,6 @@
128
920
  `--site` for now.)
129
921
 
130
922
  **Fixed:**
131
-
132
923
  - `@getvitops/core`'s 12 runtime deps (`lit`, both `@oddbird` polyfills, both
133
924
  `@webcomponents` packages, `colorjs.io`, etc.) were listed as `dependencies` despite
134
925
  being bundled into `dist/elements.js`/`dist/polyfills.js`/`dist/deferred.js` — a
@@ -274,7 +1065,7 @@
274
1065
  created, with `patterns/counter.css`'s own reduced-motion query as a second belt.
275
1066
  - The accessible name stays the final value for the whole animation: `.counter__value` is toggled
276
1067
  to the framework's `.sr-only` utility while running, never hidden — `display: none`/`visibility:
277
- hidden` would pull it out of the accessible-name computation along with the visual hiding, and a
1068
+ hidden` would pull it out of the accessible-name computation along with the visual hiding, and a
278
1069
  screen reader must never be read ~60 intermediate numbers. The animated text lives in a separate
279
1070
  `aria-hidden` presentation span it never reaches.
280
1071
  - `data-from` (default `0`), `data-duration` (ms, default `1200`), `data-easing` (`linear` |
@@ -985,7 +1776,7 @@ hidden` would pull it out of the accessible-name computation along with the visu
985
1776
  `animate-view` / `animate-scroll` / `animate-trigger` / `transition`, and every effect
986
1777
  gained an `open-<fx>` state variant. Overlays now state _where they start_
987
1778
  (`--translate-x-from`) instead of owning a keyframe: `class="drawer drawer--right
988
- open-fade-in"` composes slide and fade. Applied at zero specificity with identity
1779
+ open-fade-in"` composes slide and fade. Applied at zero specificity with identity
989
1780
  defaults, so a popover that sets no effect vars is unchanged.
990
1781
  - **One scrim.** `--scrim` / `--scrim-filter` tokens and `.no-scrim` in `popover.css`,
991
1782
  replacing 18 `::backdrop` blocks across seven partials. `.drawer--modeless` is aliased.
@@ -1060,7 +1851,7 @@ open-fade-in"` composes slide and fade. Applied at zero specificity with identit
1060
1851
  Also new, and worth wiring at the same time — **`gitLastmod()` for real sitemap dates**:
1061
1852
 
1062
1853
  ```js
1063
- import vitops, { gitLastmod } from "@getvitops/astro";
1854
+ import vitops, { gitLastmod } from '@getvitops/astro';
1064
1855
  vitops({ sitemap: { serialize: await gitLastmod() } });
1065
1856
  ```
1066
1857
 
@@ -1126,7 +1917,7 @@ open-fade-in"` composes slide and fade. Applied at zero specificity with identit
1126
1917
  toolchain bump. Use `--force` when you mean to re-encode.
1127
1918
 
1128
1919
  **`ffmpeg` is an external tool, not an npm dependency** — install it yourself (`brew install
1129
- ffmpeg`, `apt install ffmpeg`, `winget install Gyan.FFmpeg`). The command fails without it rather
1920
+ ffmpeg`, `apt install ffmpeg`, `winget install Gyan.FFmpeg`). The command fails without it rather
1130
1921
  than skipping: a page referencing a video that was never encoded is broken, not degraded.
1131
1922
 
1132
1923
  Two defaults worth knowing about. The MP4 exists because older iOS and the in-app webviews inside
@@ -1160,8 +1951,8 @@ ffmpeg`, `apt install ffmpeg`, `winget install Gyan.FFmpeg`). The command fails
1160
1951
 
1161
1952
  ```js
1162
1953
  vitops({
1163
- analytics: { googleAnalytics: "G-XXXXXXXXXX", plausible: "acme.com" },
1164
- consent: { policyUrl: "/legal/cookies" },
1954
+ analytics: { googleAnalytics: 'G-XXXXXXXXXX', plausible: 'acme.com' },
1955
+ consent: { policyUrl: '/legal/cookies' },
1165
1956
  });
1166
1957
  ```
1167
1958
 
@@ -1219,8 +2010,8 @@ ffmpeg`, `apt install ffmpeg`, `winget install Gyan.FFmpeg`). The command fails
1219
2010
  replace it with `--anim-range`. `--stagger-range-step` is now a viewport length (`5vh`) to match.
1220
2011
 
1221
2012
  **Fixed: every journey ran on a truncated range.** The generator emitted `animation-range: entry
1222
- exit`; lightningcss parsed the end of that shorthand as `exit 0%` rather than the spec's `exit
1223
- 100%`, so the bundle shipped `entry exit 0%`. Journeys reach their `100%` keyframe — the hidden
2013
+ exit`; lightningcss parsed the end of that shorthand as `exit 0%` rather than the spec's `exit
2014
+ 100%`, so the bundle shipped `entry exit 0%`. Journeys reach their `100%` keyframe — the hidden
1224
2015
  `from` state — as the element hits the top of the viewport, which is to say they faded out while
1225
2016
  still fully on screen. Journeys now run `entry calc(50% + 10vh) → exit 100%`: they start on the same
1226
2017
  midpoint pivot as everything else and keep the full entry → hold → exit arc, so the hold sits in the
@@ -1233,7 +2024,7 @@ exit`; lightningcss parsed the end of that shorthand as `exit 0%` rather than th
1233
2024
  so one knob tunes both. If your own config has an empty `slide` base, add the same line.
1234
2025
 
1235
2026
  **Fixed: `reveal-*` on hover was unreachable.** A `hover-reveal-left` element rests at `clip-path:
1236
- inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting — so it had zero
2027
+ inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting — so it had zero
1237
2028
  hittable area and could never receive the hover that would reveal it. The state variants now match
1238
2029
  the element **or its direct parent**, mirroring what `animation.css` already did for the trigger
1239
2030
  driver (`:is(.is-active, [data-active]) > .animate-trigger`):
@@ -1301,7 +2092,7 @@ inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting
1301
2092
 
1302
2093
  ```js
1303
2094
  // astro.config.mjs
1304
- getvitops({ css: { input: "company.json", format: "css" } });
2095
+ getvitops({ css: { input: 'company.json', format: 'css' } });
1305
2096
  ```
1306
2097
 
1307
2098
  ```
@@ -1400,11 +2191,11 @@ inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting
1400
2191
  getvitops({
1401
2192
  fonts: [
1402
2193
  {
1403
- name: "League Spartan",
1404
- provider: "fontsource",
1405
- cssVariable: "--font-league-spartan",
1406
- weights: ["100 900"],
1407
- subsets: ["latin"],
2194
+ name: 'League Spartan',
2195
+ provider: 'fontsource',
2196
+ cssVariable: '--font-league-spartan',
2197
+ weights: ['100 900'],
2198
+ subsets: ['latin'],
1408
2199
  preload: true,
1409
2200
  },
1410
2201
  ],
@@ -1584,7 +2375,7 @@ inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting
1584
2375
  - bf453b0: Fixed: a site on `css.format: 'css'` failed to build because of Tailwind.
1585
2376
 
1586
2377
  `getvitops()` loads `@tailwindcss/vite` lazily, so a project that never sets `css.format:
1587
- 'tailwind'` should never touch it. But the specifier was a literal, which makes it statically
2378
+ 'tailwind'` should never touch it. But the specifier was a literal, which makes it statically
1588
2379
  analysable — so the bundler **followed** the import regardless of the branch ever running, down
1589
2380
  through `@tailwindcss/node` to `@tailwindcss/oxide`'s native `.node` binding, which it cannot parse.
1590
2381
  The build died on `[UNLOADABLE_DEPENDENCY] … stream did not contain valid UTF-8`, naming a package
@@ -1632,14 +2423,14 @@ inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting
1632
2423
  // astro.config.mjs
1633
2424
  vitops({
1634
2425
  seo: {
1635
- siteName: "Acme",
1636
- titleTemplate: "%s · Acme",
1637
- defaultDescription: "We make the thing.",
2426
+ siteName: 'Acme',
2427
+ titleTemplate: '%s · Acme',
2428
+ defaultDescription: 'We make the thing.',
1638
2429
  openGraph: {
1639
- locale: "en_CA",
1640
- image: { url: "/og.png", alt: "Acme", width: 1200, height: 630 },
2430
+ locale: 'en_CA',
2431
+ image: { url: '/og.png', alt: 'Acme', width: 1200, height: 630 },
1641
2432
  },
1642
- twitter: { site: "@acme" },
2433
+ twitter: { site: '@acme' },
1643
2434
  },
1644
2435
  });
1645
2436
  ```
@@ -1683,7 +2474,7 @@ inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting
1683
2474
 
1684
2475
  ```js
1685
2476
  getvitops({ sitemap: true });
1686
- getvitops({ sitemap: { filter: (page) => !page.includes("/draft/") } });
2477
+ getvitops({ sitemap: { filter: (page) => !page.includes('/draft/') } });
1687
2478
  ```
1688
2479
 
1689
2480
  `@astrojs/sitemap` is an **optional peer** — install it yourself (`pnpm add -D @astrojs/sitemap`) and
@@ -1918,15 +2709,15 @@ inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting
1918
2709
  which `@getvitops/astro` re-exports wholesale. So from `astro.config.mjs`:
1919
2710
 
1920
2711
  ```js
1921
- import { generateIconInclude } from "@getvitops/astro";
1922
- import icon from "astro-icon";
2712
+ import { generateIconInclude } from '@getvitops/astro';
2713
+ import icon from 'astro-icon';
1923
2714
 
1924
2715
  integrations: [
1925
2716
  icon({
1926
2717
  include: generateIconInclude({
1927
- ui: "fa7-solid",
1928
- brand: "simple-icons",
1929
- semantic: ["menu", "close", "search", "github"],
2718
+ ui: 'fa7-solid',
2719
+ brand: 'simple-icons',
2720
+ semantic: ['menu', 'close', 'search', 'github'],
1930
2721
  }),
1931
2722
  }),
1932
2723
  ];
@@ -1960,7 +2751,7 @@ inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting
1960
2751
  ```js
1961
2752
  // astro.config.mjs
1962
2753
  getvitops({
1963
- css: { input: "design-system.json", format: "css", out: "src/styles" },
2754
+ css: { input: 'design-system.json', format: 'css', out: 'src/styles' },
1964
2755
  editor: true,
1965
2756
  });
1966
2757
  ```
@@ -2071,7 +2862,7 @@ inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting
2071
2862
  - `.chip-list__chip` and `.chip-list__chip-remove` are **removed**. A tag list is a list of tags, so
2072
2863
  its items are now the existing `.tag` / `.tag__remove` — which also means they pick up tag role
2073
2864
  variants. **Migration:** replace `<span class="chip-list__chip">x <button
2074
- class="chip-list__chip-remove">` with `<span class="tag">x <button class="tag__remove">`. Note the
2865
+ class="chip-list__chip-remove">` with `<span class="tag">x <button class="tag__remove">`. Note the
2075
2866
  items change appearance: `.tag` is outlined (border + neutral text) where the old chip was filled
2076
2867
  with `--color-surface-muted`.
2077
2868
  - The `radii.chip` primitive is **removed** from the example config; it was only ever an alias of