@getvitops/generator 8.1.0 → 8.2.0-alpha.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,335 @@
1
1
  # @getvitops/generator
2
2
 
3
+ ## 8.2.0-alpha.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
+ - 67e2088: **Config validation now rejects unknown keys instead of silently stripping them.** Every
57
+ object in `ConfigSchema` (the `Config` type — `organization`/`site` and everything nested under
58
+ them) switched from `z.object` to `z.strictObject`, matching what the published JSON Schema has
59
+ always asserted (`additionalProperties: false` at ~95 points) but the runtime validator never
60
+ actually enforced. A typo'd field (`site.ntoifications`, an add-on key on the wrong nesting
61
+ level, …) now fails `validateConfig()`/`vitops validate` with an `unrecognized_keys` error naming
62
+ the bad key, instead of validating successfully and doing nothing. `site.icons` stays
63
+ deliberately loose (`z.looseObject`, unchanged) — its per-set keys are open-ended by design.
64
+
65
+ **Breaking for any config carrying a field that isn't in the schema** — this is the point of the
66
+ change, but if a real config was relying on the old silent-strip behavior (an extra key kept
67
+ around for another tool to read), move it under a block the schema already permits `z.unknown()`
68
+ on (`site.cloudflare`, `site.tags`, `site.postTypes`) or open an issue.
69
+
70
+ Also:
71
+ - Added `configVersion` (optional, currently `1`) to `Config` — the document's own SHAPE
72
+ version, distinct from this package's release version (which is CSS-driven and bumps on every
73
+ design-system change regardless of `Config`'s shape). `defaultSiteConfig()` stamps it.
74
+ Exported as `CONFIG_VERSION`.
75
+ - Re-exported several `Config`-related types/values that were defined but missing from the
76
+ package root: `SiteReviews`, `SiteReviewLocation`, `SiteSearchConsoleSetup`,
77
+ `CrmProviderConfig`, `CRM_PROVIDERS`, `BIGIN_DATA_CENTERS`.
78
+ - Added a `@getvitops/generator/config` subpath export — `ConfigSchema`, `Config`,
79
+ `validateConfig`, `resolveConfig`, `isConfig`, `resolveInput` and friends, on their own, with no
80
+ `node:fs`/`node:crypto`/lightningcss in the import graph (verified: `publint` clean, and the
81
+ built chunk's own imports are `@zod/mini` + `@getvitops/utils`'s Worker-safe `ads`/`tracking`/
82
+ `color` subpaths only). This is what makes `Config` bundleable in a Cloudflare Worker — the
83
+ EmDash plugin's own admin route being the motivating case — without pulling in code that only
84
+ ever runs at build time.
85
+
86
+ - 0574388: Add `site.crm` — push form submissions into HubSpot, Zoho Bigin, or Jobber.
87
+
88
+ Client sites need form submissions to land in the client's CRM. The options available before this
89
+ — a HubSpot/Bigin/Jobber iframe or JS embed — fail on three counts: the markup can't be
90
+ white-labelled against this design system, nothing can operate on the submission before it leaves
91
+ the page, and the submission never touched `createConversionRoute()`, so `_ac` ad-click attribution
92
+ was lost and no conversion notification was planned.
93
+
94
+ This adds one server-side path for all three CRMs, hung off the conversion route that already
95
+ exists. `createConversionRoute()` gets a new `crm` option (`{ config, credentials, fetch?,
96
+ timeoutMs? }`); credentials arrive as a function of `locals`, exactly like the existing `binding`
97
+ option, and are never config. A failed CRM write never fails the request and is folded into the
98
+ notification email's new "Delivery" section, so a broken CRM token is never silent.
99
+
100
+ New `@getvitops/utils/crm` subpath: `deliverToCrm()`, `mapSubmission()`, `deriveLastName()` (Bigin
101
+ requires a last name; this derives one from an explicit field, a full name, or an email address,
102
+ refusing rather than fabricating one), `submitToHubSpot()`, `submitToBigin()`, `submitToJobber()`.
103
+ `identifySubmitter()` was promoted out of `notify`'s private name-guessing cascade into
104
+ `@getvitops/utils/tracking`, since the CRM mapper needed the whole identity (first/last name,
105
+ email, phone, company), not just a display name.
106
+
107
+ Jobber is GraphQL, not REST, and answers `200 OK` for failures — a rejected `clientCreate` returns
108
+ `userErrors[]`, a malformed request returns top-level `errors[]`. `submitToJobber()` classifies on
109
+ the response body, not just the status. Its refresh tokens also rotate by default: **Refresh Token
110
+ Rotation must be turned off** in the app's Developer Center settings, since a Worker reading a
111
+ static `VITOPS_JOBBER_REFRESH_TOKEN` cannot persist a rotated one — a rotated token is detected at
112
+ exchange time and reported with the fix, rather than surfacing an hour later as an opaque 401.
113
+ Attribution and unmapped extra fields render into a best-effort note by default (works on every
114
+ Jobber plan); a resubmitting visitor creates a second Jobber client unless `crm.jobber.dedupe: true`
115
+ is set once its lookup query has been verified against a live account (defaults `false` pending
116
+ that verification — see `jobber.ts`'s docblock).
117
+
118
+ New `site.crm` config block (`provider`, `hubspot`/`bigin`/`jobber`, `fieldMap`, `trackingFields`)
119
+ and two CLI commands: `vitops crm check` (fetches the live HubSpot property list or Bigin field
120
+ list and reports every mapped name that doesn't exist there — HubSpot rejects an unknown field
121
+ outright, Bigin silently drops it; Jobber has nothing live to check yet, since its mapped values
122
+ currently render as note text) and `vitops crm setup` (creates missing HubSpot properties; Bigin
123
+ and Jobber have no such API, so both always print a manual reminder).
124
+
125
+ The generated privacy policy now names HubSpot, Zoho Bigin, or Jobber when `site.crm` configures
126
+ one. Bigin gets the same cross-border-transfer derivation Matomo Cloud already gets: a
127
+ `dataCenter: 'ca'` asserts no transfer outside Canada, any other data centre does. Jobber's
128
+ operator (Octopusapp Inc. d/b/a Jobber) is Canadian, but its own privacy policy names the United
129
+ States as a processing location too, so the policy always asserts a cross-border transfer for
130
+ Jobber regardless of config — a Canadian operator is not read as Canadian-only storage.
131
+
132
+ Credentials: `VITOPS_HUBSPOT_ACCESS_TOKEN` for HubSpot; `VITOPS_ZOHO_CLIENT_ID` /
133
+ `VITOPS_ZOHO_CLIENT_SECRET` / `VITOPS_ZOHO_REFRESH_TOKEN` for Bigin; `VITOPS_JOBBER_CLIENT_ID` /
134
+ `VITOPS_JOBBER_CLIENT_SECRET` / `VITOPS_JOBBER_REFRESH_TOKEN` for Jobber. Never config.
135
+
136
+ No consumer-facing change in `@getvitops/astro` beyond the new `crm` route option and a build
137
+ warning when `site.crm` is configured with no route answering `TRACKING_ENDPOINT` — CSS/Bricks/
138
+ tailwind outputs are unaffected.
139
+
140
+ - c3c1bcb: **New: the design-system editor, mounted inside the EmDash admin.** `vitopsEmdash({
141
+ designSystemEditor: { config } })` (`config` is your `site.json`, e.g. `import config from
142
+ './site.json' with { type: 'json' }`) adds a `/design-system` admin page mounting
143
+ `<wc-theme-editor>` — the same live token editor `@getvitops/vite`'s dev server has always
144
+ offered, now usable by the client directly, no dev server required. An edit is visible
145
+ site-wide immediately (a runtime `:root` override, served via a new plugin API route and
146
+ `page:fragments`) and — once `site.deployment.repo`/`branch` and a `VITOPS_GITHUB_TOKEN`
147
+ secret are set (see the scaffold's `SETUP.html`) — auto-commits to your `dev` branch, so it
148
+ reaches the git-tracked `site.json` through your repo's normal review path rather than
149
+ becoming a second, unreviewed source of truth. Until that's configured, the live half still
150
+ works; only the auto-commit half is skipped (and says so in the admin page). The save route
151
+ runs the exact same accessibility (APCA contrast) gate `generate()` enforces at build time,
152
+ synchronously, before persisting — nothing non-compliant ever goes live even momentarily.
153
+
154
+ Requires `@astrojs/react` + `react` + `react-dom` (already scaffold dependencies, for
155
+ EmDash's own admin) and `@getvitops/core` (added as a new scaffold dependency this release).
156
+ Declares two new plugin capabilities when enabled: `network:request` (for the GitHub
157
+ auto-commit) and `hooks.page-fragments:register` (for the live override) — reviewed once at
158
+ plugin-install time.
159
+
160
+ **Breaking for `@getvitops/emdash`**: `vitopsEmdash()`'s `hooks.page-fragments:register`
161
+ capability is now also granted whenever `designSystemEditor` is set, independent of the
162
+ existing `scripts: 'fragments'` option (which governs something unrelated — web-component
163
+ script delivery). No change if you don't use `designSystemEditor`.
164
+
165
+ Also new, supporting the above:
166
+ - `@getvitops/generator`: `validateColorContract()` and `buildReverseIndex()` (both
167
+ `/tokens` subpath, Worker-safe) — the monotonicity/contrast gate and the
168
+ `design-manifest.json` reverse-index, each extracted from `generate()`'s `build()` into
169
+ standalone, non-throwing functions. `patchToOverrideCss()` turns a design-system patch
170
+ into the `:root` override `<style>` block. `designSystemPath`/`getAt`/`setAt`/
171
+ `mergePatch`/`formatConfig` moved from `@getvitops/vite` into `@getvitops/generator/config`
172
+ (re-exported from `@getvitops/vite` unchanged) — one "patch a config file's design-system
173
+ subtree" implementation shared by the dev endpoint and the new plugin route. New
174
+ `site.deployment.repo`/`branch` config fields.
175
+ - `@getvitops/utils`: new `/git` subpath — `commitFile()`, a Worker-safe (fetch-only)
176
+ wrapper over GitHub's Contents API.
177
+ - `@getvitops/core`: `<wc-theme-editor>` gains a `save-endpoint` attribute (default
178
+ unchanged — `/__vitops/design-system`) so the same element can target the new plugin
179
+ route instead of the dev-only Vite endpoint.
180
+
181
+ - 0574388: Add `vitops email setup|check` — connect `site.notifications.email.from` to whether its domain is
182
+ actually onboarded onto an outbound email provider.
183
+
184
+ Before this, nothing checked that. `EMAIL.send()` throws `E_SENDER_NOT_VERIFIED` at runtime with no
185
+ build or deploy-time warning — the failure mode is silent and production-only.
186
+
187
+ `vitops email setup [domain]` onboards a sending domain (currently Cloudflare Email Sending only)
188
+ and polls DNS until its SPF/DKIM/DMARC records resolve or a timeout is reached; `vitops email
189
+ check` is the non-blocking deploy guard both `@getvitops/create` templates' `deploy`/`deploy:dev`
190
+ scripts now run first (`--strict` makes it a real gate; opt out per-site with
191
+ `site.notifications.email.verifySender: false`, or globally with `--skip` /
192
+ `VITOPS_SKIP_EMAIL_CHECK`).
193
+
194
+ Two things worth knowing before running it:
195
+ - **The onboarded entity is an exact name, and it is usually a subdomain**
196
+ (`send.yourdomain.com`), never the apex your `from` address names. When the exact domain isn't
197
+ onboarded but a sibling in the same zone already is, the command reports that as the fix — send
198
+ from the onboarded subdomain — rather than silently trying to onboard the apex, which rewrites
199
+ `_dmarc.<domain>` and can tighten DMARC policy on every other mail your domain sends.
200
+ - Candidates come from `site.notifications.email.from` (or its derived `noreply@<domains
201
+ .canonical>` default) first; a best-effort scan of `--src` only supplements it, and never guesses
202
+ at a computed `from` value.
203
+
204
+ New `@getvitops/utils/email` subpath and `site.notifications.email.verifySender` config field.
205
+ Needs `CLOUDFLARE_API_TOKEN` with an Email Sending scope in addition to the `Zone:Read` other
206
+ commands already use.
207
+
208
+ - dde3df3: **`site.ads.google.conversionLabel` now accepts a per-action map** —
209
+ `{ call?, email?, form? }` — instead of one bare string shared by every
210
+ interaction. A bare string still works (back-compat, treated as `call`), but a
211
+ call and an email click used to report as the **same** Google Ads conversion
212
+ action, indistinguishable in the account; each action now gets its own
213
+ `send_to` target.
214
+
215
+ **New: `vitopsFormConversion(formEl)`**, exposed as `window.vitopsFormConversion`
216
+ by `<Tracking />`'s capture script — call it from your own fetch-based form
217
+ submit handler once the POST to `createConversionRoute()` succeeds, to fire the
218
+ `form` Google Ads conversion action client-side (mirroring the existing
219
+ tel:/mailto: click handler, which now fires `call`/`email` instead of sharing
220
+ one label). Idempotent per form element. There's deliberately no server-side
221
+ equivalent for a no-JS `<form method="post">` submission — that's a full-page
222
+ redirect with no script to fire from, and a real server-side Google Ads
223
+ conversion needs the Ads API's click-conversion upload (developer token + MCC
224
+ access), tracked separately in the vNext backlog.
225
+
226
+ **Fixed: the conversion route's rate limiter no longer trusts the client's
227
+ raw `event` field.** It used to key on whatever string a client sent
228
+ (`` `${ip}:${body.event}` ``), so varying that value on every request let a
229
+ form submission dodge the per-IP rate limit entirely — only `'call'`/`'email'`
230
+ were ever recognized, and anything else got its own fresh bucket. The key now
231
+ uses the server's own classification (`call` | `email` | `form`), closing that
232
+ bypass without changing the existing call-vs-email-don't-compete behavior.
233
+
234
+ **New: `site.notifications.actionUrl`** — an optional action-link template
235
+ added to the conversion-notification email as an "Actions" section.
236
+ `{clickId}` is replaced with the ad click ID captured on `_ac` (gclid, fbclid,
237
+ etc.); the section is omitted when the event has no click ID to interpolate,
238
+ rather than emailing a broken link.
239
+
240
+ - d9f213b: **New: `site.plan`** — records a commercial plan/add-on entitlement (`{ key, addons? }`),
241
+ distinct from a section's own `enabled` flag: `enabled` answers "does this site do X at all"
242
+ (a capability switch); `plan` answers "is this client entitled to turn X on" (a commercial
243
+ switch one layer above). Absence means unrestricted, matching every other opt-in block in
244
+ `Config`.
245
+
246
+ `PLAN_CAPABILITIES` (new export) is the small, hand-authored table of which add-on gates
247
+ which `Config` field — today: `ab_testing` gates `site.abTesting.enabled`, any
248
+ `environments.*.variant`, **and any enabled `site.experiments` entry** (that last one was
249
+ missed in this feature's first pass and fixed before release — found while planning this
250
+ toolchain's downstream adoption, not by a consumer hitting it), `server_side_tracking` gates
251
+ `site.tracking.enabled`. `validateConfig` now rejects a config that turns on a gated feature
252
+ without listing the owning add-on in `site.plan.addons`, naming the add-on in the error.
253
+
254
+ **Breaking**: an existing config using `site.abTesting.enabled`/`environments.*.variant` or
255
+ `site.tracking.enabled` now needs a matching `site.plan.addons` entry, or `validateConfig`
256
+ rejects it. Add `site.plan: { key: '<your-plan>', addons: ['ab_testing'] }` (and/or
257
+ `'server_side_tracking'`) to a config already using either feature.
258
+
259
+ ### Patch Changes
260
+
261
+ - 0574388: Bump `emdash`/`@emdash-cms/cloudflare` peers to `>=1.0.0 <2` and `vite-plus` to `^1.0.0`, following
262
+ both projects' GA releases.
263
+
264
+ **Breaking for `@getvitops/emdash` consumers on EmDash 0.x**: the peer range no longer admits
265
+ `emdash@0.31.x`/`@emdash-cms/cloudflare@0.31.x`. Upgrade `emdash` and `@emdash-cms/cloudflare` to
266
+ `^1.0.1` first — nothing in `@getvitops/emdash`'s own plugin surface (`admin.portableTextBlocks`,
267
+ the `hooks.page-fragments:register` capability, `definePlugin()`) changed against EmDash 1.0, so no
268
+ code here needed to change, only the declared range.
269
+
270
+ The `emdash` scaffold template (`vp create @getvitops:emdash`) picks up several fixes alongside
271
+ the version bump:
272
+ - `tailwindcss`/`@tailwindcss/vite` are now real dependencies — the template selected
273
+ `css.format: 'tailwind'` without installing either, which threw on build.
274
+ - `site.json` now sets `site.icons.sprite: true` plus the full `semantic` list the
275
+ `vitops.actionLink` block's icon picker offers — previously its sprite icons pointed at a file
276
+ the build never generated.
277
+ - `astro.config.mjs` sets `migrations: { runtime: 'check', dev: 'auto' }`, and
278
+ `site-deploy-emdash.yml` runs EmDash 1.0's documented `build → status → apply → deploy → check`
279
+ migration sequence explicitly, rather than relying solely on first-request auto-migration. This
280
+ needs a new GitHub Environment variable, `EMDASH_TARGET_FINGERPRINT` (one per environment),
281
+ which only a human can set — run `emdash migrate --status` against the real deployed database,
282
+ review the printed identity, then `gh variable set EMDASH_TARGET_FINGERPRINT --env <env> --body
283
+ <value>`. Deploys fail clearly on the migration-apply step until it's set.
284
+ - `lint.options.typeAware`/`typeCheck` in the template's own `vite.config.ts` are commented out,
285
+ not deleted: vite-plus 1.0 only honours that option in a project's actual root config, and hard
286
+ errors on it in a nested one (which this file is, sitting inside the `design-system` monorepo).
287
+ Uncomment it once scaffolded into its own repo.
288
+
289
+ No config schema changes. `@getvitops/generator` also switches from the `zod/mini` subpath import
290
+ to the standalone `@zod/mini` package (zod 4.5+; same API) and fixes `validateConfig()` silently
291
+ losing the offending key name on an `unrecognized_keys` error (e.g. a typo'd `site.ads` platform
292
+ now names the bad key instead of reporting a bare "Invalid input").
293
+
294
+ Also fixed: `vp check`'s `tsconfig-error` for `packages/create/templates/emdash/tsconfig.json`
295
+ (`extends: "astro/tsconfigs/strict"`, unresolvable from inside this monorepo since the template
296
+ has no `node_modules` of its own — a pre-existing, structural gap, newly surfaced as a hard error
297
+ by oxlint 1.85's deeper config discovery, and blocking on every commit since this repo's pre-commit
298
+ hook runs `vp check --fix`). Fixed with a new `scripts/link-template-deps.mjs`, wired as the root
299
+ `postinstall` script: symlinks `astro`/`emdash`/`@emdash-cms/cloudflare` into the template's own
300
+ `node_modules/` (resolved fresh from sibling packages that already depend on them for real, not a
301
+ hardcoded `.pnpm` hash) and writes a minimal `astro:content` ambient shim at `.astro/types.d.ts`
302
+ (the same path `astro sync` would generate for real). Both are gitignored and only ever exist
303
+ inside this monorepo's copy of the template — a real scaffold installs its own real copies instead.
304
+ Considered and rejected first: inlining astro's strict preset (drift risk against astro's own
305
+ evolving default) and making the template a real pnpm workspace member (would change how its
306
+ `@getvitops/*: "latest"` deps resolve locally, which `templates.test.ts` exists to protect).
307
+
308
+ **Known gap, not fixed here**: `packages/astro/src/components/container.test.ts` (the
309
+ `AstroContainer`-based render tests) fails to even load under vite-plus 1.0's Vite 8 — direct
310
+ `.astro` imports in a Vitest file need the Astro Vite plugin registered, which nothing in this
311
+ repo's test config actually does (Astro's own docs call for `getViteConfig()`, which doesn't
312
+ compose with vite-plus's own `defineConfig`). A type-level shim
313
+ (`packages/astro/src/components/astro-modules.d.ts`) fixes `vp check`'s type-aware pass, but the
314
+ runtime `vp test` failure for this one file is still open.
315
+
316
+ - 1a3e811: **Fixed: `<Icon />` no longer crashes a Cloudflare Workers SSR page.** It statically imported
317
+ `loadIconSvg`/`spriteId` from `@getvitops/generator`'s root entry, which also re-exports
318
+ `generate.ts` — reaching lightningcss, whose native binary Workers cannot `require()`.
319
+ Rendering `<Icon>` anywhere in an SSR project 500'd every request, regardless of
320
+ `icons.engine`, since the import itself (not just calling the function) pulled lightningcss
321
+ into the bundle. Fixed by publishing a new Worker-safe `@getvitops/generator/icons-sprite`
322
+ subpath (`buildIconSprite`/`loadIconSvg`/`spriteId`, no lightningcss in its import graph) and
323
+ switching `<Icon />` to import from there.
324
+ - Updated dependencies [0574388]
325
+ - Updated dependencies [c3c1bcb]
326
+ - Updated dependencies [0574388]
327
+ - Updated dependencies [0574388]
328
+ - Updated dependencies [0574388]
329
+ - Updated dependencies [0574388]
330
+ - Updated dependencies [dde3df3]
331
+ - @getvitops/utils@8.2.0-alpha.0
332
+
3
333
  ## 8.1.0
4
334
 
5
335
  ### Minor Changes
@@ -45,7 +375,6 @@
45
375
 
46
376
  **Breaking (`css`/`bricks` and `tailwind` formats — `.lightbox-dialog`'s markup
47
377
  contract changed):**
48
-
49
378
  - `<Gallery />` now emits ONE `<dialog class="lightbox-dialog">` per gallery,
50
379
  wrapping a `<Carousel />`, instead of one dialog per image. A `.gallery__trigger`
51
380
  is now a plain `<a href="#slide-id">`, not a `command="show-modal"` button.
@@ -86,7 +415,6 @@
86
415
  ### Minor Changes
87
416
 
88
417
  - Add Google Business Profile reviews: fetch, cache, render.
89
-
90
418
  - **`vitops reviews`** (new CLI command) fetches a client's GBP reviews (legacy v4 API — the only
91
419
  one that has them) and writes them to `.vitops/reviews.json`, meant to be committed so a build
92
420
  never needs a live Google credential. `--dry` prints the plan; `--check` diffs against the cache
@@ -121,7 +449,6 @@
121
449
  `--site` for now.)
122
450
 
123
451
  **Fixed:**
124
-
125
452
  - `@getvitops/core`'s 12 runtime deps (`lit`, both `@oddbird` polyfills, both
126
453
  `@webcomponents` packages, `colorjs.io`, etc.) were listed as `dependencies` despite
127
454
  being bundled into `dist/elements.js`/`dist/polyfills.js`/`dist/deferred.js` — a
@@ -1161,20 +1488,28 @@ hidden` would pull it out of the accessible-name computation along with the visu
1161
1488
  {
1162
1489
  "designSystem": {
1163
1490
  "themes": {
1164
- "default": {/* … */},
1491
+ "default": {
1492
+ /* … */
1493
+ },
1165
1494
  },
1166
1495
  },
1167
1496
  "organization": {
1168
1497
  "name": "Acme",
1169
1498
  "contact": "hq",
1170
1499
  "locations": {
1171
- "hq": {/* … */},
1500
+ "hq": {
1501
+ /* … */
1502
+ },
1172
1503
  },
1173
1504
  },
1174
1505
  "site": {
1175
1506
  "defaultLocale": "en",
1176
- "domains": {/* … */},
1177
- "analytics": {/* … */},
1507
+ "domains": {
1508
+ /* … */
1509
+ },
1510
+ "analytics": {
1511
+ /* … */
1512
+ },
1178
1513
  },
1179
1514
  }
1180
1515
  ```
@@ -1339,7 +1674,7 @@ open-fade-in"` composes slide and fade. Applied at zero specificity with identit
1339
1674
  Also new, and worth wiring at the same time — **`gitLastmod()` for real sitemap dates**:
1340
1675
 
1341
1676
  ```js
1342
- import vitops, { gitLastmod } from "@getvitops/astro";
1677
+ import vitops, { gitLastmod } from '@getvitops/astro';
1343
1678
  vitops({ sitemap: { serialize: await gitLastmod() } });
1344
1679
  ```
1345
1680
 
@@ -1524,8 +1859,8 @@ open-fade-in"` composes slide and fade. Applied at zero specificity with identit
1524
1859
 
1525
1860
  ```js
1526
1861
  vitops({
1527
- analytics: { googleAnalytics: "G-XXXXXXXXXX", plausible: "acme.com" },
1528
- consent: { policyUrl: "/legal/cookies" },
1862
+ analytics: { googleAnalytics: 'G-XXXXXXXXXX', plausible: 'acme.com' },
1863
+ consent: { policyUrl: '/legal/cookies' },
1529
1864
  });
1530
1865
  ```
1531
1866
 
@@ -1665,7 +2000,7 @@ inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting
1665
2000
 
1666
2001
  ```js
1667
2002
  // astro.config.mjs
1668
- getvitops({ css: { input: "company.json", format: "css" } });
2003
+ getvitops({ css: { input: 'company.json', format: 'css' } });
1669
2004
  ```
1670
2005
 
1671
2006
  ```
@@ -1716,11 +2051,11 @@ inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting
1716
2051
  getvitops({
1717
2052
  fonts: [
1718
2053
  {
1719
- name: "League Spartan",
1720
- provider: "fontsource",
1721
- cssVariable: "--font-league-spartan",
1722
- weights: ["100 900"],
1723
- subsets: ["latin"],
2054
+ name: 'League Spartan',
2055
+ provider: 'fontsource',
2056
+ cssVariable: '--font-league-spartan',
2057
+ weights: ['100 900'],
2058
+ subsets: ['latin'],
1724
2059
  preload: true,
1725
2060
  },
1726
2061
  ],
@@ -2591,7 +2926,7 @@ inset(0 100% 0 0)`, and `clip-path` clips **hit-testing** as well as painting
2591
2926
  ```js
2592
2927
  // astro.config.mjs
2593
2928
  getvitops({
2594
- css: { input: "design-system.json", format: "css", out: "src/styles" },
2929
+ css: { input: 'design-system.json', format: 'css', out: 'src/styles' },
2595
2930
  editor: true,
2596
2931
  });
2597
2932
  ```
@@ -1 +1 @@
1
- const e=[`necessary`,`analytics`,`marketing`,`preferences`],t=`vitops:consent`,n=`vitops:consent-open`,r=`vitops_consent`,i=[`analytics`,`marketing`,`preferences`];function a(e){return{analytics:e,marketing:e,preferences:e}}function o(){return{ts:null,choices:a(null)}}function s(e,t){for(let n of e.split(`;`)){let e=n.trim();if(e.slice(0,t.length+1)===`${t}=`)return e.slice(t.length+1)}return null}function c(e){if(!e)return o();let t;try{t=JSON.parse(decodeURIComponent(e))}catch{return o()}if(typeof t!=`object`||!t)return o();let n=t;if(n.v!==2||typeof n.c!=`object`||n.c===null)return o();let r=n.c,s=a(null);for(let e of i){let t=r[e];typeof t==`boolean`&&(s[e]=t)}return{ts:typeof n.ts==`number`?n.ts:null,choices:s}}function l(e){let t={};for(let n of i){let r=e.choices[n];typeof r==`boolean`&&(t[n]=r)}return encodeURIComponent(JSON.stringify({v:2,ts:e.ts??0,c:t}))}function u(e,t){return t===`necessary`?!0:e.choices[t]===!0}function d(e,t){return t===`necessary`?!0:e.choices[t]!==null}function f(e,t){for(let n of t)if(!d(e,n))return!0;return!1}function p(e,t){return{ts:t,choices:a(e)}}function m(e,t,n){let r={...e.choices};for(let e of i){let n=t[e];typeof n==`boolean`&&(r[e]=n)}return{ts:n,choices:r}}function h(e,t){return i.filter(n=>u(e,n)&&!u(t,n))}function g(e){return`; path=/; max-age=15552000; SameSite=Lax${e?`; Secure`:``}`}const _=location.protocol===`https:`;let v=c(s(document.cookie,r));const y=new Set,b=new WeakSet,x=new Set,S=new Set,C=new Map,w=new Set;function T(e){w.has(e)||(w.add(e),queueMicrotask(()=>{document.querySelector(`wc-consent`)||console.warn(`[vitops] something requested the "${e}" consent category, but this page has no <wc-consent> element to ask with — so the request can never be granted and whatever waited on it will not run. Render <CookieConsent /> on every page that gates anything.`)}))}function E(e){S.has(e)||(S.add(e),T(e),B())}function D(){for(let[e,t]of C)if(d(v,e)){C.delete(e);for(let n of t)n(u(v,e))}}const O={idle:[],async:[],interaction:[]},k={idle:!1,async:!0,interaction:!1};function A(e){if(k[e])return;k[e]=!0;let t=O[e];O[e]=[];for(let e of t)e()}function j(e,t){k[e]?t():O[e].push(t)}function M(e){document.readyState===`complete`?e():addEventListener(`load`,e,{once:!0})}M(()=>{let e=globalThis.requestIdleCallback;e?e(()=>A(`idle`),{timeout:3e3}):setTimeout(()=>A(`idle`),1)});{let e=[`pointerdown`,`keydown`,`scroll`,`touchstart`],t=()=>{for(let n of e)removeEventListener(n,t);A(`interaction`)};for(let n of e)addEventListener(n,t,{once:!0,passive:!0});M(()=>setTimeout(()=>A(`interaction`),8e3))}const N=new Set([`type`,`src`,`data-vitops-tag`,`data-src`,`data-strategy`,`data-consent`,`data-consent-src`,`data-consent-cookies`]);function P(t){let n=t.getAttribute(`data-consent`);return e.includes(n??``)?n:`analytics`}function F(e){let t=e.textContent??``,n=e.getAttribute(`data-src`),r=t=>{let n=document.createElement(`script`);for(let{name:t,value:r}of Array.from(e.attributes))N.has(t)||n.setAttribute(t,r);t(n),e.parentNode?.insertBefore(n,e.nextSibling)};t.trim()&&r(e=>e.text=t),n&&r(e=>{e.async=!0,e.src=n})}function I(e){if(!b.has(e)){if(b.add(e),x.add(P(e)),e instanceof HTMLScriptElement)F(e);else{let t=e.getAttribute(`data-consent-src`);t&&e.setAttribute(`src`,t)}e.setAttribute(`data-vitops-activated`,``)}}function L(){let e=document.querySelectorAll(`script[data-vitops-tag]:not([data-vitops-activated]),[data-consent-src]:not([data-vitops-activated])`);for(let t of e){let e=t.getAttribute(`data-strategy`);j(e===`async`||e===`interaction`?e:`idle`,()=>{let e=P(t);u(v,e)?I(t):E(e)})}}function R(e){let t=new Set;for(let n of e)for(let e of document.querySelectorAll(`[data-consent="${n}"]`))for(let n of(e.getAttribute(`data-consent-cookies`)??``).split(`,`))n.trim()&&t.add(n.trim());if(!t.size)return;let n=document.cookie.split(`;`).map(e=>e.trim().split(`=`)[0]??``),r=location.hostname.split(`.`).slice(-2).join(`.`),i=[``,`; domain=${location.hostname}`,`; domain=.${r}`];for(let e of n)if([...t].some(t=>t.endsWith(`*`)?e.startsWith(t.slice(0,-1)):e===t))for(let t of i)document.cookie=`${e}=; path=/; max-age=0${t}`}function z(e,t){let n=h(v,e),i=v;v=e,t?document.cookie=`${r}=${l(e)}${g(_)}`:document.cookie=`${r}=; path=/; max-age=0`,B(),L(),n.length&&(R(n),n.some(e=>x.has(e)&&u(i,e))&&V.reloadOnRevoke&&location.reload())}function B(){D();for(let e of y)e(v);document.dispatchEvent(new CustomEvent(t,{detail:v}))}const V={categories:e,reloadOnRevoke:!0,get:()=>v,granted:e=>u(v,e),needed:()=>f(v,S),demanded:()=>e.filter(e=>S.has(e)),require(e){return E(e),u(v,e)},request(e){return E(e),d(v,e)?Promise.resolve(u(v,e)):new Promise(t=>{let n=C.get(e);n?n.push(t):C.set(e,[t])})},set:e=>z(m(v,e,Date.now()),!0),acceptAll:()=>z(p(!0,Date.now()),!0),rejectAll:()=>z(p(!1,Date.now()),!0),reset:()=>z(o(),!1),open:()=>document.dispatchEvent(new CustomEvent(n)),subscribe(e){return y.add(e),e(v),()=>void y.delete(e)}},H=window.vitopsConsent?.q;if(window.vitopsConsent=V,H)for(let[e,t]of H)t?V.request(e).then(t):V.require(e);document.addEventListener(`click`,e=>{e.target?.closest?.(`[data-consent-open]`)&&(e.preventDefault(),V.open())}),document.addEventListener(`astro:page-load`,L),L(),B();var U=class extends HTMLElement{#e;#t=`prompt`;#n=!1;get#r(){return window.vitopsConsent}get#i(){return this.querySelector(`[popover]`)}connectedCallback(){let e=this.#r;if(!e)return;let t=this.#i;t&&typeof t.showPopover!=`function`&&t.removeAttribute(`popover`),this.getAttribute(`reload-on-revoke`)===`false`&&(e.reloadOnRevoke=!1),this.addEventListener(`click`,this.#p),this.addEventListener(`submit`,this.#o),document.addEventListener(n,this.#a),this.#e=e.subscribe(()=>{this.#n&&this.#t===`settings`?this.#f():e.needed()?this.#u(`prompt`):this.#d()})}disconnectedCallback(){this.removeEventListener(`click`,this.#p),this.removeEventListener(`submit`,this.#o),document.removeEventListener(n,this.#a),this.#e?.()}#a=()=>this.#u(`settings`);#o=e=>e.preventDefault();#s(e){return this.querySelector(`[data-consent-row="${e}"]`)??this.#c(e)?.closest(`label`)??null}#c(e){return this.querySelector(`input[name="${e}"]`)}#l(e){let t=this.#r;if(!t)return[];if(e===`settings`){let e=i.filter(e=>this.#s(e));return e.length?e:[...i]}let n=t.get();return i.filter(e=>t.demanded().includes(e)&&!d(n,e))}#u(e){if(!this.#l(e).length)return;this.#t=e,this.#f(),this.hidden=!1,this.#n=!0;let t=this.#i;t?.isConnected&&!t.matches(`:popover-open`)&&t.showPopover()}#d(){this.#n=!1,this.#i?.hidePopover?.(),this.hidden=!0}#f(){let e=this.#r;if(!e)return;let t=this.#l(this.#t);for(let n of i){let r=this.#s(n);r&&(r.hidden=!t.includes(n));let i=this.#c(n);i&&(i.checked=e.granted(n))}this.#i?.classList.toggle(`consent--single`,t.length<2)}#p=e=>{let t=this.#r,n=e.target?.closest?.(`[data-consent-accept],[data-consent-reject],[data-consent-save]`);if(!t||!n)return;e.preventDefault();let r=this.#l(this.#t);n.hasAttribute(`data-consent-accept`)?t.set(this.#m(r,()=>!0)):n.hasAttribute(`data-consent-reject`)?t.set(this.#m(r,()=>!1)):t.set(this.#m(r,e=>this.#c(e)?.checked===!0)),this.#d()};#m(e,t){let n={};for(let r of e)n[r]=t(r);return n}};customElements.get(`wc-consent`)||customElements.define(`wc-consent`,U);export{e as CATEGORIES,t as CONSENT_EVENT,n as CONSENT_OPEN_EVENT,r as COOKIE_NAME,V as consent};
1
+ const e=[`necessary`,`analytics`,`marketing`,`preferences`],t=`vitops:consent`,n=`vitops:consent-open`,r=`vitops_consent`,i=[`analytics`,`marketing`,`preferences`];function a(e){return{analytics:e,marketing:e,preferences:e}}function o(){return{ts:null,choices:a(null)}}function s(e,t){for(let n of e.split(`;`)){let e=n.trim();if(e.slice(0,t.length+1)===`${t}=`)return e.slice(t.length+1)}return null}function c(e){if(!e)return o();let t;try{t=JSON.parse(decodeURIComponent(e))}catch{return o()}if(typeof t!=`object`||!t)return o();let n=t;if(n.v!==2||typeof n.c!=`object`||n.c===null)return o();let r=n.c,s=a(null);for(let e of i){let t=r[e];typeof t==`boolean`&&(s[e]=t)}return{ts:typeof n.ts==`number`?n.ts:null,choices:s}}function l(e){let t={};for(let n of i){let r=e.choices[n];typeof r==`boolean`&&(t[n]=r)}return encodeURIComponent(JSON.stringify({v:2,ts:e.ts??0,c:t}))}function u(e,t){return t===`necessary`||e.choices[t]===!0}function d(e,t){return t===`necessary`||e.choices[t]!==null}function f(e,t){for(let n of t)if(!d(e,n))return!0;return!1}function p(e,t){return{ts:t,choices:a(e)}}function m(e,t,n){let r={...e.choices};for(let e of i){let n=t[e];typeof n==`boolean`&&(r[e]=n)}return{ts:n,choices:r}}function h(e,t){return i.filter(n=>u(e,n)&&!u(t,n))}function g(e){return`; path=/; max-age=15552000; SameSite=Lax${e?`; Secure`:``}`}const _=location.protocol===`https:`;let v=c(s(document.cookie,r));const y=/* @__PURE__ */ new Set,b=/* @__PURE__ */ new WeakSet,x=/* @__PURE__ */ new Set,S=/* @__PURE__ */ new Set,C=/* @__PURE__ */ new Map,w=/* @__PURE__ */ new Set;function T(e){w.has(e)||(w.add(e),queueMicrotask(()=>{document.querySelector(`wc-consent`)||console.warn(`[vitops] something requested the "${e}" consent category, but this page has no <wc-consent> element to ask with — so the request can never be granted and whatever waited on it will not run. Render <CookieConsent /> on every page that gates anything.`)}))}function E(e){S.has(e)||(S.add(e),T(e),B())}function D(){for(let[e,t]of C)if(d(v,e)){C.delete(e);for(let n of t)n(u(v,e))}}const O={idle:[],async:[],interaction:[]},k={idle:!1,async:!0,interaction:!1};function A(e){if(k[e])return;k[e]=!0;let t=O[e];O[e]=[];for(let e of t)e()}function j(e,t){k[e]?t():O[e].push(t)}function M(e){document.readyState===`complete`?e():addEventListener(`load`,e,{once:!0})}M(()=>{let e=globalThis.requestIdleCallback;e?e(()=>A(`idle`),{timeout:3e3}):setTimeout(()=>A(`idle`),1)});{let e=[`pointerdown`,`keydown`,`scroll`,`touchstart`],t=()=>{for(let n of e)removeEventListener(n,t);A(`interaction`)};for(let n of e)addEventListener(n,t,{once:!0,passive:!0});M(()=>setTimeout(()=>A(`interaction`),8e3))}const N=/* @__PURE__ */ new Set([`type`,`src`,`data-vitops-tag`,`data-src`,`data-strategy`,`data-consent`,`data-consent-src`,`data-consent-cookies`]);function P(t){let n=t.getAttribute(`data-consent`);return e.includes(n??``)?n:`analytics`}function F(e){let t=e.textContent??``,n=e.getAttribute(`data-src`),r=t=>{let n=document.createElement(`script`);for(let{name:t,value:r}of Array.from(e.attributes))N.has(t)||n.setAttribute(t,r);t(n),e.parentNode?.insertBefore(n,e.nextSibling)};t.trim()&&r(e=>e.text=t),n&&r(e=>{e.async=!0,e.src=n})}function I(e){if(!b.has(e)){if(b.add(e),x.add(P(e)),e instanceof HTMLScriptElement)F(e);else{let t=e.getAttribute(`data-consent-src`);t&&e.setAttribute(`src`,t)}e.setAttribute(`data-vitops-activated`,``)}}function L(){let e=document.querySelectorAll(`script[data-vitops-tag]:not([data-vitops-activated]),[data-consent-src]:not([data-vitops-activated])`);for(let t of e){let e=t.getAttribute(`data-strategy`);j(e===`async`||e===`interaction`?e:`idle`,()=>{let e=P(t);u(v,e)?I(t):E(e)})}}function R(e){let t=/* @__PURE__ */ new Set;for(let n of e)for(let e of document.querySelectorAll(`[data-consent="${n}"]`))for(let n of(e.getAttribute(`data-consent-cookies`)??``).split(`,`))n.trim()&&t.add(n.trim());if(!t.size)return;let n=document.cookie.split(`;`).map(e=>e.trim().split(`=`)[0]??``),r=location.hostname.split(`.`).slice(-2).join(`.`),i=[``,`; domain=${location.hostname}`,`; domain=.${r}`];for(let e of n)if([...t].some(t=>t.endsWith(`*`)?e.startsWith(t.slice(0,-1)):e===t))for(let t of i)document.cookie=`${e}=; path=/; max-age=0${t}`}function z(e,t){let n=h(v,e),i=v;v=e,t?document.cookie=`${r}=${l(e)}${g(_)}`:document.cookie=`${r}=; path=/; max-age=0`,B(),L(),n.length&&(R(n),n.some(e=>x.has(e)&&u(i,e))&&V.reloadOnRevoke&&location.reload())}function B(){D();for(let e of y)e(v);document.dispatchEvent(new CustomEvent(t,{detail:v}))}const V={categories:e,reloadOnRevoke:!0,get:()=>v,granted:e=>u(v,e),needed:()=>f(v,S),demanded:()=>e.filter(e=>S.has(e)),require(e){return E(e),u(v,e)},request(e){return E(e),d(v,e)?Promise.resolve(u(v,e)):new Promise(t=>{let n=C.get(e);n?n.push(t):C.set(e,[t])})},set:e=>z(m(v,e,Date.now()),!0),acceptAll:()=>z(p(!0,Date.now()),!0),rejectAll:()=>z(p(!1,Date.now()),!0),reset:()=>z(o(),!1),open:()=>document.dispatchEvent(new CustomEvent(n)),subscribe(e){return y.add(e),e(v),()=>void y.delete(e)}},H=window.vitopsConsent?.q;if(window.vitopsConsent=V,H)for(let[e,t]of H)t?V.request(e).then(t):V.require(e);document.addEventListener(`click`,e=>{e.target?.closest?.(`[data-consent-open]`)&&(e.preventDefault(),V.open())}),document.addEventListener(`astro:page-load`,L),L(),B();var U=class extends HTMLElement{#e;#t=`prompt`;#n=!1;get#r(){return window.vitopsConsent}get#i(){return this.querySelector(`[popover]`)}connectedCallback(){let e=this.#r;if(!e)return;let t=this.#i;t&&typeof t.showPopover!=`function`&&t.removeAttribute(`popover`),this.getAttribute(`reload-on-revoke`)===`false`&&(e.reloadOnRevoke=!1),this.addEventListener(`click`,this.#p),this.addEventListener(`submit`,this.#o),document.addEventListener(n,this.#a),this.#e=e.subscribe(()=>{this.#n&&this.#t===`settings`?this.#f():e.needed()?this.#u(`prompt`):this.#d()})}disconnectedCallback(){this.removeEventListener(`click`,this.#p),this.removeEventListener(`submit`,this.#o),document.removeEventListener(n,this.#a),this.#e?.()}#a=()=>this.#u(`settings`);#o=e=>e.preventDefault();#s(e){return this.querySelector(`[data-consent-row="${e}"]`)??this.#c(e)?.closest(`label`)??null}#c(e){return this.querySelector(`input[name="${e}"]`)}#l(e){let t=this.#r;if(!t)return[];if(e===`settings`){let e=i.filter(e=>this.#s(e));return e.length?e:[...i]}let n=t.get();return i.filter(e=>t.demanded().includes(e)&&!d(n,e))}#u(e){if(!this.#l(e).length)return;this.#t=e,this.#f(),this.hidden=!1,this.#n=!0;let t=this.#i;t?.isConnected&&!t.matches(`:popover-open`)&&t.showPopover()}#d(){this.#n=!1,this.#i?.hidePopover?.(),this.hidden=!0}#f(){let e=this.#r;if(!e)return;let t=this.#l(this.#t);for(let n of i){let r=this.#s(n);r&&(r.hidden=!t.includes(n));let i=this.#c(n);i&&(i.checked=e.granted(n))}this.#i?.classList.toggle(`consent--single`,t.length<2)}#p=e=>{let t=this.#r,n=e.target?.closest?.(`[data-consent-accept],[data-consent-reject],[data-consent-save]`);if(!t||!n)return;e.preventDefault();let r=this.#l(this.#t);n.hasAttribute(`data-consent-accept`)?t.set(this.#m(r,()=>!0)):n.hasAttribute(`data-consent-reject`)?t.set(this.#m(r,()=>!1)):t.set(this.#m(r,e=>this.#c(e)?.checked===!0)),this.#d()};#m(e,t){let n={};for(let r of e)n[r]=t(r);return n}};customElements.get(`wc-consent`)||customElements.define(`wc-consent`,U);export{e as CATEGORIES,t as CONSENT_EVENT,n as CONSENT_OPEN_EVENT,r as COOKIE_NAME,V as consent};
@@ -1 +1 @@
1
- function e(e){let t=[...e.children];parseFloat(getComputedStyle(e).getPropertyValue(`--stagger-amount`));let n=[],r=e.getBoundingClientRect().top;t.forEach(e=>{let t=Math.round(e.getBoundingClientRect().top-r),i=n.find(e=>Math.abs(e.top-t)<10);i||(i={top:t,items:[]},n.push(i)),i.items.push(e)}),n.forEach(({items:e})=>{e.forEach((e,t)=>{e.style.setProperty(`--_stagger-index`,String(t))})})}let t;function n(){clearTimeout(t),t=setTimeout(()=>{requestAnimationFrame(()=>{document.querySelectorAll(`.stagger`).forEach(e)})},100)}const r=new IntersectionObserver((e,t)=>{e.forEach(e=>{if(!e.isIntersecting)return;let n=e.rootBounds,r=e.boundingClientRect;(!n||r.height>=n.height||r.top+r.height/2<=n.bottom)&&(e.target.classList.add(`is-active`),t.unobserve(e.target))})},{rootMargin:`0px 0px -10% 0px`,threshold:[0,.25,.5,.75,1]});function i(e){let t=e.querySelector(`.horizontal-scroll__track`);if(!t)return;let n=Array.from(t.children).reduce((e,t)=>e+t.offsetWidth,0);t.style.width=`${n}px`}document.addEventListener(`click`,e=>{let t=e.target.closest?.(`a[href^="#"]`);if(!t)return;let n=t.closest(`dialog[open]`);n&&n.close()}),window.addEventListener(`load`,()=>{requestAnimationFrame(()=>{document.querySelectorAll(`.stagger`).forEach(e),window.addEventListener(`resize`,n),document.querySelectorAll(`.horizontal-scroll`).forEach(i),document.querySelectorAll(`.animate-trigger`).forEach(e=>{r.observe(e)});let t=new Date().getFullYear();document.querySelectorAll(`[data-current-year]`).forEach(e=>{e.textContent=t.toString()})})});
1
+ function e(e){let t=[...e.children];parseFloat(getComputedStyle(e).getPropertyValue(`--stagger-amount`));let n=[],r=e.getBoundingClientRect().top;t.forEach(e=>{let t=Math.round(e.getBoundingClientRect().top-r),i=n.find(e=>Math.abs(e.top-t)<10);i||(i={top:t,items:[]},n.push(i)),i.items.push(e)}),n.forEach(({items:e})=>{e.forEach((e,t)=>{e.style.setProperty(`--_stagger-index`,String(t))})})}let t;function n(){clearTimeout(t),t=setTimeout(()=>{requestAnimationFrame(()=>{document.querySelectorAll(`.stagger`).forEach(e)})},100)}const r=new IntersectionObserver((e,t)=>{e.forEach(e=>{if(!e.isIntersecting)return;let n=e.rootBounds,r=e.boundingClientRect;(!n||r.height>=n.height||r.top+r.height/2<=n.bottom)&&(e.target.classList.add(`is-active`),t.unobserve(e.target))})},{rootMargin:`0px 0px -10% 0px`,threshold:[0,.25,.5,.75,1]});function i(e){let t=e.querySelector(`.horizontal-scroll__track`);if(!t)return;let n=Array.from(t.children).reduce((e,t)=>e+t.offsetWidth,0);t.style.width=`${n}px`}document.addEventListener(`click`,e=>{let t=e.target.closest?.(`a[href^="#"]`);if(!t)return;let n=t.closest(`dialog[open]`);n&&n.close()}),window.addEventListener(`load`,()=>{requestAnimationFrame(()=>{document.querySelectorAll(`.stagger`).forEach(e),window.addEventListener(`resize`,n),document.querySelectorAll(`.horizontal-scroll`).forEach(i),document.querySelectorAll(`.animate-trigger`).forEach(e=>{r.observe(e)});let t=(/* @__PURE__ */ new Date()).getFullYear();document.querySelectorAll(`[data-current-year]`).forEach(e=>{e.textContent=t.toString()})})});
@@ -1,2 +1,2 @@
1
- const e=`vitops:ds-overrides`,t=`/__vitops/design-system`,n=(e,t)=>{let n=t.indexOf(`-`),r=n===-1?t:t.slice(0,n),i=n===-1?``:t.slice(n+1);return i===`on`?`--color-${r}-on-${e}`:i.startsWith(`on-`)?`--color-${r}-on-${e}-${i.slice(3)}`:i?`--color-${r}-${e}-${i}`:`--color-${r}-${e}`},r=(e,t)=>`--color-${e}-${t}`;var i=class extends HTMLElement{constructor(...e){super(...e),this.store={light:new Map,dark:new Map},this.roles=new Map,this.roleVars=new Set,this.syncers=new Set,this.scheme=`light`,this.canSave=!1}static{this.observedAttributes=[`manifest`]}connectedCallback(){this.boot()}async boot(){let e=this.getAttribute(`manifest`)??`/vitops/design-manifest.json`;try{let t=await fetch(e);if(!t.ok)throw Error(`manifest ${t.status}`);this.manifest=await t.json()}catch(t){console.warn(`[wc-theme-editor] manifest unavailable at ${e} — editor disabled`,t);return}this.styleEl=document.createElement(`style`),this.styleEl.id=`ds-overrides`,document.head.append(this.styleEl),this.hydrate(),this.render(),this.scheme=this.currentScheme(),new MutationObserver(()=>{let e=this.currentScheme();e!==this.scheme&&(this.scheme=e,this.syncers.forEach(e=>e()))}).observe(document.documentElement,{attributes:!0,attributeFilter:[`data-theme`]}),this.canSave=await this.probeSave(),this.build(this.manifest)}async probeSave(){try{let e=await fetch(t);return e.ok?(await e.json()).writable===!0:!1}catch{return!1}}block(e,t){return t.size?`${e} {\n${[...t].map(([e,t])=>` ${e}: ${t};`).join(`
2
- `)}\n}\n`:``}serializeCSS(){return this.block(`:root`,this.store.light)+this.block(`:root[data-brx-theme="dark"], :root[data-theme="dark"]`,this.store.dark)}render(){this.styleEl&&(this.styleEl.textContent=this.serializeCSS())}persist(){try{localStorage.setItem(e,JSON.stringify({light:[...this.store.light],dark:[...this.store.dark],roles:[...this.roles]}))}catch{}}hydrate(){try{let t=localStorage.getItem(e);if(!t)return;let n=JSON.parse(t);this.store.light=new Map(n.light??[]),this.store.dark=new Map(n.dark??[]),this.roles=new Map(n.roles??[]);for(let[e,t]of this.roles)this.markRoleVars(e,t)}catch{}}setOverride(e,t,n){let r=this.store[n];t==null||t===``?r.delete(e):r.set(e,t),this.render(),this.persist()}effective(e,t,n){return this.store[t].has(e)?this.store[t].get(e):t===`dark`&&this.store.light.has(e)?this.store.light.get(e):n}reset(){this.store.light.clear(),this.store.dark.clear(),this.roles.clear(),this.roleVars.clear(),this.render(),this.persist(),this.syncers.forEach(e=>e())}currentScheme(){let e=document.documentElement;return e.dataset.theme===`dark`||e.getAttribute(`data-brx-theme`)===`dark`?`dark`:`light`}setScheme(e){this.scheme=e,document.documentElement.dataset.theme=e,document.documentElement.style.colorScheme=`${e} only`;try{localStorage.setItem(`vitops-color-scheme`,e)}catch{}this.syncers.forEach(e=>e())}roleTokenNames(e,t,r){let i=e.colors.roles.find(e=>e.name===t)?.kind??`chromatic`,a=e.colors.roleTokens?.[r]?.[i];if(!a)return[];let o=[];for(let e of[`light`,`dark`])for(let[r,i]of Object.entries(a[e]))o.push([n(t,r),i,e]);return o}markRoleVars(e,t){if(this.manifest)for(let[n]of this.roleTokenNames(this.manifest,e,t))this.roleVars.add(n)}remapRole(e,t,n){let r=this.roleTokenNames(e,t,n);if(r.length){this.roles.set(t,n);for(let[e,t,n]of r)this.roleVars.add(e),this.store[n].set(e,t);this.render(),this.persist()}}deepSet(e,t,n){let r=t.split(`.`),i=e;for(let e=0;e<r.length-1;e++){let t=r[e];(typeof i[t]!=`object`||i[t]==null)&&(i[t]={}),i=i[t]}i[r[r.length-1]]=n}buildPatch(e){let t={},n=[];for(let[n,r]of this.roles){let i=e.colors.roles.find(e=>e.name===n)?.kind??`chromatic`;this.deepSet(t,`colors.roles.${n}`,{hue:r,kind:i})}for(let[r,i]of this.store.light){if(this.roleVars.has(r))continue;let a=e.reverseIndex[r];a?this.deepSet(t,a,i):n.push(r)}for(let e of this.store.dark.keys())this.roleVars.has(e)||n.push(`${e} (dark)`);return{patch:t,skipped:n}}el(e,t={},n=[]){let r=document.createElement(e),i=r;for(let[e,n]of Object.entries(t))n===!0?i.setAttribute(e,``):n!==!1&&i.setAttribute(e,String(n));for(let e of n)i.append(e);return r}row(e,t,n){return this.syncers.add(n),n(),this.el(`label`,{class:`ed-row`},[this.el(`span`,{class:`ed-label`,title:e},[e.replace(/^--/,``)]),t])}textInput(e,t,n=``){let r=this.el(`input`,{type:`text`,class:`ed-input`});return r.addEventListener(`input`,()=>this.setOverride(e,r.value.trim()||null,this.scheme)),this.row(e,r,()=>{r.value=this.store[this.scheme].get(e)??``,r.placeholder=n||t})}colorInput(e,t){let n=this.el(`input`,{type:`color`,class:`ed-color`}),r=this.el(`input`,{type:`text`,class:`ed-input ed-mono`}),i=t=>this.setOverride(e,t||null,`light`);n.addEventListener(`input`,()=>{r.value=n.value,i(n.value)}),r.addEventListener(`input`,()=>{/^#[0-9a-f]{6}$/i.test(r.value)&&(n.value=r.value),i(r.value.trim())});let a=this.el(`span`,{class:`ed-pair`},[n,r]);return this.row(e,a,()=>{let i=this.store.light.get(e)??t;r.value=i,/^#[0-9a-f]{6}$/i.test(i)&&(n.value=i)})}selectInput(e,t){let n=this.el(`select`,{class:`ed-input`});n.append(this.el(`option`,{value:``},[`— default —`]));for(let e of t)n.append(this.el(`option`,{value:e.value},[e.label]));return n.addEventListener(`change`,()=>this.setOverride(e,n.value||null,this.scheme)),this.row(e,n,()=>{n.value=this.store[this.scheme].get(e)??``})}remSlider(e,t){let n=parseFloat(t)||1,r=this.el(`input`,{type:`range`,class:`ed-range`,min:`0`,max:String(Math.max(4,Math.ceil(n*2))),step:`0.05`}),i=this.el(`output`,{class:`ed-out`});r.addEventListener(`input`,()=>{i.textContent=`${r.value}rem`,this.setOverride(e,`${r.value}rem`,this.scheme)});let a=this.el(`span`,{class:`ed-pair`},[r,i]);return this.row(e,a,()=>{r.value=String(parseFloat(this.effective(e,this.scheme,t))||n),i.textContent=this.store[this.scheme].get(e)??`auto`})}slider(e,t){let n=t.toValue??(e=>`${e}${t.unit??``}`),r=t.fromValue??(e=>Number.isNaN(parseFloat(e))?t.def:parseFloat(e)),i=this.el(`input`,{type:`range`,class:`ed-range`,min:String(t.min),max:String(t.max),step:String(t.step)}),a=this.el(`output`,{class:`ed-out`});i.addEventListener(`input`,()=>{let t=n(parseFloat(i.value));a.textContent=t,this.setOverride(e,t,this.scheme)});let o=this.el(`span`,{class:`ed-pair`},[i,a]);return this.row(e,o,()=>{let n=this.store[this.scheme].get(e);i.value=String(n==null?t.def:r(n)),a.textContent=n??`${t.def}${t.unit??``}`})}section(e,t=!1){let n=this.el(`div`,{class:`ed-body rhythm`});return{details:this.el(`details`,{class:`details details--marker-start ed-section`,...t?{open:!0}:{}},[this.el(`summary`,{class:`font-eyebrow`},[this.el(`span`,{class:`icon-mask details-icon ed-icon ed-icon--caret`,"aria-hidden":`true`}),e]),n]),body:n}}hint(e){return this.el(`p`,{class:`font-footnote text-surface-x-muted`},[e])}sub(e){let t=this.section(e);return t.details.classList.add(`ed-sub`),t}buildPalette(e){let{details:t,body:n}=this.section(`Palette`,!0);n.append(this.hint(`Hues feed the semantic roles below — edit a hue and every role mapped to it follows. Hue edits are scheme-global: dark mode re-points which step a token reads, it never redefines the hue itself.`));for(let t of e.colors.ramps){let i=this.sub(t);for(let n of e.colors.steps){let a=e.colors.palette[t]?.[n];a!=null&&i.body.append(this.colorInput(r(t,n),a))}n.append(i.details)}return n.append(this.buildRoles(e)),t}buildRoles(e){let{details:t,body:n}=this.sub(`Semantic roles`);n.append(this.hint(`Point a role at another hue — its whole functional token set (bg / border / solid / on-solid / text) is rewritten for both appearances.`));let r=e.colors.ramps;for(let{name:t,ramp:i}of e.colors.roles){let a=this.el(`select`,{class:`ed-input`});for(let e of r)a.append(this.el(`option`,{value:e},[e]));a.addEventListener(`change`,()=>this.remapRole(e,t,a.value)),n.append(this.row(`--${t}`,a,()=>{a.value=this.roles.get(t)??i}))}return t}buildTypography(e){let{details:t,body:n}=this.section(`Typography`),r=Object.keys(e.fonts).map(e=>({label:e,value:`var(--font-${e})`})),i=e.scales.text.map(e=>({label:e.name,value:`var(--text-${e.name})`})),a={tt:[`none`,`uppercase`,`lowercase`,`capitalize`],fst:[`normal`,`italic`,`oblique`],td:[`none`,`underline`,`line-through`],tw:[`wrap`,`balance`,`pretty`,`nowrap`]};for(let t of e.typography.roles){let o=e.typography.specs[t]??{},s=this.sub(t);for(let[n,{key:c}]of Object.entries(e.typography.hooks)){let e=`--${t}-${n}`,l=o[c]==null?``:String(o[c]);n===`ff`?s.body.append(this.selectInput(e,r)):n===`fs`?s.body.append(this.selectInput(e,i)):a[n]?s.body.append(this.selectInput(e,a[n].map(e=>({label:e,value:e})))):s.body.append(this.textInput(e,l,l||c))}n.append(s.details)}return t}buildSpacing(e){let{details:t,body:n}=this.section(`Spacing`);n.append(this.hint(`Each step overrides to a fixed rem (dropping the fluid clamp). Reset restores it.`));for(let t of e.scales.space)n.append(this.remSlider(`--space-${t.name}`,t.max));return t}buildLayout(){let{details:e,body:t}=this.section(`Layout`);return t.append(this.hint(`Vertical rhythm and the .centered track widths — global structure.`)),t.append(this.slider(`--rhythm-scale`,{min:.5,max:2.5,step:.05,def:1}),this.slider(`--width-measure`,{min:30,max:110,step:1,def:65,unit:`ch`}),this.slider(`--width-breakout`,{min:40,max:150,step:1,def:90,unit:`ch`}),this.slider(`--width-spotlight`,{min:60,max:200,step:1,def:120,unit:`ch`}),this.slider(`--gutter`,{min:0,max:6,step:.1,def:3,toValue:e=>`clamp(1rem, 4cqi, ${e}rem)`,fromValue:e=>{let t=/clamp\([^,]+,[^,]+,\s*([\d.]+)/.exec(e);return t?parseFloat(t[1]):3}})),e}buildPatterns(e){let t=this.section(`Pattern defaults`);for(let[n,r]of Object.entries(e.patterns.defaults))t.body.append(this.textInput(`--${n}-default`,r,r));let n=this.section(`Radii`);for(let[t,r]of Object.entries(e.patterns.radii))n.body.append(this.textInput(`--br-${t}`,r,r));let r=this.section(`Pattern groups`);for(let[t,n]of Object.entries(e.patterns.groups)){let e=this.sub(t);for(let[r,i]of Object.entries(n))e.body.append(this.textInput(`--${r}-${t}`,i,i));r.body.append(e.details)}let i=this.section(`Components`);i.body.append(this.hint(`Per-component geometry — overrides the group defaults for just this component.`));for(let t of e.patterns.items){if(!t.wrappable.length)continue;let e=this.sub(t.name);for(let{sfx:n,prop:r}of t.wrappable){let i=t.base[r]??``;e.body.append(this.textInput(`--${n}-${t.name}`,i,i))}i.body.append(e.details)}let a=this.section(`Shadows`);for(let[t,n]of Object.entries(e.shadows))a.body.append(this.textInput(`--shadow-${t}`,n,n));return[t.details,n.details,r.details,i.details,a.details]}async flash(e,t){let n=e.textContent;e.textContent=t,setTimeout(()=>e.textContent=n,1400)}async copy(e,t){try{await navigator.clipboard.writeText(e),this.flash(t,`Copied ✓`)}catch{this.flash(t,`Copy failed`)}}async save(e,n){let{patch:r}=this.buildPatch(e);if(!Object.keys(r).length)return void this.flash(n,`Nothing to save`);try{let e=await fetch(t,{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({patch:r})});if(!e.ok){let t=await e.json().catch(()=>({}));console.error(`[wc-theme-editor] save rejected`,t.errors??e.statusText),this.flash(n,`Rejected ✗`);return}this.reset(),this.flash(n,`Saved ✓`)}catch(e){console.error(`[wc-theme-editor] save failed`,e),this.flash(n,`Failed ✗`)}}buildToolbar(e){let t=this.el(`div`,{class:`cluster-start ed-toolbar`}),n=this.el(`button`,{type:`button`,class:`btn ed-seg`},[`Light`]),r=this.el(`button`,{type:`button`,class:`btn ed-seg`},[`Dark`]),i=()=>{n.classList.toggle(`is-active`,this.scheme===`light`),r.classList.toggle(`is-active`,this.scheme===`dark`)};this.syncers.add(i),n.addEventListener(`click`,()=>this.setScheme(`light`)),r.addEventListener(`click`,()=>this.setScheme(`dark`)),i(),t.append(this.el(`span`,{class:`ed-segwrap`,role:`group`},[n,r]));let a=this.el(`button`,{type:`button`,class:`btn`},[`Copy CSS`]);a.addEventListener(`click`,()=>this.copy(this.serializeCSS()||`/* no overrides yet */`,a));let o=this.el(`button`,{type:`button`,class:`btn`},[`Copy JSON patch`]);if(o.addEventListener(`click`,()=>{let{patch:t,skipped:n}=this.buildPatch(e),r=n.length?`\n// runtime-only (no source mapping): ${n.join(`, `)}`:``;this.copy(JSON.stringify(t,null,2)+r,o)}),t.append(a,o),this.canSave){let n=this.el(`button`,{type:`button`,class:`cta`},[`Save to source`]);n.addEventListener(`click`,()=>void this.save(e,n)),t.append(n)}let s=this.el(`button`,{type:`button`,class:`btn ed-danger`},[`Reset all`]);return s.addEventListener(`click`,()=>this.reset()),t.append(s),t}build(e){let t=`ds-editor-panel`,n=this.el(`div`,{id:t,popover:`manual`,class:`drawer drawer--right drawer--modeless ed-panel bg-surface text-surface rhythm`,"aria-label":`Design-system editor`});n.append(this.el(`header`,{class:`cluster-between`},[this.el(`strong`,{class:`font-heading`},[`Theme editor`]),(()=>{let e=this.el(`button`,{type:`button`,class:`btn`,popovertarget:t,popovertargetaction:`hide`},[this.el(`span`,{class:`icon-mask ed-icon ed-icon--close`,"aria-hidden":`true`})]);return e.setAttribute(`aria-label`,`Close editor`),e})()]),this.buildToolbar(e),this.buildPalette(e),this.buildTypography(e),this.buildSpacing(e),this.buildLayout(),...this.buildPatterns(e));let r=this.el(`button`,{type:`button`,class:`cta ed-launch`,popovertarget:t},[this.el(`span`,{class:`icon-mask ed-icon ed-icon--theme`,"aria-hidden":`true`}),`Theme`]);document.addEventListener(`keydown`,e=>{e.key===`Escape`&&n.matches(`:popover-open`)&&n.hidePopover()}),this.append(r,n)}};customElements.get(`wc-theme-editor`)||customElements.define(`wc-theme-editor`,i);
1
+ const e=`vitops:ds-overrides`,t=(e,t)=>{let n=t.indexOf(`-`),r=n===-1?t:t.slice(0,n),i=n===-1?``:t.slice(n+1);return i===`on`?`--color-${r}-on-${e}`:i.startsWith(`on-`)?`--color-${r}-on-${e}-${i.slice(3)}`:i?`--color-${r}-${e}-${i}`:`--color-${r}-${e}`},n=(e,t)=>`--color-${e}-${t}`;var r=class extends HTMLElement{constructor(...e){super(...e),this.store={light:/* @__PURE__ */ new Map,dark:/* @__PURE__ */ new Map},this.roles=/* @__PURE__ */ new Map,this.roleVars=/* @__PURE__ */ new Set,this.syncers=/* @__PURE__ */ new Set,this.scheme=`light`,this.canSave=!1}static{this.observedAttributes=[`manifest`,`save-endpoint`]}connectedCallback(){this.boot()}async boot(){let e=this.getAttribute(`manifest`)??`/vitops/design-manifest.json`;try{let t=await fetch(e);if(!t.ok)throw Error(`manifest ${t.status}`);this.manifest=await t.json()}catch(t){console.warn(`[wc-theme-editor] manifest unavailable at ${e} — editor disabled`,t);return}this.styleEl=document.createElement(`style`),this.styleEl.id=`ds-overrides`,document.head.append(this.styleEl),this.hydrate(),this.render(),this.scheme=this.currentScheme(),new MutationObserver(()=>{let e=this.currentScheme();e!==this.scheme&&(this.scheme=e,this.syncers.forEach(e=>e()))}).observe(document.documentElement,{attributes:!0,attributeFilter:[`data-theme`]}),this.canSave=await this.probeSave(),this.build(this.manifest)}get saveUrl(){return this.getAttribute(`save-endpoint`)??`/__vitops/design-system`}async probeSave(){try{let e=await fetch(this.saveUrl);return e.ok?(await e.json()).writable===!0:!1}catch{return!1}}block(e,t){return t.size?`${e} {\n${[...t].map(([e,t])=>` ${e}: ${t};`).join(`
2
+ `)}\n}\n`:``}serializeCSS(){return this.block(`:root`,this.store.light)+this.block(`:root[data-brx-theme="dark"], :root[data-theme="dark"]`,this.store.dark)}render(){this.styleEl&&(this.styleEl.textContent=this.serializeCSS())}persist(){try{localStorage.setItem(e,JSON.stringify({light:[...this.store.light],dark:[...this.store.dark],roles:[...this.roles]}))}catch{}}hydrate(){try{let t=localStorage.getItem(e);if(!t)return;let n=JSON.parse(t);this.store.light=new Map(n.light??[]),this.store.dark=new Map(n.dark??[]),this.roles=new Map(n.roles??[]);for(let[e,t]of this.roles)this.markRoleVars(e,t)}catch{}}setOverride(e,t,n){let r=this.store[n];t==null||t===``?r.delete(e):r.set(e,t),this.render(),this.persist()}effective(e,t,n){return this.store[t].has(e)?this.store[t].get(e):t===`dark`&&this.store.light.has(e)?this.store.light.get(e):n}reset(){this.store.light.clear(),this.store.dark.clear(),this.roles.clear(),this.roleVars.clear(),this.render(),this.persist(),this.syncers.forEach(e=>e())}currentScheme(){let e=document.documentElement;return e.dataset.theme===`dark`||e.getAttribute(`data-brx-theme`)===`dark`?`dark`:`light`}setScheme(e){this.scheme=e,document.documentElement.dataset.theme=e,document.documentElement.style.colorScheme=`${e} only`;try{localStorage.setItem(`vitops-color-scheme`,e)}catch{}this.syncers.forEach(e=>e())}roleTokenNames(e,n,r){let i=e.colors.roles.find(e=>e.name===n)?.kind??`chromatic`,a=e.colors.roleTokens?.[r]?.[i];if(!a)return[];let o=[];for(let e of[`light`,`dark`])for(let[r,i]of Object.entries(a[e]))o.push([t(n,r),i,e]);return o}markRoleVars(e,t){if(this.manifest)for(let[n]of this.roleTokenNames(this.manifest,e,t))this.roleVars.add(n)}remapRole(e,t,n){let r=this.roleTokenNames(e,t,n);if(r.length){this.roles.set(t,n);for(let[e,t,n]of r)this.roleVars.add(e),this.store[n].set(e,t);this.render(),this.persist()}}deepSet(e,t,n){let r=t.split(`.`),i=e;for(let e=0;e<r.length-1;e++){let t=r[e];(typeof i[t]!=`object`||i[t]==null)&&(i[t]={}),i=i[t]}i[r[r.length-1]]=n}buildPatch(e){let t={},n=[];for(let[n,r]of this.roles){let i=e.colors.roles.find(e=>e.name===n)?.kind??`chromatic`;this.deepSet(t,`colors.roles.${n}`,{hue:r,kind:i})}for(let[r,i]of this.store.light){if(this.roleVars.has(r))continue;let a=e.reverseIndex[r];a?this.deepSet(t,a,i):n.push(r)}for(let e of this.store.dark.keys())this.roleVars.has(e)||n.push(`${e} (dark)`);return{patch:t,skipped:n}}el(e,t={},n=[]){let r=document.createElement(e),i=r;for(let[e,n]of Object.entries(t))n===!0?i.setAttribute(e,``):n!==!1&&i.setAttribute(e,String(n));for(let e of n)i.append(e);return r}row(e,t,n){return this.syncers.add(n),n(),this.el(`label`,{class:`ed-row`},[this.el(`span`,{class:`ed-label`,title:e},[e.replace(/^--/,``)]),t])}textInput(e,t,n=``){let r=this.el(`input`,{type:`text`,class:`ed-input`});return r.addEventListener(`input`,()=>this.setOverride(e,r.value.trim()||null,this.scheme)),this.row(e,r,()=>{r.value=this.store[this.scheme].get(e)??``,r.placeholder=n||t})}colorInput(e,t){let n=this.el(`input`,{type:`color`,class:`ed-color`}),r=this.el(`input`,{type:`text`,class:`ed-input ed-mono`}),i=t=>this.setOverride(e,t||null,`light`);n.addEventListener(`input`,()=>{r.value=n.value,i(n.value)}),r.addEventListener(`input`,()=>{/^#[0-9a-f]{6}$/i.test(r.value)&&(n.value=r.value),i(r.value.trim())});let a=this.el(`span`,{class:`ed-pair`},[n,r]);return this.row(e,a,()=>{let i=this.store.light.get(e)??t;r.value=i,/^#[0-9a-f]{6}$/i.test(i)&&(n.value=i)})}selectInput(e,t){let n=this.el(`select`,{class:`ed-input`});n.append(this.el(`option`,{value:``},[`— default —`]));for(let e of t)n.append(this.el(`option`,{value:e.value},[e.label]));return n.addEventListener(`change`,()=>this.setOverride(e,n.value||null,this.scheme)),this.row(e,n,()=>{n.value=this.store[this.scheme].get(e)??``})}remSlider(e,t){let n=parseFloat(t)||1,r=this.el(`input`,{type:`range`,class:`ed-range`,min:`0`,max:String(Math.max(4,Math.ceil(n*2))),step:`0.05`}),i=this.el(`output`,{class:`ed-out`});r.addEventListener(`input`,()=>{i.textContent=`${r.value}rem`,this.setOverride(e,`${r.value}rem`,this.scheme)});let a=this.el(`span`,{class:`ed-pair`},[r,i]);return this.row(e,a,()=>{r.value=String(parseFloat(this.effective(e,this.scheme,t))||n),i.textContent=this.store[this.scheme].get(e)??`auto`})}slider(e,t){let n=t.toValue??(e=>`${e}${t.unit??``}`),r=t.fromValue??(e=>Number.isNaN(parseFloat(e))?t.def:parseFloat(e)),i=this.el(`input`,{type:`range`,class:`ed-range`,min:String(t.min),max:String(t.max),step:String(t.step)}),a=this.el(`output`,{class:`ed-out`});i.addEventListener(`input`,()=>{let t=n(parseFloat(i.value));a.textContent=t,this.setOverride(e,t,this.scheme)});let o=this.el(`span`,{class:`ed-pair`},[i,a]);return this.row(e,o,()=>{let n=this.store[this.scheme].get(e);i.value=String(n==null?t.def:r(n)),a.textContent=n??`${t.def}${t.unit??``}`})}section(e,t=!1){let n=this.el(`div`,{class:`ed-body rhythm`});return{details:this.el(`details`,{class:`details details--marker-start ed-section`,...t?{open:!0}:{}},[this.el(`summary`,{class:`font-eyebrow`},[this.el(`span`,{class:`icon-mask details-icon ed-icon ed-icon--caret`,"aria-hidden":`true`}),e]),n]),body:n}}hint(e){return this.el(`p`,{class:`font-footnote text-surface-x-muted`},[e])}sub(e){let t=this.section(e);return t.details.classList.add(`ed-sub`),t}buildPalette(e){let{details:t,body:r}=this.section(`Palette`,!0);r.append(this.hint(`Hues feed the semantic roles below — edit a hue and every role mapped to it follows. Hue edits are scheme-global: dark mode re-points which step a token reads, it never redefines the hue itself.`));for(let t of e.colors.ramps){let i=this.sub(t);for(let r of e.colors.steps){let a=e.colors.palette[t]?.[r];a!=null&&i.body.append(this.colorInput(n(t,r),a))}r.append(i.details)}return r.append(this.buildRoles(e)),t}buildRoles(e){let{details:t,body:n}=this.sub(`Semantic roles`);n.append(this.hint(`Point a role at another hue — its whole functional token set (bg / border / solid / on-solid / text) is rewritten for both appearances.`));let r=e.colors.ramps;for(let{name:t,ramp:i}of e.colors.roles){let a=this.el(`select`,{class:`ed-input`});for(let e of r)a.append(this.el(`option`,{value:e},[e]));a.addEventListener(`change`,()=>this.remapRole(e,t,a.value)),n.append(this.row(`--${t}`,a,()=>{a.value=this.roles.get(t)??i}))}return t}buildTypography(e){let{details:t,body:n}=this.section(`Typography`),r=Object.keys(e.fonts).map(e=>({label:e,value:`var(--font-${e})`})),i=e.scales.text.map(e=>({label:e.name,value:`var(--text-${e.name})`})),a={tt:[`none`,`uppercase`,`lowercase`,`capitalize`],fst:[`normal`,`italic`,`oblique`],td:[`none`,`underline`,`line-through`],tw:[`wrap`,`balance`,`pretty`,`nowrap`]};for(let t of e.typography.roles){let o=e.typography.specs[t]??{},s=this.sub(t);for(let[n,{key:c}]of Object.entries(e.typography.hooks)){let e=`--${t}-${n}`,l=o[c]==null?``:String(o[c]);n===`ff`?s.body.append(this.selectInput(e,r)):n===`fs`?s.body.append(this.selectInput(e,i)):a[n]?s.body.append(this.selectInput(e,a[n].map(e=>({label:e,value:e})))):s.body.append(this.textInput(e,l,l||c))}n.append(s.details)}return t}buildSpacing(e){let{details:t,body:n}=this.section(`Spacing`);n.append(this.hint(`Each step overrides to a fixed rem (dropping the fluid clamp). Reset restores it.`));for(let t of e.scales.space)n.append(this.remSlider(`--space-${t.name}`,t.max));return t}buildLayout(){let{details:e,body:t}=this.section(`Layout`);return t.append(this.hint(`Vertical rhythm and the .centered track widths — global structure.`)),t.append(this.slider(`--rhythm-scale`,{min:.5,max:2.5,step:.05,def:1}),this.slider(`--width-measure`,{min:30,max:110,step:1,def:65,unit:`ch`}),this.slider(`--width-breakout`,{min:40,max:150,step:1,def:90,unit:`ch`}),this.slider(`--width-spotlight`,{min:60,max:200,step:1,def:120,unit:`ch`}),this.slider(`--gutter`,{min:0,max:6,step:.1,def:3,toValue:e=>`clamp(1rem, 4cqi, ${e}rem)`,fromValue:e=>{let t=/clamp\([^,]+,[^,]+,\s*([\d.]+)/.exec(e);return t?parseFloat(t[1]):3}})),e}buildPatterns(e){let t=this.section(`Pattern defaults`);for(let[n,r]of Object.entries(e.patterns.defaults))t.body.append(this.textInput(`--${n}-default`,r,r));let n=this.section(`Radii`);for(let[t,r]of Object.entries(e.patterns.radii))n.body.append(this.textInput(`--br-${t}`,r,r));let r=this.section(`Pattern groups`);for(let[t,n]of Object.entries(e.patterns.groups)){let e=this.sub(t);for(let[r,i]of Object.entries(n))e.body.append(this.textInput(`--${r}-${t}`,i,i));r.body.append(e.details)}let i=this.section(`Components`);i.body.append(this.hint(`Per-component geometry — overrides the group defaults for just this component.`));for(let t of e.patterns.items){if(!t.wrappable.length)continue;let e=this.sub(t.name);for(let{sfx:n,prop:r}of t.wrappable){let i=t.base[r]??``;e.body.append(this.textInput(`--${n}-${t.name}`,i,i))}i.body.append(e.details)}let a=this.section(`Shadows`);for(let[t,n]of Object.entries(e.shadows))a.body.append(this.textInput(`--shadow-${t}`,n,n));return[t.details,n.details,r.details,i.details,a.details]}async flash(e,t){let n=e.textContent;e.textContent=t,setTimeout(()=>e.textContent=n,1400)}async copy(e,t){try{await navigator.clipboard.writeText(e),this.flash(t,`Copied ✓`)}catch{this.flash(t,`Copy failed`)}}async save(e,t){let{patch:n}=this.buildPatch(e);if(!Object.keys(n).length)return void this.flash(t,`Nothing to save`);try{let e=await fetch(this.saveUrl,{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({patch:n})});if(!e.ok){let n=await e.json().catch(()=>({}));console.error(`[wc-theme-editor] save rejected`,n.errors??e.statusText),this.flash(t,`Rejected ✗`);return}this.reset(),this.flash(t,`Saved ✓`)}catch(e){console.error(`[wc-theme-editor] save failed`,e),this.flash(t,`Failed ✗`)}}buildToolbar(e){let t=this.el(`div`,{class:`cluster-start ed-toolbar`}),n=this.el(`button`,{type:`button`,class:`btn ed-seg`},[`Light`]),r=this.el(`button`,{type:`button`,class:`btn ed-seg`},[`Dark`]),i=()=>{n.classList.toggle(`is-active`,this.scheme===`light`),r.classList.toggle(`is-active`,this.scheme===`dark`)};this.syncers.add(i),n.addEventListener(`click`,()=>this.setScheme(`light`)),r.addEventListener(`click`,()=>this.setScheme(`dark`)),i(),t.append(this.el(`span`,{class:`ed-segwrap`,role:`group`},[n,r]));let a=this.el(`button`,{type:`button`,class:`btn`},[`Copy CSS`]);a.addEventListener(`click`,()=>this.copy(this.serializeCSS()||`/* no overrides yet */`,a));let o=this.el(`button`,{type:`button`,class:`btn`},[`Copy JSON patch`]);if(o.addEventListener(`click`,()=>{let{patch:t,skipped:n}=this.buildPatch(e),r=n.length?`\n// runtime-only (no source mapping): ${n.join(`, `)}`:``;this.copy(JSON.stringify(t,null,2)+r,o)}),t.append(a,o),this.canSave){let n=this.el(`button`,{type:`button`,class:`cta`},[`Save to source`]);n.addEventListener(`click`,()=>void this.save(e,n)),t.append(n)}let s=this.el(`button`,{type:`button`,class:`btn ed-danger`},[`Reset all`]);return s.addEventListener(`click`,()=>this.reset()),t.append(s),t}build(e){let t=`ds-editor-panel`,n=this.el(`div`,{id:t,popover:`manual`,class:`drawer drawer--right drawer--modeless ed-panel bg-surface text-surface rhythm`,"aria-label":`Design-system editor`});n.append(this.el(`header`,{class:`cluster-between`},[this.el(`strong`,{class:`font-heading`},[`Theme editor`]),(()=>{let e=this.el(`button`,{type:`button`,class:`btn`,popovertarget:t,popovertargetaction:`hide`},[this.el(`span`,{class:`icon-mask ed-icon ed-icon--close`,"aria-hidden":`true`})]);return e.setAttribute(`aria-label`,`Close editor`),e})()]),this.buildToolbar(e),this.buildPalette(e),this.buildTypography(e),this.buildSpacing(e),this.buildLayout(),...this.buildPatterns(e));let r=this.el(`button`,{type:`button`,class:`cta ed-launch`,popovertarget:t},[this.el(`span`,{class:`icon-mask ed-icon ed-icon--theme`,"aria-hidden":`true`}),`Theme`]);document.addEventListener(`keydown`,e=>{e.key===`Escape`&&n.matches(`:popover-open`)&&n.hidePopover()}),this.append(r,n)}};customElements.get(`wc-theme-editor`)||customElements.define(`wc-theme-editor`,r);