@takazudo/zdtp 0.4.4 → 0.4.6

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,51 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.6
4
+
5
+ ### Features
6
+
7
+ - Notes tab — host-configurable token notes as the panel's top page ([#515](https://github.com/Takazudo/zudo-design-token-panel/issues/515)) (4169a05)
8
+ - Palette tab: groups collapsed by default with boxed toggle headers ([#517](https://github.com/Takazudo/zudo-design-token-panel/issues/517)) (2ff5a1e)
9
+ - Container-query responsive header & tabs at narrow panel widths, plus "zdtp" panel title + square corners ([#518](https://github.com/Takazudo/zudo-design-token-panel/issues/518)) (25dff1a)
10
+ - Click-to-cycle unit suffix on value inputs ([#519](https://github.com/Takazudo/zudo-design-token-panel/issues/519)) (b05581d)
11
+ - "?" help tooltips for Literal and Per-mode rows in the Color tab ([#520](https://github.com/Takazudo/zudo-design-token-panel/issues/520)) (c85fd69)
12
+ - `zdtp.show()` — fixed-name global open API ([#523](https://github.com/Takazudo/zudo-design-token-panel/issues/523)) (9b3c636)
13
+
14
+ ### Fixed
15
+
16
+ - Tooltip repositions correctly after panel resize (ResizeObserver initial-delivery fix) ([#516](https://github.com/Takazudo/zudo-design-token-panel/issues/516)) (d10fe16)
17
+ - Closed a C0-control URL-scheme bypass in the notes-tab HTML sanitizer ([#515](https://github.com/Takazudo/zudo-design-token-panel/issues/515)) (19f7147)
18
+ - Corrected kebab-close dead zone + title truncation in the responsive header ([#518](https://github.com/Takazudo/zudo-design-token-panel/issues/518)) (bfc4b81)
19
+ - `window.zdtp` guards against a null instance and clarifies multi-instance behavior ([#523](https://github.com/Takazudo/zudo-design-token-panel/issues/523)) (bcc71aa)
20
+
21
+ ### Other Changes
22
+
23
+ - docs: notes-tab demo in doc-site manifest; help-icon resize-pin trade-off + sanitizer intent documented (82cf7d8, fe1b477)
24
+ - test: VRT baselines re-recorded for zdtp title + square corners; broad coverage added for notes tab, help icons, unit cycling
25
+ - chore: removed consumed `_temp-resource/` prototype scaffold (ccbd18a)
26
+
27
+ ## 0.4.5
28
+
29
+ ### Features
30
+
31
+ - Per-scheme/per-mode keyed color persistence — envelope v4 ([#509](https://github.com/Takazudo/zudo-design-token-panel/issues/509)) (653c4ab)
32
+ - Distinguish declared-outside-scanned-block from genuinely-absent unknowns ([#508](https://github.com/Takazudo/zudo-design-token-panel/issues/508)) (441fa93)
33
+ - Scan top-level `@theme` blocks in `applyTokenOverrides` ([#507](https://github.com/Takazudo/zudo-design-token-panel/issues/507)) (d6c6692)
34
+ - Add `colorExtras.semanticDefaults` config-time override map ([#499](https://github.com/Takazudo/zudo-design-token-panel/issues/499)) (543136e)
35
+
36
+ ### Fixed
37
+
38
+ - Harden v4 persistence load edge cases ([#509](https://github.com/Takazudo/zudo-design-token-panel/issues/509) audit, epic [#502](https://github.com/Takazudo/zudo-design-token-panel/issues/502) review) (2b07460)
39
+ - Scope color-scheme clearing to panel-written values (closes [#506](https://github.com/Takazudo/zudo-design-token-panel/issues/506)) (f434c8c)
40
+ - Validate persisted semantic index mappings against live cluster paletteSize ([#503](https://github.com/Takazudo/zudo-design-token-panel/issues/503)) (915ffbf)
41
+
42
+ ### Other Changes
43
+
44
+ - docs: sync README + doc-site with `@theme` apply, v4 persistence, schemaId ([#511](https://github.com/Takazudo/zudo-design-token-panel/issues/511)) (3c3fd57)
45
+ - test: cross-cutting interaction coverage for [#510](https://github.com/Takazudo/zudo-design-token-panel/issues/510) confirm pass (9a5d7fe)
46
+ - docs: truth-up schemaId docs, tighten Import modal copy, export `SCHEMA_V1`/`SCHEMA_V2`/`SCHEMA_V3` ([#505](https://github.com/Takazudo/zudo-design-token-panel/issues/505)) (8088281)
47
+ - docs: replace zmodular references with Takazudo Modular markdown links (afcfea2)
48
+
3
49
  ## 0.4.4
4
50
 
5
51
  ### Features
package/README.md CHANGED
@@ -53,6 +53,8 @@ The package builds against Preact (declared as a `peerDependency`) and ships its
53
53
 
54
54
  > Visual: a screenshot or short capture would go here. Skipped in the v1 README — a placeholder is worse than nothing. See the external example repos linked in §15 for live demos.
55
55
 
56
+ **Browser floor:** the panel's own header/tabbar chrome adapts to the panel's *width* (not the viewport) via CSS container queries, which are Baseline 2023 (Safari 16+). The panel is a developer tool used on evergreen browsers, so older browsers simply keep the wide-layout chrome with no JS fallback.
57
+
56
58
  ---
57
59
 
58
60
  ## 2. Install
@@ -161,6 +163,8 @@ export const panelConfig: PanelConfig = {
161
163
 
162
164
  For the full token-overrides payload schema and the `PanelConfig` shape, see §5 and §6.
163
165
 
166
+ **Which CSS blocks the rewriter scans.** Each routed file's rewrite only reaches two locations: the FIRST top-level `:root { ... }` block and the FIRST top-level `@theme { ... }` block (bare `@theme`, or with one modifier such as `@theme inline` — the shape Tailwind v4 prescribes when theme values reference other variables). Later blocks of either kind, and anything nested under `@media` / `@layer` / `@supports`, are not scanned. A cssVar declared in BOTH blocks is rewritten only in `:root` (`:root`-first-match-wins). A file with neither block returns a 409 — see §11 for the Tailwind `@theme` worked example, and [Apply pipeline reference](/docs/reference/apply-pipeline) for the full response-field contract, including the `unknownOutsideBlockCssVars` diagnostic for a cssVar that's declared somewhere in the file but outside both scanned blocks.
167
+
164
168
  ### 3.3 Security model
165
169
 
166
170
  The bin is **dev-only** and is built with three independent guards:
@@ -273,7 +277,7 @@ export const myPanelConfig: PanelConfig = {
273
277
  storagePrefix: 'myapp-design-token-panel',
274
278
  consoleNamespace: 'myapp',
275
279
  modalClassPrefix: 'myapp-design-token-panel-modal',
276
- schemaId: 'myapp-design-tokens/v1',
280
+ schemaId: 'myapp-design-tokens/v1', // display-only label — see §5.3; import/export always use the canonical SCHEMA_V1/V2/V3
277
281
  exportFilenameBase: 'myapp-design-tokens',
278
282
  tabs: [spacingTab /*, fontTab, sizeTab, colorTab, ... */],
279
283
  };
@@ -324,6 +328,22 @@ That is the entire integration. `<DesignTokenPanelHost>` emits a JSON `<script>`
324
328
  window.myapp.toggleDesignPanel();
325
329
  ```
326
330
 
331
+ Or use the fixed-name `zdtp` global — no need to remember your own
332
+ `consoleNamespace`:
333
+
334
+ ```js
335
+ // In the browser devtools console — works regardless of consoleNamespace
336
+ zdtp.show();
337
+ zdtp.hide();
338
+ zdtp.toggle();
339
+ ```
340
+
341
+ `zdtp.show()` / `hide()` / `toggle()` are console sugar for the exact same
342
+ open/close verbs `window.myapp.*` exposes (see §10 for the full contract) —
343
+ `window.myapp.*` keeps working unchanged. On an Astro page, `zdtp.*` is
344
+ available as soon as the host-adapter script has run, even before the panel
345
+ bundle itself has loaded.
346
+
327
347
  Or wire a hidden keyboard shortcut / dev-only button to call the same helper.
328
348
 
329
349
  ### 4.1.4 Stylesheet (self-injected — no consumer import required)
@@ -470,6 +490,13 @@ export interface PanelInstanceHandle {
470
490
  }
471
491
  ```
472
492
 
493
+ `open()` / `close()` / `toggle()` are the same primitives both
494
+ `window.myapp.*` (§10) and the fixed-name `window.zdtp.*` global (§10) wrap
495
+ for the **default** (most-recently-configured) instance. On a page with more
496
+ than one instance, call `handle.open()` / `.close()` / `.toggle()` directly
497
+ on the specific instance's handle — `window.zdtp.*` always targets the
498
+ default instance only.
499
+
473
500
  ### 5.3 Field summary
474
501
 
475
502
  | Field | Type | Purpose |
@@ -477,7 +504,7 @@ export interface PanelInstanceHandle {
477
504
  | `storagePrefix` | `string` | Base for every derived `localStorage` key. Also the instance id. See §9. |
478
505
  | `consoleNamespace` | `string` | Global object the package installs `showDesignPanel` / `hideDesignPanel` / `toggleDesignPanel` on (e.g. `consoleNamespace: 'myapp'` → `window.myapp.showDesignPanel`). |
479
506
  | `modalClassPrefix` | `string` | BEM root class for every modal the panel owns (export, import, apply). Emits `${prefix}__overlay`, `${prefix}__panel`, etc. |
480
- | `schemaId` | `string` | `$schema` value emitted into export JSON and required on import. |
507
+ | `schemaId` | `string` | **Display-only** label surfaced in the Import modal's copy. `serialize()` always emits the canonical `SCHEMA_V2`/`SCHEMA_V3` and `deserialize()` always validates against the canonical `SCHEMA_V1`/`V2`/`V3` — neither reads this field. See §14's migration recipe. |
481
508
  | `exportFilenameBase` | `string` | Default download filename base — exports save as `${exportFilenameBase}.json`. |
482
509
  | `toggleEvent` | `string` (optional) | Window-event name that toggles THIS instance. Defaults to `toggle-${storagePrefix}` for non-default instances; the default instance keeps `toggle-design-token-panel`. |
483
510
  | `tabs` | `readonly TabConfig[]` | **Required.** Tab strip data — each entry is a tab with one or more `TierConfig` objects. The color tab (id `'color'`) additionally requires `colorExtras`. See §6. |
@@ -533,7 +560,7 @@ const handle = configurePanel({
533
560
  The Astro entry point (`<DesignTokenPanelHost>`) handles mounting for you. Internally:
534
561
 
535
562
  - The console API (`showDesignPanel` etc.) is **always installed eagerly**, even when the panel module has not loaded — calling them is what triggers the lazy import for cold-start users.
536
- - The panel module is **dynamically imported on first need**: when the user calls a console helper, OR when first-paint detects any of four gate signals in `localStorage` — `${storagePrefix}:visible` set to `1`, persisted overrides (`${storagePrefix}-state-v3`), the owner-autoload flag (`${storagePrefix}:autoload` set to `1`), or the element-path inspector enabled.
563
+ - The panel module is **dynamically imported on first need**: when the user calls a console helper, OR when first-paint detects any of four gate signals in `localStorage` — `${storagePrefix}:visible` set to `1`, persisted overrides (checked as `${storagePrefix}-state-v4`, falling back to `-state-v3` / `-state-v2` for pre-migration sessions — see §9), the owner-autoload flag (`${storagePrefix}:autoload` set to `1`), or the element-path inspector enabled.
537
564
  - This gating keeps the panel out of the initial JS bundle for first-time visitors while still re-applying overrides on hard reload for users who have tweaked things. **General visitors** (none of the four signals set) pay zero bundle cost.
538
565
 
539
566
  For a Vite-only / non-Astro host, mount it yourself by importing the adapter module after `configurePanel(...)`. See §11.5.
@@ -693,6 +720,13 @@ export interface ColorClusterExtras {
693
720
  colorScheme: string;
694
721
  colorMode: false | { defaultMode: 'light' | 'dark'; lightScheme: string; darkScheme: string };
695
722
  };
723
+ /**
724
+ * Optional config-time override map for a semantic tier's derived defaults,
725
+ * keyed by semantic item id. This is the ONLY way to ship a `{ literal }` /
726
+ * `{ literal: { light, dark } }` / `{ ref }` default — `TierItem.default`
727
+ * itself stays a plain string, consumed generically by every tab kind.
728
+ */
729
+ semanticDefaults?: Record<string, SemanticValue>;
696
730
  }
697
731
 
698
732
  export interface ColorScheme {
@@ -712,6 +746,30 @@ export interface ColorScheme {
712
746
  > re-exported from the package root:
713
747
  > `import type { ColorClusterConfig } from '@takazudo/zdtp'`.
714
748
 
749
+ **`colorExtras.semanticDefaults`.** Without this field, a semantic tier's default `SemanticValue` is DERIVED from each `TierItem.default` string (palette-index lookup, or a literal fallback). `semanticDefaults` lets a host override that derivation for specific semantic item ids and ship a default the plain-string `default` field can't express — most usefully a per-mode literal that resolves via CSS `light-dark()`:
750
+
751
+ ```ts
752
+ const colorTab: TabConfig = {
753
+ id: 'color',
754
+ label: 'Color',
755
+ tiers: [paletteTier, semanticTier],
756
+ colorExtras: {
757
+ id: 'myapp',
758
+ baseRoles: { background: '--myapp-bg', foreground: '--myapp-fg' },
759
+ baseDefaults: {},
760
+ defaultShikiTheme: 'dracula',
761
+ colorSchemes: { /* ... */ },
762
+ panelSettings: { colorScheme: 'Default', colorMode: false },
763
+ semanticDefaults: {
764
+ // Keyed by the semantic TierItem's `id`, not its cssVar.
765
+ danger: { literal: { light: '#b91c1c', dark: '#f87171' } },
766
+ },
767
+ },
768
+ };
769
+ ```
770
+
771
+ A key present in `semanticDefaults` wins verbatim over the derived value; keys not listed fall back to the normal derivation from `TierItem.default`.
772
+
715
773
  ### 7.2 JSON-serializable constraint (important)
716
774
 
717
775
  **Every field on the color `TabConfig` (including `colorExtras` and every
@@ -888,9 +946,10 @@ Behaviour notes:
888
946
 
889
947
  | Logical key | Derivation | Purpose |
890
948
  | ----------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
891
- | `state-v3` | `${storagePrefix}-state-v3` | Current unified envelope (color + spacing + typography + size + panelPosition + generic `tabs` map). Added in abstract-token-tiers epic. |
892
- | `state-v2` | `${storagePrefix}-state-v2` | Legacy pre-v3 format. Migrated into `state-v3` on first load, then deleted. |
893
- | `state-v1` | `${storagePrefix}-state` | Legacy pre-v2 flat-state format (Color-only). Migrated into `state-v3` on first load, then deleted. |
949
+ | `state-v4` | `${storagePrefix}-state-v4` | Current unified envelope. Same top-level slices as v3, EXCEPT `color` (and optional `secondary`) is an identity-keyed map — one `ColorTweakState` slot per active-scheme identity — instead of a single flat object. See "Per-scheme color persistence" below. |
950
+ | `state-v3` | `${storagePrefix}-state-v3` | Legacy pre-v4 format (flat, single-slot `color`). Migrated into `state-v4` on first load; the v3 key itself is left in place (not deleted) so a downgrade can still read it. |
951
+ | `state-v2` | `${storagePrefix}-state-v2` | Legacy pre-v3 format. Migrated into `state-v3` (and from there into `state-v4`) on first load, then deleted. |
952
+ | `state-v1` | `${storagePrefix}-state` | Legacy pre-v2 flat-state format (Color-only). Migrated into `state-v3` (and from there into `state-v4`) on first load, then deleted. |
894
953
  | `open` | `${storagePrefix}-open` | Mirror of the panel's `open` boolean (synchronous mount-time read — preserves user intent across reloads, fixes #1549). |
895
954
  | `position` | `${storagePrefix}-position` | Drag position `{ top, right }` so the panel reappears where the user left it. |
896
955
  | `visible` | `${storagePrefix}:visible` | Adapter-level visibility-intent flag, owned by the lazy-load gate. |
@@ -899,6 +958,7 @@ Behaviour notes:
899
958
  For example, with `storagePrefix: 'myapp-design-token-panel'`:
900
959
 
901
960
  ```
961
+ myapp-design-token-panel-state-v4
902
962
  myapp-design-token-panel-state-v3
903
963
  myapp-design-token-panel-state-v2
904
964
  myapp-design-token-panel-state
@@ -912,18 +972,40 @@ myapp-design-token-panel:autoload
912
972
 
913
973
  The `visible` and `autoload` keys use a literal `:` separator, not `-`. Every other derived key uses `-`. The colon form is intentional for these adapter-level flags — see [`PORTABLE-CONTRACT.md`](./PORTABLE-CONTRACT.md) §2 for the historical reason. Don't try to "normalize" them; the unit tests assert this specific shape.
914
974
 
915
- ### Scheme changes and the global tweak model
975
+ ### Per-scheme color persistence (v4 envelope)
976
+
977
+ Color/secondary tweaks are persisted **per (scheme, mode) identity**, inside the single `state-v4` storage key — there is no per-scheme key fan-out. The identity is exactly the active scheme name the panel already resolves for seeding (`getActiveSchemeName`): a cluster without `colorMode` resolves one constant identity (one slot, effectively the old global behavior); a cluster with light/dark `colorMode` resolves a distinct identity per side, so a light tweak and a dark tweak occupy independent slots in the same envelope:
978
+
979
+ ```jsonc
980
+ {
981
+ "color": {
982
+ "Default Light": { "palette": [...], "semanticMappings": {...}, /* ... */ },
983
+ "Default Dark": { "palette": [...], "semanticMappings": {...}, /* ... */ }
984
+ },
985
+ "secondary": { "Default Light": { /* ... */ } }, // optional, same identity keying
986
+ "spacing": { /* ... */ },
987
+ "typography": { /* ... */ },
988
+ "size": { /* ... */ }
989
+ }
990
+ ```
991
+
992
+ The `spacing` / `typography` / `size` (and generic `tabs`) slices stay **global and unkeyed**, exactly as in v1–v3 — a scheme/mode toggle never touches them.
916
993
 
917
- Tweak state is **global**, not scheme-scoped: the keys above are derived only from `storagePrefix` and carry no color-scheme name. A single envelope holds every slice — the `color` slice stores **absolute** colors (palette + role/semantic indices resolved against that palette), while the `spacing` / `typography` / `size` slices store token overrides that are independent of the active scheme.
994
+ When the host dispatches a `color-scheme-changed` event (e.g. a light/dark toggle), the panel re-seeds its live color state for the newly active identity:
918
995
 
919
- Because the stored palette is absolute, it cannot simply be re-applied on top of a different scheme. So when the host dispatches a `color-scheme-changed` event (e.g. a light/dark toggle), the panel **re-seeds its live color state from the newly active scheme**: it removes only the inline `:root` color-cluster overrides it had applied (`clearAppliedColorStyles`) and re-initializes only the live `color` (and optional `secondary`) slices from that scheme. Palette tweaks are therefore intentionally **not** carried across a scheme switch — the panel adopts the new scheme's palette rather than layering the previous absolute colors on top of it. This is the panel's deliberate global model; it does **not** persist a separate color slice per scheme.
996
+ - If that identity already has a persisted slot, the slot's stored `color` (and `secondary`) state is loaded and re-applied — a per-scheme tweak now **survives** a round-trip through another scheme and back.
997
+ - If the identity has no slot yet (never tweaked under this scheme before), the panel cold-seeds from that scheme's defaults instead — the pre-existing "adopt the new scheme's palette" behavior for a scheme you haven't touched.
920
998
 
921
- The `spacing` / `typography` / `size` slices are scheme-INDEPENDENT, so they are deliberately left untouched by the re-seed: their live values and their applied inline `:root` vars survive a scheme toggle (the scheme-change handler narrows the clear to the color cluster(s) for exactly this reason — see #347). A full panel reset (Reset / Apply) still wipes every slice via `clearAppliedStyles`.
999
+ Editing color under scheme A only ever overwrites scheme A's slot (`writeMergedV4` merge-saves: it reads the existing envelope, replaces just the active identity's slot, and preserves every other identity's slot untouched) — it never mutates scheme B's stored state. A full panel reset (Reset / Apply) still wipes every slice via `clearAppliedStyles`.
1000
+
1001
+ **Migration.** On first load with no `state-v4` key, the legacy v1/v2/v3 chain runs unchanged (v3 wins over v2 over v1, exactly as before) and the resulting single flat `TweakState` is filed into `state-v4` under whichever identity is active at that moment — every subsequent load reads `state-v4` first. The legacy v3/v2/v1 keys are left in place by the v4 migration step (only the v1→v2→v3 chain deletes as it goes), so a host that needs to downgrade can still read them.
1002
+
1003
+ **Host-owned `color-scheme` is never touched.** A host that manages its own `<html style="color-scheme">` (e.g. a site-level light/dark toggle) has that inline style tracked separately from the panel's own writes; the panel's mount/clear paths only ever remove a `color-scheme` value that the panel itself applied, never a host-owned one (#501).
922
1004
 
923
1005
  Two consequences worth knowing as a host integrator:
924
1006
 
925
- - The event handler does not rewrite the `localStorage` envelope — it only re-seeds the live (in-memory) state and clears the inline overrides. The persisted envelope remains until the next in-panel edit re-persists the (re-seeded) state, or a full reload re-applies it via `loadPersistedState`.
926
- - Do **not** manually delete the tweak keys on a scheme toggle. The panel already re-seeds on the event, and a manual envelope delete would also discard the scheme-independent `spacing` / `typography` / `size` overrides.
1007
+ - The event handler does not rewrite `localStorage` directly beyond what's described above — it reads/re-seeds the live (in-memory) state and clears the inline overrides for the previous identity. The next in-panel edit (or a full reload via `loadPersistedState`) is what persists the current identity's state.
1008
+ - Do **not** manually delete the tweak keys on a scheme toggle. The panel already handles per-identity persistence on the event, and a manual envelope delete would also discard the scheme-independent `spacing` / `typography` / `size` overrides.
927
1009
 
928
1010
  ---
929
1011
 
@@ -943,6 +1025,27 @@ window.myapp.disableAutoload(); // disarm owner-mode; unmounts the panel
943
1025
 
944
1026
  All helpers are **async** — the first call lazy-imports the panel module. Subsequent calls share the memoised module promise and resolve synchronously after the first import completes.
945
1027
 
1028
+ ### Fixed-name global: `window.zdtp`
1029
+
1030
+ `consoleNamespace` is a **required** field on `PanelConfig` — every consumer picks its own value, so `window[consoleNamespace].*` needs the host's chosen namespace before you can open the panel from the console. `window.zdtp` is an additive, fixed-name alias that needs none:
1031
+
1032
+ ```ts
1033
+ zdtp.show(); // open the panel (lazy-loads the bundle on first call, same as showDesignPanel())
1034
+ zdtp.hide(); // close the panel
1035
+ zdtp.toggle(); // toggle open/closed
1036
+ ```
1037
+
1038
+ - **`show` / `hide` / `toggle` only.** There is no `zdtp.enableAutoload()` — owner-autoload (§10.1) stays on `window[consoleNamespace].*` and the package-root `enableAutoload()` / `disableAutoload()` exports.
1039
+ - **`window[consoleNamespace].*` is unchanged** — `window.zdtp` is sugar layered on top of it, not a replacement. Both stay available side by side.
1040
+ - **Targets the default instance**, exactly like the package-root `showDesignTokenPanel()` / `hideDesignTokenPanel()` / `toggleDesignPanel()` exports and `window[consoleNamespace].*` itself. On an Astro host specifically, the alias binds to whichever instance's adapter script installs it first, rather than re-resolving "the current default" on every call — see `PORTABLE-CONTRACT.md` §6.5 for the exact per-install-site rule. For a page with more than one panel instance, use `configurePanel(cfg)`'s returned handle (§5.2) instead — it is unambiguous regardless of install order.
1041
+ - **Available on both integration paths:**
1042
+ - Non-Astro hosts get it as soon as `@takazudo/zdtp`'s package-root module has loaded (it installs the alias at module init).
1043
+ - Astro hosts get it as soon as the host-adapter `<script>` has run — **before** the panel bundle itself has loaded. The first `zdtp.*` call lazy-imports the bundle, exactly like `window[consoleNamespace].*`.
1044
+ - **Never clobbers a host-defined `window.zdtp`.** If your page already has its own `window.zdtp` for something unrelated, the package leaves it alone and logs a `console.warn` instead of overwriting it — including the edge case of choosing `consoleNamespace: 'zdtp'` yourself.
1045
+ - **Auto-remember applies too** — `zdtp.show()` arms the `:autoload` flag exactly like `showDesignPanel()` (§10.1's Auto-remember footgun note applies here as well).
1046
+
1047
+ See `PORTABLE-CONTRACT.md` §6.5 for the full install-site and no-clobber/no-double-install contract.
1048
+
946
1049
  ### Backward-compatible: the on-demand flow is unchanged
947
1050
 
948
1051
  The pre-existing on-demand usage — calling `window.<consoleNamespace>.showDesignPanel()` / `hideDesignPanel()` / `toggleDesignPanel()` to lazy-import and initialize the panel — is **unchanged and fully supported**. Owner-autoload (§10.1) is an opt-in layered on top, not a replacement. If you never call `enableAutoload()`, the panel behaves exactly as it always did: nothing loads for any visitor until the first console call.
@@ -1032,6 +1135,39 @@ Because the fallback ladder is host-CSS-var-driven, a brand-new consumer can mou
1032
1135
 
1033
1136
  The CSS variables the panel **writes to** (the `cssVar` field on each `TokenDef`, the cluster's `paletteCssVarTemplate`, base-role names, and semantic CSS names) are entirely consumer-controlled. The package never reads those consumer CSS variables; it only writes through `setProperty` on `:root`.
1034
1137
 
1138
+ ### 11.2 Apply-to-disk for a Tailwind v4 `@theme` block
1139
+
1140
+ The section above is about the panel's OWN chrome — it never needed Tailwind. This section is about the opposite direction: a Tailwind v4 **consumer** whose design tokens live inside `@theme { ... }` (so Tailwind can generate utility classes from them) rather than `:root`.
1141
+
1142
+ The apply-to-disk rewriter (§3.2) scans the first top-level `:root` block AND the first top-level `@theme` block, so a Tailwind v4 token file like this applies out of the box (issue #496's repro):
1143
+
1144
+ ```css
1145
+ :root {
1146
+ --palette-cool-700: oklch(0.21 0.03 264);
1147
+ }
1148
+
1149
+ @theme {
1150
+ --spacing-md: 0.75rem;
1151
+ --color-ink: light-dark(var(--palette-cool-700), var(--palette-cool-50));
1152
+ }
1153
+ ```
1154
+
1155
+ POSTing overrides for `--palette-cool-700` (in `:root`), `--spacing-md`, or `--color-ink` (both in `@theme`) all land in `changed` — no indirection required. A file that is 100% `@theme` (no `:root` block at all) also applies cleanly; only a file with **neither** block 409s.
1156
+
1157
+ **Legacy workaround (no longer required).** Before the rewriter learned to scan `@theme`, the only way to make a Tailwind-`@theme` token editable was to declare the editable value in `:root` and have `@theme` alias it via `var(...)`:
1158
+
1159
+ ```css
1160
+ :root {
1161
+ --spacing-md-editable: 0.75rem; /* apply pipeline writes here */
1162
+ }
1163
+
1164
+ @theme {
1165
+ --spacing-md: var(--spacing-md-editable); /* Tailwind reads the alias */
1166
+ }
1167
+ ```
1168
+
1169
+ This indirection is no longer necessary — declare the token directly inside `@theme` as shown above. The alias pattern still works (it's just two ordinary top-level blocks), so existing consumers are not required to migrate.
1170
+
1035
1171
  ---
1036
1172
 
1037
1173
  ## 12. Bundler notes
@@ -1112,6 +1248,17 @@ This recipe walks through wiring the panel into a project that does not currentl
1112
1248
 
1113
1249
  Pick identifiers for `storagePrefix`, `consoleNamespace`, `modalClassPrefix`, `schemaId`, `exportFilenameBase`, and your palette CSS-var family (`paletteCssVarTemplate`). Pull these into a host-side config file (e.g. `src/lib/panel-config.ts`). If you are migrating from an internal snapshot fork and want to preserve users' saved state across the migration, keep the legacy values verbatim; otherwise pick fresh, neutral identifiers (e.g. `myapp-design-token-panel`).
1114
1250
 
1251
+ `schemaId` is display-only (see §5.3) — it never gates import/export, so picking any string here is safe. If you want the Import modal's copy to name the ACTUAL schema your export/import round-trips through, import `SCHEMA_V1` / `SCHEMA_V2` / `SCHEMA_V3` from the package root and set `schemaId` to one of them instead of an arbitrary string:
1252
+
1253
+ ```ts
1254
+ import { SCHEMA_V3 } from '@takazudo/zdtp';
1255
+
1256
+ export const myPanelConfig: PanelConfig = {
1257
+ // ...
1258
+ schemaId: SCHEMA_V3, // truthful label — matches what serialize()/deserialize() actually validate against
1259
+ };
1260
+ ```
1261
+
1115
1262
  3. **Author your token manifest in the host project.**
1116
1263
 
1117
1264
  The package itself ships zero baked-in manifest data. Define `SPACING_TOKENS`, `FONT_TOKENS`, `SIZE_TOKENS`, `COLOR_TOKENS` (or whatever names you prefer) in your project and wire them up as `tokens.spacing`, `tokens.typography`, `tokens.size`, `tokens.color`. The host is the source of truth.
@@ -2,79 +2,186 @@
2
2
  * Pure, IO-free CSS custom-property value replacement.
3
3
  *
4
4
  * `applyTokenOverrides(source, overrides)` rewrites the values of one or more
5
- * CSS custom properties inside the FIRST top-level `:root { ... }` block of a
6
- * CSS file, preserving surrounding whitespace and trailing inline comments.
7
- * The module is the regex foundation that the dev-API endpoint
5
+ * CSS custom properties inside the FIRST top-level `:root { ... }` block AND
6
+ * the FIRST top-level `@theme { ... }` block (Tailwind v4 design-token scope)
7
+ * of a CSS file, preserving surrounding whitespace and trailing inline
8
+ * comments. The module is the regex foundation that the dev-API endpoint
8
9
  * wraps; it performs no IO, no DOM access, and is safe to import in any
9
10
  * environment.
10
11
  *
11
12
  * Behavior summary
12
13
  * ----------------
13
- * - Only the FIRST top-level `:root { ... }` block is modified. Tokens inside
14
- * `@media`, `@layer`, `@supports`, or nested `:root` blocks are IGNORED.
14
+ * - Two top-level blocks are scanned: the FIRST top-level `:root { ... }` block
15
+ * and the FIRST top-level `@theme { ... }` block (first-of-each-kind only).
16
+ * Later blocks of either kind, and tokens inside `@media`, `@layer`,
17
+ * `@supports`, or nested blocks, are IGNORED.
18
+ * - `:root`-first-match-wins: for each override var, `:root` is tried first and
19
+ * `@theme` is the fallback. A var declared in BOTH blocks is rewritten only
20
+ * in `:root`.
21
+ * - `@theme` accepts an optional modifier — `@theme`, `@theme inline`,
22
+ * `@theme static`, `@theme reference`, or a combination — because Tailwind v4
23
+ * prescribes `@theme inline` precisely when theme values reference other
24
+ * variables (the mainstream case). `:root` matching stays strict: grouped
25
+ * selectors such as `:root, html { ... }` are NOT treated as a `:root` block.
15
26
  * - Per-cssVar regex: `(--name:)\s*([\s\S]+?);` with the name regex-escaped.
16
27
  * The non-greedy value capture stops at the first `;`, which preserves any
17
28
  * trailing `/​* ... *​/` inline comment on the same logical line.
18
29
  * - A whitespace-trimmed comparison between the old value and the override
19
30
  * decides whether the cssVar lands in `changed` or `unchanged`.
20
31
  * - Idempotent: applying the same overrides twice in a row reports every key
21
- * as `unchanged` on the second call and produces byte-identical output.
32
+ * as `unchanged` on the second call and produces byte-identical output. The
33
+ * two non-overlapping regions are spliced back in DESCENDING start order so
34
+ * the earlier region's offsets stay valid through the later region's splice.
35
+ * - `unknownOutsideBlock` (#508): a whole-file scan (ignoring the "first
36
+ * block of each kind" and "no nesting" restrictions above) reclassifies any
37
+ * `unknown` key that IS declared somewhere in the file, so callers can tell
38
+ * "declared, but outside a scanned block" apart from "genuinely absent".
22
39
  *
23
40
  * Known limitations (if a richer transform is ever needed, swap this module
24
41
  * for a real CSS parser such as postcss):
25
42
  * - Only `:root` as a bare selector is recognized. Grouped selectors such as
26
43
  * `:root, html { ... }` are NOT treated as a `:root` block.
27
- * - Nested `:root` under `@media` / `@layer` / `@supports` is intentionally
28
- * skipped; see the test matrix in the companion `__tests__` file.
44
+ * - Only the FIRST top-level block of each kind is scanned; a second top-level
45
+ * `:root` or `@theme` block is ignored.
46
+ * - Nested `:root` / `@theme` under `@media` / `@layer` / `@supports` is
47
+ * intentionally skipped; see the test matrix in the companion `__tests__`
48
+ * file.
29
49
  * - Values containing a literal `;` inside a string or comment would confuse
30
50
  * the non-greedy regex. Not supported.
31
- * - If the same cssVar is declared multiple times in the top-level `:root`
51
+ * - If the same cssVar is declared multiple times inside a single scanned
32
52
  * block, only the FIRST occurrence is rewritten.
33
53
  */
34
54
  export interface ApplyResult {
35
55
  /** The new CSS file contents (byte-identical to `source` when nothing
36
- * changed, including the "no :root block" case). */
56
+ * changed, including the "no scannable block" case). */
37
57
  updated: string;
38
58
  /** cssVar names actually rewritten (trimmed new value differs from old). */
39
59
  changed: string[];
40
- /** cssVar names present in the file whose trimmed value already matched
41
- * the override. */
60
+ /** cssVar names present in a scanned block whose trimmed value already
61
+ * matched the override. */
42
62
  unchanged: string[];
43
- /** cssVar names in `overrides` that were not found in the first top-level
44
- * `:root` block (either because the name is absent, or because no
45
- * top-level `:root` block exists). */
63
+ /** cssVar names in `overrides` that were not found in EITHER the first
64
+ * top-level `:root` block or the first top-level `@theme` block (the name
65
+ * is absent, or neither block exists). */
46
66
  unknown: string[];
67
+ /**
68
+ * Subset of `unknown` (#508): cssVar names that ARE declared somewhere in
69
+ * `source` — found by a whole-file scan, not restricted to the two scanned
70
+ * blocks — but outside both of them, so nothing was rewritten. Typical
71
+ * causes: the declaration sits in a nested block (`@media`/`@layer`/
72
+ * `@supports`), a grouped selector (`:root, html { ... }`), or a SECOND
73
+ * top-level `:root`/`@theme` block (the scanner only reads the first of
74
+ * each kind). Lets callers distinguish "declared, but Apply doesn't rewrite
75
+ * this location" from "genuinely absent / typo'd name" — the remaining
76
+ * `unknown` entries not in this list.
77
+ */
78
+ unknownOutsideBlock: string[];
47
79
  }
48
80
  /**
49
- * Thrown by {@link applyTokenOverridesOrThrow} when the source has no
50
- * top-level `:root { ... }` block. Lets the dev-API handler surface a
51
- * diagnostic message without having to probe the result shape.
81
+ * Thrown by {@link applyTokenOverridesOrThrow} when the source has neither a
82
+ * top-level `:root { ... }` block nor a top-level `@theme { ... }` block. Lets
83
+ * the dev-API handler surface a diagnostic message without having to probe the
84
+ * result shape.
52
85
  */
53
- export declare class NoRootBlockError extends Error {
86
+ export declare class NoTokenBlockError extends Error {
54
87
  constructor(message?: string);
55
88
  }
56
89
  /**
57
- * Rewrite `overrides` into the first top-level `:root { ... }` block of
58
- * `source` and return an {@link ApplyResult} describing the outcome.
90
+ * @deprecated Retained as an alias for {@link NoTokenBlockError} so existing
91
+ * `instanceof` / import sites keep working. The scan now covers `@theme`
92
+ * blocks too, so "NoRootBlock" is no longer literally accurate.
93
+ */
94
+ export declare const NoRootBlockError: typeof NoTokenBlockError;
95
+ export interface BlockBounds {
96
+ /** Index of the first character INSIDE the block (just after the opening
97
+ * `{`). */
98
+ contentStart: number;
99
+ /** Index of the matching `}` (exclusive end of block content). */
100
+ contentEnd: number;
101
+ }
102
+ /**
103
+ * Bounds of the first top-level `:root { ... }` block, or `null`. Grouped
104
+ * selectors (`:root, html { ... }`) are intentionally NOT matched.
105
+ */
106
+ export declare function findFirstTopLevelRootBlock(source: string): BlockBounds | null;
107
+ /**
108
+ * Bounds of the first top-level `@theme { ... }` block, or `null`. Tolerates an
109
+ * optional modifier (`@theme inline`, `@theme static`, ...) between `@theme`
110
+ * and the opening `{`.
111
+ */
112
+ export declare function findFirstTopLevelThemeBlock(source: string): BlockBounds | null;
113
+ export type SingleReplaceStatus = 'changed' | 'unchanged' | 'unknown';
114
+ export interface SingleReplaceResult {
115
+ content: string;
116
+ status: SingleReplaceStatus;
117
+ }
118
+ /**
119
+ * Replace every CSS block comment (`/​* ... *​/`) with same-length whitespace
120
+ * so the per-declaration regex below cannot match a fake declaration that
121
+ * lives INSIDE a comment. Same-length is critical: the masked string is used
122
+ * only as a search space, but the resulting `match.index` is then sliced
123
+ * straight back into the original (un-masked) `content`, so masked and
124
+ * original must agree byte-for-byte on offsets.
125
+ *
126
+ * Pre-fix behaviour: a `:root` containing
127
+ *
128
+ * /* --zd-foo: red; *​/
129
+ * --zd-foo: blue;
59
130
  *
60
- * If `source` has no top-level `:root` block, the function returns
61
- * `source` unchanged with every key routed to `unknown` — the companion
62
- * {@link applyTokenOverridesOrThrow} raises {@link NoRootBlockError} in
63
- * that case so callers that need a hard failure can distinguish it from a
64
- * block where merely none of the overrides matched.
131
+ * would have its commented-out value rewritten by the regex (it matches the
132
+ * FIRST occurrence), leaving the live declaration untouched and silently
133
+ * corrupting the user-authored comment.
134
+ */
135
+ export declare function maskComments(input: string): string;
136
+ /**
137
+ * Replace the value of a single custom property inside `content`. The name
138
+ * may be supplied with or without the leading `--`. Returns the updated
139
+ * content along with a status flag used to populate `changed` / `unchanged`
140
+ * / `unknown` in the top-level {@link ApplyResult}.
141
+ *
142
+ * The match is run against a comment-masked copy of `content` so a
143
+ * commented-out declaration cannot shadow a live one (B4 fix). Edits are
144
+ * still spliced back into the original `content` so trailing inline comments
145
+ * survive byte-for-byte.
146
+ */
147
+ export declare function replaceOne(content: string, cssVarName: string, newValue: string): SingleReplaceResult;
148
+ /**
149
+ * Rewrite `overrides` into the first top-level `:root { ... }` block AND the
150
+ * first top-level `@theme { ... }` block of `source`, returning an
151
+ * {@link ApplyResult} describing the outcome.
152
+ *
153
+ * Per override var, `:root` is tried first and `@theme` is the fallback, so a
154
+ * var present in both blocks is rewritten only in `:root` (`:root`-first-match-
155
+ * wins). The two non-overlapping block regions are spliced back into `source`
156
+ * in DESCENDING start order so the earlier region's offsets stay valid.
157
+ *
158
+ * If `source` has neither block, the function returns `source` unchanged with
159
+ * every key routed to `unknown` — the companion {@link applyTokenOverridesOrThrow}
160
+ * raises {@link NoTokenBlockError} in that case so callers that need a hard
161
+ * failure can distinguish it from a file where merely none of the overrides
162
+ * matched.
65
163
  */
66
164
  export declare function applyTokenOverrides(source: string, overrides: Record<string, string>): ApplyResult;
67
165
  /**
68
166
  * Throwing variant. Same shape as {@link applyTokenOverrides} except it
69
- * raises {@link NoRootBlockError} when no top-level `:root { ... }` block
70
- * exists, instead of returning every override under `unknown`. Use this from
71
- * server-side handlers where "no :root block" is a fatal configuration
72
- * problem rather than an expected state.
167
+ * raises {@link NoTokenBlockError} when the source has neither a top-level
168
+ * `:root { ... }` block nor a top-level `@theme { ... }` block, instead of
169
+ * returning every override under `unknown`. Use this from server-side handlers
170
+ * where "no writable token block" is a fatal configuration problem rather than
171
+ * an expected state.
73
172
  */
74
173
  export declare function applyTokenOverridesOrThrow(source: string, overrides: Record<string, string>): ApplyResult;
75
174
  /**
76
175
  * Low-level predicate exposed primarily for callers that want to report a
77
176
  * precise diagnostic without running a full rewrite. Returns `true` iff the
78
- * source contains at least one top-level `:root { ... }` block.
177
+ * source contains at least one scannable top-level token block — a `:root`
178
+ * block OR a `@theme` block. Consistent with the {@link applyTokenOverridesOrThrow}
179
+ * throw condition (which fires only when this returns `false`).
180
+ */
181
+ export declare function hasApplicableTokenBlock(source: string): boolean;
182
+ /**
183
+ * @deprecated Renamed to {@link hasApplicableTokenBlock} now that the scan
184
+ * covers `@theme` blocks too. Retained as an alias so existing call sites keep
185
+ * working; it returns `true` for a `@theme`-only file (no `:root`).
79
186
  */
80
- export declare function hasTopLevelRootBlock(source: string): boolean;
187
+ export declare const hasTopLevelRootBlock: typeof hasApplicableTokenBlock;