@takazudo/zdtp 0.4.9 → 0.4.11

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.
@@ -0,0 +1,1309 @@
1
+ # Design Token Panel — Portable Contract
2
+
3
+ This document codifies the public contract that the
4
+ `@takazudo/zdtp` package exposes to its host applications.
5
+ It is the source of truth for the package's portable API surface. Reviewers
6
+ should be able to check off any change to the package against the section
7
+ that pins the surface it touches.
8
+
9
+ The package extracts every project-specific identifier behind a single
10
+ configure-once init (`configurePanel({...})`) so the same package can ship
11
+ into any Preact-supporting Astro / Vite / Next.js / Rust-SSG consumer. Storage
12
+ keys, console namespace, modal class prefixes, schema id, and the entire tab
13
+ configuration (tiers, items, color cluster extras) are all host-supplied.
14
+
15
+ ---
16
+
17
+ ## 1. `configurePanel({...})` — multi-instance init
18
+
19
+ The package exposes a setup function that returns a `PanelInstanceHandle`.
20
+ Hosts call it once per `storagePrefix` per page lifecycle, before the panel
21
+ adapter for that instance is dynamically imported (typically from a small Astro
22
+ host script that gates the adapter behind a visibility / persistence probe —
23
+ see §6). The same function supports **multiple independent panel instances** on
24
+ one page: call it with a distinct `storagePrefix` to register a new instance;
25
+ call it with the same prefix and equal config for an idempotent no-op.
26
+
27
+ ```ts
28
+ export interface PanelConfig {
29
+ /** Base for every derived storage key. Also the instance id. See §2. */
30
+ storagePrefix: string;
31
+ /** Console API namespace — installed as `window[consoleNamespace].showDesignPanel`, etc. */
32
+ consoleNamespace: string;
33
+ /** BEM-style prefix used by every modal in the panel (export / import / apply). */
34
+ modalClassPrefix: string;
35
+ /** `$schema` value emitted into export JSON and required on import. */
36
+ schemaId: string;
37
+ /** Default filename base — exports save as `${exportFilenameBase}.json`. */
38
+ exportFilenameBase: string;
39
+ /**
40
+ * Optional window-event name that toggles THIS instance's panel.
41
+ *
42
+ * The default (single-panel) instance keeps the historical public event
43
+ * `toggle-design-token-panel` and ignores this field. A configured instance
44
+ * with a NON-default `storagePrefix` listens on this name; when omitted it
45
+ * defaults to `toggle-${storagePrefix}` so two panels on one page get
46
+ * independent toggle channels with no cross-talk.
47
+ */
48
+ toggleEvent?: string;
49
+ /**
50
+ * Host-supplied tab configuration (required). The panel renders a tab strip
51
+ * from this array. See §3 for the full tab/tier model.
52
+ *
53
+ * Hosts MUST supply this field. An empty array is legal but produces a panel
54
+ * with no tabs. The colour tab (id 'color') is driven by tiers + colorExtras
55
+ * on the matching TabConfig entry (no separate colorCluster field).
56
+ */
57
+ tabs: readonly TabConfig[];
58
+ /**
59
+ * Optional host-supplied color-scheme presets. Surfaces additional named
60
+ * `ColorScheme` entries in the Color tab "Scheme..." dropdown alongside the
61
+ * schemes bundled in the color TabConfig's colorExtras. Defaults to `{}`.
62
+ * See §4.5 for the merge contract.
63
+ */
64
+ colorPresets?: Record<string, ColorScheme>;
65
+ /**
66
+ * Optional dev-API endpoint URL. When the host wires the panel into a
67
+ * project that ships its own design-tokens-apply route, supply the URL
68
+ * here; the Apply button POSTs its diff payload to it. When `undefined`,
69
+ * the Apply button stays disabled with a tooltip.
70
+ */
71
+ applyEndpoint?: string;
72
+ /**
73
+ * Optional CSS-var prefix → repo-relative source-file routing map.
74
+ * Drives `routeTokensToFiles` so a host whose tokens use any prefix
75
+ * family can opt into the apply pipeline without forking the package.
76
+ * Apply is gated on `applyEndpoint` AND a non-empty routing map. Omit
77
+ * to disable apply entirely.
78
+ *
79
+ * Example:
80
+ *
81
+ * ```ts
82
+ * applyRouting: {
83
+ * myapp: 'src/styles/tokens.css',
84
+ * 'myapp-extra': 'src/styles/extra-tokens.css',
85
+ * }
86
+ * ```
87
+ */
88
+ applyRouting?: Record<string, string>;
89
+ /**
90
+ * Optional DOM Tweaker feature block. Presence enables the eager header
91
+ * toggle and persisted closed-shell revival path. The object is pure JSON
92
+ * data; `themeCss`, when set, must be a string and must not contain
93
+ * `@import`.
94
+ */
95
+ domTweaker?: {
96
+ /** Optional host Tailwind v4 theme CSS used by the lazy side for suggestions. */
97
+ themeCss?: string;
98
+ };
99
+ /**
100
+ * Optional apply sink. Routes this instance's CSS-var writes and clears
101
+ * through the caller-supplied object instead of `document.documentElement`.
102
+ * See §3.5 for the full sink contract.
103
+ *
104
+ * NOTE: this field carries a function reference and is therefore NOT
105
+ * JSON-serializable. It cannot pass through Astro's inline JSON config.
106
+ * Supply it via a post-configure call or a custom adapter.
107
+ */
108
+ applySink?: ApplySink;
109
+ /**
110
+ * Optional id rename map applied during `loadPersistedState` migration.
111
+ * Keys are old ids found in persisted state; values are either the new
112
+ * canonical id (string) or `null` to drop the legacy id entirely.
113
+ * Defaults to an empty map (no renaming).
114
+ */
115
+ legacyIdRenameMap?: Record<string, string | null>;
116
+ /**
117
+ * Whether opening the panel (any of the auto-remember call sites — see
118
+ * §6.2) writes `${storagePrefix}:autoload` with `'auto'` provenance.
119
+ * Defaults to `true`. Set `false` for a public site that wants a
120
+ * panel-open trigger visible to every visitor without arming owner-mode
121
+ * for whoever clicks it; `enableAutoload()`'s explicit `'1'` write is
122
+ * unaffected either way. See §6.2's Auto-remember footgun.
123
+ */
124
+ autoRememberOnOpen?: boolean;
125
+ }
126
+
127
+ /**
128
+ * Apply sink — routes CSS-var writes for one panel instance somewhere other
129
+ * than the host `:root`. See §3.5.
130
+ */
131
+ export interface ApplySink {
132
+ /** Upsert the given var name→value pairs on the sink target. */
133
+ apply(pairs: ReadonlyArray<readonly [string, string]>): void;
134
+ /** Remove the given var names from the sink target. */
135
+ clear(names: readonly string[]): void;
136
+ }
137
+
138
+ /**
139
+ * Handle returned by `configurePanel`. Identifies one configured instance
140
+ * and exposes its imperative lifecycle controls.
141
+ *
142
+ * `instanceId` equals `config.storagePrefix` — the registry key.
143
+ * Two `configurePanel` calls with the same prefix+config return the SAME
144
+ * handle (referential identity is stable across idempotent re-calls).
145
+ */
146
+ export interface PanelInstanceHandle {
147
+ /** Stable instance id — equal to the instance's `storagePrefix`. */
148
+ readonly instanceId: string;
149
+ /** Show this instance's panel. */
150
+ open(): void;
151
+ /** Hide this instance's panel. */
152
+ close(): void;
153
+ /** Toggle this instance's panel open/closed. */
154
+ toggle(): void;
155
+ /**
156
+ * Deregister this instance from the registry. Unmounts the instance's
157
+ * Preact tree, removes its DOM root, and unbinds its toggle-event listener.
158
+ * After `destroy()` the prefix can be re-configured by calling
159
+ * `configurePanel` again.
160
+ */
161
+ destroy(): void;
162
+ }
163
+
164
+ /**
165
+ * Configure one panel instance. Returns the instance handle.
166
+ * Call once per `storagePrefix` per page lifecycle.
167
+ */
168
+ export function configurePanel(config: PanelConfig): PanelInstanceHandle;
169
+
170
+ /**
171
+ * Lazy preset attachment. Hosts that don't want to ship the preset library
172
+ * inline in the SSR config blob can call this AFTER the panel has been
173
+ * configured to attach the preset map from a deferred dynamic import. Same
174
+ * precedence rules as `PanelConfig.colorPresets` — see §4.5.
175
+ */
176
+ export function setPanelColorPresets(presets: Record<string, ColorScheme>): void;
177
+
178
+ /**
179
+ * Runtime validator at the host-adapter trust boundary. Throws with a
180
+ * message naming the offending field when a parsed inline config is
181
+ * malformed. The Astro adapter calls this automatically on every page
182
+ * load; hosts that wire the panel without the Astro entry point should
183
+ * call it too.
184
+ */
185
+ export function assertValidPanelConfig(value: unknown): asserts value is PanelConfig;
186
+ ```
187
+
188
+ Required behaviours:
189
+
190
+ - **Multi-instance.** Calling `configurePanel` with a **distinct**
191
+ `storagePrefix` registers an independent panel instance (no throw). Distinct
192
+ instances derive independent storage keys, DOM roots, and toggle events and
193
+ do not interfere with each other.
194
+ - **Idempotent for same prefix+config.** Calling `configurePanel` a second
195
+ time with the same `storagePrefix` and structurally-equal config values is a
196
+ no-op and returns the SAME handle. This covers Astro view-transition reruns
197
+ that re-parse the inline JSON config.
198
+ - **Same-prefix-different-config THROWS (`RECONFIGURE_RULE = 'reject-with-error'`).** Calling
199
+ `configurePanel` with a `storagePrefix` already in the registry but a
200
+ structurally-different config throws immediately. To re-configure a prefix,
201
+ call `handle.destroy()` first, then `configurePanel` again.
202
+ - **Synchronous.** No I/O, no awaits. The call must be cheap enough to run
203
+ inline at module-init from the Astro frontmatter side.
204
+ - **Pure data only (except `applySink`).** Every field on `PanelConfig` other
205
+ than `applySink` MUST be JSON-serializable. This is the hard precondition
206
+ for the Astro frontmatter → island prop handoff (§6): Astro stringifies
207
+ props, so functions / class instances do not survive. `applySink` carries
208
+ function references and MUST NOT be included in the Astro JSON config.
209
+ `domTweaker`, when present, is part of this pure-data surface: it may only
210
+ contain the optional string `themeCss` field. `themeCss` MUST NOT contain
211
+ any `@import` occurrence.
212
+ - **No default `PanelConfig` baked into the package.** Hosts MUST configure
213
+ the panel explicitly via `<DesignTokenPanelHost config={...} />` or a
214
+ direct `configurePanel({...})` call. The package ships zero baked-in
215
+ identifiers — every storage prefix, namespace, and manifest entry comes
216
+ from the host.
217
+
218
+ ### Multi-instance example
219
+
220
+ ```ts
221
+ // Primary panel instance
222
+ const primaryHandle = configurePanel({
223
+ storagePrefix: 'myapp-design-token-panel',
224
+ // ...other fields
225
+ });
226
+
227
+ // Secondary panel instance — distinct prefix, independent instance
228
+ const secondaryHandle = configurePanel({
229
+ storagePrefix: 'myapp-preview-panel',
230
+ toggleEvent: 'toggle-preview-panel', // optional; default: toggle-${storagePrefix}
231
+ // ...other fields
232
+ });
233
+
234
+ // Each handle controls only its own instance:
235
+ primaryHandle.open(); // opens primary panel
236
+ secondaryHandle.toggle(); // toggles secondary panel
237
+
238
+ // Listen for the secondary panel's toggle event:
239
+ window.dispatchEvent(new CustomEvent('toggle-preview-panel'));
240
+
241
+ // To re-configure a prefix, destroy first:
242
+ primaryHandle.destroy();
243
+ configurePanel({ storagePrefix: 'myapp-design-token-panel', /* new config */ });
244
+ ```
245
+
246
+ ### Per-instance toggle events
247
+
248
+ | Instance | `storagePrefix` | `toggleEvent` field | Effective toggle event name |
249
+ | --- | --- | --- | --- |
250
+ | Default (single-panel path) | `'zudo-design-token-panel'` (the historical default) | (ignored) | `toggle-design-token-panel` |
251
+ | Any other | any distinct value | omitted | `toggle-${storagePrefix}` |
252
+ | Any other | any distinct value | supplied | the supplied string |
253
+
254
+ The default instance keeps the historical `toggle-design-token-panel` event for
255
+ backwards compatibility. Every non-default instance gets its own independent
256
+ channel so two panels on one page do not cross-talk.
257
+
258
+ ---
259
+
260
+ ## 2. Storage-key derivation
261
+
262
+ `storagePrefix` is the only knob that controls every persisted key. The panel
263
+ derives the keys at runtime from this single base.
264
+
265
+ | Logical key | Derivation | Owner | Purpose |
266
+ | ----------- | --------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
267
+ | `state-v3` | `${storagePrefix}-state-v3` | tweak-state | Current unified envelope: tabs map + color + spacing + typography + size + panelPosition. Added `tabs` map for generic host-coined tabs. |
268
+ | `state-v2` | `${storagePrefix}-state-v2` | tweak-state (legacy) | Pre-v3 unified envelope (color + spacing + typography + size + panelPosition). Migrated into `state-v3` on first load, then deleted. |
269
+ | `state-v1` | `${storagePrefix}-state` | tweak-state (legacy) | Pre-v2 flat-state format (Color-only). Migrated into `state-v3` on first load, then deleted. |
270
+ | `open` | `${storagePrefix}-open` | panel | Mirror of the panel's `open` boolean state (so the next mount opens directly into the user's last state without a post-render toggle dispatch). |
271
+ | `position` | `${storagePrefix}-position` | panel | Drag position (`{ top, left }`) so the panel reappears where the user left it. |
272
+ | `visible` | `${storagePrefix}:visible` | adapter | Adapter-level visibility-intent flag, owned by the lazy-load gate (§6). |
273
+ | `autoload` | `${storagePrefix}:autoload` | autoload-state | Owner-mode autoload flag. `'1'` (explicit, set by `enableAutoload()`) or `'auto'` (auto-remembered, set by opening the panel — see §6.2) both mean "load the panel bundle eagerly and mount CLOSED on every page load." `enableAutoload()` / `disableAutoload()` manage the explicit value; `disableAutoload()` clears either. See §6.2. |
274
+ | `domtweaker-enabled` | `${storagePrefix}-domtweaker-enabled` | dom-tweaker-state | DOM Tweaker enabled bit. `'1'` means "mount the closed shell and load the DOM Tweaker lazy boundary." Only meaningful when `PanelConfig.domTweaker` is present. |
275
+
276
+ **Constraint — colon, not dash, for `visible` and `autoload`.** Both adapter-
277
+ level flags use a `:` separator; every other derived key uses `-`. The colon
278
+ form is a historical artifact for `visible`, preserved for storage-key
279
+ continuity; `autoload` follows the same colon convention to pair with it.
280
+ The derivation MUST emit the colon literally; do not "fix" it during refactors.
281
+
282
+ **Storage-key derivation is literal.** With `storagePrefix: "myapp-design-token-panel"`,
283
+ the derivation produces:
284
+
285
+ ```
286
+ myapp-design-token-panel-state-v3
287
+ myapp-design-token-panel-state-v2
288
+ myapp-design-token-panel-state
289
+ myapp-design-token-panel-open
290
+ myapp-design-token-panel-position
291
+ myapp-design-token-panel:visible
292
+ myapp-design-token-panel:autoload
293
+ myapp-design-token-panel-domtweaker-enabled
294
+ ```
295
+
296
+ Unit tests in the package verify these derivations with literal-equality
297
+ checks, and the v1 → v3 / v2 → v3 migration paths at first-load are part of
298
+ the test matrix.
299
+
300
+ ### 2.1 Default first-open geometry
301
+
302
+ When the `position` key (and, likewise, the size key) has no persisted value
303
+ yet, the panel does not fall back to a fixed pixel position. The fallback is
304
+ computed at open time as one coherent rectangle:
305
+
306
+ - **Size is computed first**, from the historical `min(1200, 0.8·vw) ×
307
+ min(800, 0.8·vh)` rule clamped to a minimum-size floor and the current
308
+ viewport. **Position is derived from that same clamped size** — centered
309
+ in the viewport, then run through a containment clamp so the whole
310
+ rectangle stays inside `[0, innerWidth]` × `[0, innerHeight]`. Position and
311
+ size are never computed independently; a host cannot observe a fallback
312
+ position that assumes a different width than the fallback size.
313
+ - **Full containment is guaranteed at every viewport width**, including
314
+ phone widths — a first-open panel never spawns with any part off-screen.
315
+ This holds when the size key IS persisted but the `position` key is not
316
+ (a resize without a drag): the fallback position is centered and contained
317
+ against the persisted size, not against the default one.
318
+ - **The fallback is instance-aware.** Each additional panel instance
319
+ concurrently mounted on the page offsets its own fallback position by 24px
320
+ on both axes, keyed to mount order with lowest-free-slot reuse (a released
321
+ slot — e.g. from `destroy()` — is reused by the next instance that mounts,
322
+ rather than the ordinal growing forever). This exists only to keep
323
+ simultaneously-opened instances from landing exactly on top of one
324
+ another; it has no effect once a `position` value is persisted.
325
+ - **A persisted `position` value always wins over the cascade.** The 24px
326
+ offset applies only to the computed fallback, never to a stored value —
327
+ once `position` is written, that instance reopens at the exact stored
328
+ coordinates regardless of how many other instances are mounted.
329
+ - **Containment takes priority over cascade distinctness.** On a viewport
330
+ with too little spare room, the 24px offset is clamped down toward
331
+ whatever room is left (potentially to 0) so the panel stays fully
332
+ contained; it is not the case that both "cascade offset is always applied"
333
+ and "the panel is always fully contained" hold simultaneously. Each axis
334
+ degrades on its own: one axis can run out of slack (offset clamped to 0)
335
+ while the other still applies the full 24px.
336
+ - **Out of scope for this section — the drag-recovery clamp.** Once a panel
337
+ has been dragged, repositioning is governed by a separate, more permissive
338
+ clamp that only guarantees a 60px grip of the panel stays on-screen and
339
+ otherwise allows it to hang off any edge. That clamp is unrelated to this
340
+ fallback-geometry contract and is unchanged by it.
341
+
342
+ ---
343
+
344
+ ## 3. Tab / tier model contract
345
+
346
+ The panel is data-driven through a `tabs` array on `PanelConfig`. Every
347
+ visible tab, including the color tab, is expressed as a `TabConfig` entry.
348
+
349
+ ### 3.1 Public interfaces
350
+
351
+ These shapes are defined in `src/tokens/tier-model.ts` and frozen as the
352
+ public surface:
353
+
354
+ ```ts
355
+ // Value-kind discriminated union — describes how a tier item is edited.
356
+ export type TierValueKind =
357
+ | { kind: 'length'; step: number; unit: string }
358
+ | { kind: 'number'; step: number }
359
+ | { kind: 'select'; options: readonly string[] }
360
+ | { kind: 'text' }
361
+ | { kind: 'cursor' }
362
+ | { kind: 'content' }
363
+ | { kind: 'mask-image' }
364
+ | { kind: 'color' };
365
+
366
+ export interface PillSpec {
367
+ value: string;
368
+ customDefault: string;
369
+ }
370
+
371
+ /** A single editable or reference token within a tier. */
372
+ export interface TierItem {
373
+ /** Stable id used as the key in persisted state (e.g. `hsp-2xs`). */
374
+ id: string;
375
+ /** CSS custom property written to `:root` (e.g. `--myapp-spacing-hgap-2xs`). */
376
+ cssVar: string;
377
+ /** Display label shown in the panel row. */
378
+ label: string;
379
+ /** Optional manifest group — tab components use this for section headers. */
380
+ group?: string;
381
+ /** Default value as a CSS string (`0.125rem`, `12px`, etc.). */
382
+ default: string;
383
+ /** Discriminated union describing the control kind and its metadata. */
384
+ type: TierValueKind;
385
+ /** Opt-in pill toggle (e.g. for a `--radius-full` 9999px sentinel). */
386
+ pill?: PillSpec;
387
+ /** Read-only items are displayed but not editable. */
388
+ readonly?: true;
389
+ }
390
+
391
+ /** A named set of tier items that share a value kind. */
392
+ export interface TierConfig {
393
+ /** Stable id for this tier (e.g. `base`, `scale`, `semantic`). */
394
+ id: string;
395
+ /** Display label for the tier heading. */
396
+ label: string;
397
+ /** Ordered list of items in this tier. All items MUST share the same kind. */
398
+ items: readonly TierItem[];
399
+ /**
400
+ * When set, this tier's items hold references. Each item's `default` is the
401
+ * id of an item in the tier whose id matches `referencesTier`. The apply
402
+ * pipeline emits `var(--target-cssvar)` for ref-tier items at apply time.
403
+ */
404
+ referencesTier?: string;
405
+ }
406
+
407
+ /**
408
+ * Color-cluster extras — the non-tier fields required for the color tab.
409
+ * Palette and semantic data move into the tier model as TierItems; ColorClusterExtras
410
+ * carries the structural metadata (base roles, scheme registry, panel settings).
411
+ */
412
+ export interface ColorClusterExtras {
413
+ id: string;
414
+ label?: string;
415
+ baseRoles: Partial<Record<BaseRoleKey, string>>;
416
+ baseDefaults: Partial<Record<BaseRoleKey, number>>;
417
+ defaultShikiTheme: string;
418
+ colorSchemes: Record<string, ColorScheme>;
419
+ panelSettings: ClusterPanelSettings;
420
+ }
421
+
422
+ /** Top-level tab entry on PanelConfig.tabs. */
423
+ export interface TabConfig {
424
+ /** Stable id. Reserved ids: 'color' (primary color tab), 'color-secondary'. */
425
+ id: string;
426
+ /** Display label rendered on the tab strip. */
427
+ label: string;
428
+ /** Ordered list of tiers within this tab. */
429
+ tiers: readonly TierConfig[];
430
+ /** Tier ids whose rows are hidden behind an Advanced <details> disclosure. */
431
+ advancedTiers?: readonly string[];
432
+ /**
433
+ * Required on color tabs (id 'color' / 'color-secondary'). Carries the
434
+ * structural metadata (base roles, scheme registry, panel settings) for the
435
+ * color tab's palette picker and semantic table. Absent on non-color tabs.
436
+ */
437
+ colorExtras?: ColorClusterExtras;
438
+ }
439
+ ```
440
+
441
+ ### 3.2 Reserved tab ids
442
+
443
+ | Tab id | Meaning |
444
+ | ------------------ | -------------------------------------------------- |
445
+ | `color` | Primary color tab — palette + base roles + semantics + scheme picker. Requires `colorExtras`. |
446
+ | `color-secondary` | Secondary color tab (same shape as `color`). Requires `colorExtras`. |
447
+
448
+ Any other id dispatches to `GenericTab`, which renders the tab's `tiers`
449
+ using kind-appropriate editors.
450
+
451
+ ### 3.3 Validation rules
452
+
453
+ `assertValidPanelConfig` enforces these structural rules at the host-adapter
454
+ trust boundary:
455
+
456
+ - `tabs` must be an array.
457
+ - Every tab must have a unique, non-empty `id`.
458
+ - Every tier within a tab must have a unique, non-empty `id`.
459
+ - Every item within a tab must have a unique `id` across all tiers in that tab.
460
+ - Every `item.cssVar` must start with `--` and be non-empty after the prefix.
461
+ - All items within a single tier must share the same `kind` (mixed kinds in a
462
+ tier are rejected).
463
+ - `referencesTier` must name an existing tier in the same tab, and the
464
+ referencing tier's kind must match the referenced tier's kind.
465
+
466
+ ### 3.4 Apply behaviour for ref-tier items
467
+
468
+ When a `TierConfig` carries `referencesTier`, the apply pipeline treats each
469
+ item's persisted value as the id of an item in the referenced tier. The
470
+ emitted CSS override is `var(--target-cssvar)` where `target-cssvar` is the
471
+ `cssVar` of the matched item in the base tier.
472
+
473
+ By default the write target is `:root` (`document.documentElement`). When a
474
+ `PanelConfig.applySink` is configured for the instance, writes are routed
475
+ through the sink instead — see §3.5.
476
+
477
+ ### 3.5 `applySink` — optional CSS-var write target
478
+
479
+ When `PanelConfig.applySink` is set, all CSS-var writes and clears for that
480
+ panel instance route through the sink rather than `document.documentElement`.
481
+ This enables embedding the panel in a shadow root, an iframe document, or a
482
+ test spy without touching `:root`.
483
+
484
+ ```ts
485
+ interface ApplySink {
486
+ /** Upsert the given var name→value pairs on the sink target. */
487
+ apply(pairs: ReadonlyArray<readonly [string, string]>): void;
488
+ /** Remove the given var names from the sink target. */
489
+ clear(names: readonly string[]): void;
490
+ }
491
+ ```
492
+
493
+ Contract:
494
+
495
+ - `apply(pairs)` — **upsert**: set each `pairs[i][0]` CSS var to
496
+ `pairs[i][1]` on the sink target.
497
+ - `clear(names)` — **remove**: remove each named CSS var from the sink target.
498
+ - **Reset clears the instance's full token set.** When the user clicks Reset,
499
+ `sink.clear` receives every var the instance can own (all palette,
500
+ base-role, semantic, and non-color tab vars) — not just the currently-dirty
501
+ vars — so the sink target is completely cleaned.
502
+ - **Default (no sink):** writes go to `document.documentElement` (unchanged
503
+ behaviour for existing integrations).
504
+ - **Sink errors are non-fatal.** The apply pipeline swallows errors from
505
+ `sink.apply` and `sink.clear` with `console.warn` and continues.
506
+ - **The host owns the sink.** The package calls `apply`/`clear`; it does not
507
+ manage the sink target's lifecycle. A host that passes a shadow-root target
508
+ must keep the target alive as long as the panel instance is alive.
509
+ - **Not JSON-serializable.** `applySink` carries function references and
510
+ MUST NOT be included in the Astro inline JSON config. Supply it via a
511
+ post-configure approach or a custom adapter that calls `configurePanel`
512
+ directly after adding the sink field.
513
+
514
+ Example — routing a panel instance to a shadow root:
515
+
516
+ ```ts
517
+ const shadowHost = document.createElement('div');
518
+ document.body.appendChild(shadowHost);
519
+ const shadow = shadowHost.attachShadow({ mode: 'open' });
520
+
521
+ const handle = configurePanel({
522
+ storagePrefix: 'myapp-shadow-panel',
523
+ // ...other required fields...
524
+ applySink: {
525
+ apply(pairs) {
526
+ for (const [name, value] of pairs) {
527
+ (shadow.host as HTMLElement).style.setProperty(name, value);
528
+ }
529
+ },
530
+ clear(names) {
531
+ for (const name of names) {
532
+ (shadow.host as HTMLElement).style.removeProperty(name);
533
+ }
534
+ },
535
+ },
536
+ });
537
+ ```
538
+
539
+ ### 3.6 Helpers (re-exported from the package root)
540
+
541
+ ```ts
542
+ export function isLengthKind(v: TierValueKind): boolean;
543
+ export function isNumberKind(v: TierValueKind): boolean;
544
+ export function isSelectKind(v: TierValueKind): boolean;
545
+ export function isTextKind(v: TierValueKind): boolean;
546
+ export function isColorKind(v: TierValueKind): boolean;
547
+ export function isCursorKind(v: TierValueKind): boolean;
548
+ export function isContentKind(v: TierValueKind): boolean;
549
+ export function isMaskImageKind(v: TierValueKind): boolean;
550
+ ```
551
+
552
+ ---
553
+
554
+ ## 4. Color tab contract
555
+
556
+ The color tab — palette + base roles + semantic table + scheme list — is
557
+ expressed as a `TabConfig` with `id: 'color'` and a `colorExtras` field.
558
+ Palette and semantic tokens are `TierItem` entries inside the tab's `tiers`;
559
+ the `colorExtras` object carries the structural metadata.
560
+
561
+ ### 4.1 `ColorClusterExtras` interface
562
+
563
+ ```ts
564
+ export type BaseRoleKey = 'background' | 'foreground' | 'cursor' | 'selectionBg' | 'selectionFg';
565
+
566
+ export interface ColorClusterExtras {
567
+ /** Stable id — used for debugging / logging only. */
568
+ id: string;
569
+ /**
570
+ * Optional human-visible label rendered in the Color tab section headings.
571
+ * When absent, the tab falls back to `id.toUpperCase()`.
572
+ */
573
+ label?: string;
574
+ /**
575
+ * Map of base-role name → CSS custom-property name. A cluster MAY declare
576
+ * a subset (an empty map is legal); only declared roles are written on apply.
577
+ */
578
+ baseRoles: Partial<Record<BaseRoleKey, string>>;
579
+ /**
580
+ * Fallback palette indices when a scheme omits a base role.
581
+ */
582
+ baseDefaults: Partial<Record<BaseRoleKey, number>>;
583
+ /** Fallback `shikiTheme` when a scheme lacks one. (Inert when no shiki integration.) */
584
+ defaultShikiTheme: string;
585
+ /**
586
+ * Color-scheme registry. Keyed by display name (`"Default Dark"`, etc.).
587
+ * Pass `{}` for clusters that don't use schemes.
588
+ */
589
+ colorSchemes: Record<string, ColorScheme>;
590
+ /**
591
+ * Panel-level scheme settings. Drives `getActiveSchemeName` / `initColorFromScheme`.
592
+ */
593
+ panelSettings: {
594
+ /** Scheme name to seed state from when `colorMode` is `false`. */
595
+ colorScheme: string;
596
+ /**
597
+ * Optional light/dark pairing. When set to an object, the panel honours
598
+ * `document.documentElement[data-theme]` and switches schemes accordingly
599
+ * on init. Set to `false` to disable the light/dark UI.
600
+ */
601
+ colorMode: false | { defaultMode: 'light' | 'dark'; lightScheme: string; darkScheme: string };
602
+ };
603
+ }
604
+ ```
605
+
606
+ `ColorScheme` shape:
607
+
608
+ ```ts
609
+ export type ColorRef = number | string;
610
+
611
+ export interface ColorScheme {
612
+ background: ColorRef;
613
+ foreground: ColorRef;
614
+ cursor: ColorRef;
615
+ selectionBg: ColorRef;
616
+ selectionFg: ColorRef;
617
+ palette: readonly string[]; // length must match the palette tier's item count
618
+ shikiTheme: string;
619
+ semantic?: Record<string, ColorRef>;
620
+ }
621
+ ```
622
+
623
+ > **Public alias** — the runtime type in `src/config/` is
624
+ > `ColorClusterDataConfig`. `ColorClusterConfig` is re-exported from the
625
+ > package root as the public-facing alias for the same shape:
626
+ > `import type { ColorClusterConfig } from '@takazudo/zdtp'`.
627
+
628
+ ### 4.2 JSON-serializable constraint
629
+
630
+ **Every field on the color `TabConfig` (including `colorExtras` and every
631
+ `ColorScheme` it nests) MUST be JSON-serializable.** No function fields, no
632
+ class instances, no `Symbol` keys, no `undefined` where `null` is meant. This
633
+ is enforced by the Astro frontmatter → component prop handoff (§6).
634
+
635
+ Palette CSS-var names are therefore expressed as `TierItem.cssVar` strings, not
636
+ as function templates. Each palette slot is an explicit `TierItem`.
637
+
638
+ ### 4.3 Multi-cluster support
639
+
640
+ The package supports a primary color cluster and an optional secondary cluster.
641
+
642
+ | Tab id | Meaning |
643
+ | ------------------ | --------------------------------------------------- |
644
+ | `color` | Primary cluster (required for color support). |
645
+ | `color-secondary` | Secondary cluster (optional — omit the tab to hide the secondary section). |
646
+
647
+ Both tabs follow the same render / apply / clear contract, scoped to their
648
+ respective palette and semantic vocabulary.
649
+
650
+ ### 4.4 Host-supplied scheme presets — `colorPresets`
651
+
652
+ `PanelConfig.colorPresets` is the optional, host-supplied preset map surfaced
653
+ by the Color tab "Scheme..." dropdown. It defaults to `{}` and the package
654
+ itself ships zero presets.
655
+
656
+ | `colorPresets` value | Meaning | Effect |
657
+ | ------------------------------ | --------------- | --------------------------------------------------------------------- |
658
+ | `undefined` (field omitted) | Default | Equivalent to `{}` — only `colorExtras.colorSchemes` populates the dropdown. |
659
+ | `{}` | Explicit empty | Same as `undefined`. |
660
+ | `Record<string, ColorScheme>` | Host-supplied | Each key surfaces as a `<option>` below the cluster's bundled schemes. Sorted alphabetically. |
661
+
662
+ **Merge order in the dropdown:**
663
+
664
+ ```
665
+ <option disabled>Scheme...</option>
666
+ ... colorExtras.colorSchemes (insertion order) ...
667
+ <hr />
668
+ ... colorPresets (alphabetical) ...
669
+ ```
670
+
671
+ **Key collision** — if a `colorPresets` entry shares a name with one in
672
+ `colorExtras.colorSchemes`, the bundled scheme wins for the
673
+ `handleLoadPreset` lookup.
674
+
675
+ **Lazy attachment via `setPanelColorPresets()`** — hosts that ship a large
676
+ preset library can omit `colorPresets` from the SSR config blob and call
677
+ `setPanelColorPresets(presets)` from a client-side dynamic import.
678
+
679
+ ### 4.5 Apply behaviour
680
+
681
+ The apply pipeline for color tabs:
682
+
683
+ - For each palette `TierItem` in the palette tier, write
684
+ `item.cssVar` ← `palette[i]` from the active scheme / user override.
685
+ - For each `(roleKey, cssName)` in `colorExtras.baseRoles`, write
686
+ `cssName` ← `palette[state[roleKey]]`.
687
+ - For each semantic `TierItem`, resolve
688
+ `state.semanticMappings[key] ?? colorExtras.semanticDefaults[key]`
689
+ through `resolveMapping` and write `item.cssVar` ← resolved hex.
690
+ - `clearAppliedStyles()` removes every property the cluster could have set.
691
+
692
+ ### 4.6 `applyEndpoint` and `applyRouting`
693
+
694
+ The Apply modal's button is gated on two `PanelConfig` fields:
695
+
696
+ | Field | Type | Purpose |
697
+ | -------------- | ----------------------- | ----------------------------------------------------------------------- |
698
+ | `applyEndpoint` | `string` | URL the Apply button POSTs the flat cssVar diff to. |
699
+ | `applyRouting` | `Record<string, string>` | CSS-var prefix family → repo-relative source-file path. |
700
+
701
+ When both are set (and the routing map is non-empty), the Apply button is
702
+ enabled. When either is missing, the modal still mounts so the user can
703
+ preview the diff, but the action stays disabled with a tooltip.
704
+
705
+ ---
706
+
707
+ ## 5. Apply pipeline
708
+
709
+ The **bin server** is the reference implementation for the apply contract.
710
+
711
+ ### 5.1 Request & response envelopes
712
+
713
+ The Apply button POSTs to `PanelConfig.applyEndpoint` with a flat JSON diff.
714
+
715
+ **Request**
716
+
717
+ ```
718
+ POST <applyEndpoint>
719
+ Content-Type: application/json
720
+
721
+ {
722
+ "tokens": {
723
+ "--myapp-spacing-md": "2rem",
724
+ "--myapp-extra-slider-length": "200px"
725
+ }
726
+ }
727
+ ```
728
+
729
+ **Response 200 (success)**
730
+
731
+ ```json
732
+ {
733
+ "ok": true,
734
+ "updated": [
735
+ {
736
+ "file": "src/styles/tokens.css",
737
+ "changed": ["--myapp-spacing-md"],
738
+ "unchanged": ["--myapp-spacing-lg"],
739
+ "unknown": []
740
+ }
741
+ ],
742
+ "unknownCssVars": [],
743
+ "unchangedCssVars": ["--myapp-spacing-lg"]
744
+ }
745
+ ```
746
+
747
+ **Response 400 (bad request)**
748
+
749
+ ```json
750
+ {
751
+ "ok": false,
752
+ "error": "<message>",
753
+ "rejected"?: ["--invalid-token"]
754
+ }
755
+ ```
756
+
757
+ Returned for: malformed JSON, missing `tokens` field, empty tokens map,
758
+ invalid token names (no `--` prefix, spaces, slashes), unsupported CSS-var
759
+ prefix, path escape attempts.
760
+
761
+ **Response 403 (Forbidden)**
762
+
763
+ ```json
764
+ { "ok": false, "error": "Origin not allowed" }
765
+ ```
766
+
767
+ **Response 405 (Method not allowed)**
768
+
769
+ Empty body, `Allow: POST, OPTIONS` header.
770
+
771
+ **Response 409 (Conflict)**
772
+
773
+ ```json
774
+ { "ok": false, "error": "No top-level :root { ... } block in <file>" }
775
+ ```
776
+
777
+ **Response 500 (Internal server error)**
778
+
779
+ ```json
780
+ {
781
+ "ok": false,
782
+ "error": "<message>",
783
+ "failedFile"?: "<relativePath>",
784
+ "restoreFailures"?: ["<file1>", "<file2>"]
785
+ }
786
+ ```
787
+
788
+ ### 5.2 Reference implementation
789
+
790
+ The bin server (`src/bin/server.ts`) is the reference for this contract. It
791
+ reads `--routing <json>` at startup and exposes a Fetch API handler
792
+ (`createApplyHandler` from `src/server/create-apply-handler.ts`). Read the
793
+ handler source as the spec.
794
+
795
+ ### 5.3 Implementing the contract natively (advanced)
796
+
797
+ Hosts physically unable to spawn Node.js must:
798
+
799
+ 1. Validate token names — reject names without `--` prefix, with spaces or slashes.
800
+ 2. Sanitize and route — split each CSS-var prefix, look up the target file in
801
+ the routing map, reject prefixes not in the map.
802
+ 3. Path safety — resolve each target path to an absolute path, verify it sits
803
+ within `writeRoot`, reject path-escape attempts.
804
+ 4. Read & parse — load each CSS file, find the `:root { ... }` block (fail
805
+ 409 if missing), parse the existing variable values.
806
+ 5. Compute rewrite — compute `changed` / `unchanged` / `unknown`, build the
807
+ updated `:root` block.
808
+ 6. Atomic write — keep the original file content in memory. Write updated
809
+ content to a temp file. Atomically rename temp to target. If any write
810
+ fails, restore every previously-written file.
811
+ 7. Respond — return the exact JSON envelope shapes pinned in §5.1.
812
+
813
+ ### 5.4 Routing config — single source of truth
814
+
815
+ Both the **panel UI** (`PanelConfig.applyRouting`) and the **bin** (`--routing`
816
+ flag) read the same JSON file. The map is keyed by the CSS-var prefix family
817
+ (without leading `--` and trailing `-`); the value is a repo-relative path to
818
+ the source file the bin rewrites.
819
+
820
+ ---
821
+
822
+ ## 6. Astro export contract
823
+
824
+ The package exposes a second entry point, `./astro`, for Astro projects.
825
+
826
+ ```astro
827
+ ---
828
+ import DesignTokenPanelHost from '@takazudo/zdtp/astro/DesignTokenPanelHost.astro';
829
+ import { panelConfig } from '~/lib/design-token-panel-config';
830
+ ---
831
+
832
+ <DesignTokenPanelHost config={panelConfig} />
833
+ ```
834
+
835
+ ### 6.1 Component prop
836
+
837
+ The component accepts the full `PanelConfig` from §1 as its `config` prop.
838
+ Astro frontmatter passes the value at SSR time; the adapter serialises it into
839
+ the rendered island and reads it back at runtime to call `configurePanel(config)`.
840
+
841
+ This is the reason the JSON-serializable constraint in §4.2 is non-negotiable.
842
+
843
+ ### 6.2 Lazy-load gate
844
+
845
+ The host adapter fires one eager `loadPanelModule()` call when any of the
846
+ following signals is present in `localStorage` at page load:
847
+
848
+ ```ts
849
+ if (
850
+ wasVisible(visibleKey) || // panel was open last visit (`:visible`)
851
+ wasVisible(openKey) || // same, via the `-open` mirror
852
+ hasPersistedOverrides() || // user has saved token tweaks
853
+ shouldAutoload() || // owner-autoload flag set ('1' or 'auto')
854
+ loadElementPathEnabled() || // element-path inspector enabled
855
+ loadDomTweakerEnabled() // DOM Tweaker enabled and configured
856
+ ) {
857
+ void loadPanelModule();
858
+ }
859
+ ```
860
+
861
+ - `wasVisible()` — reads `${storagePrefix}:visible` (colon-form key, §2), and
862
+ is applied a second time to the `${storagePrefix}-open` mirror (dash-form,
863
+ §2) that `panel.tsx` writes alongside it. Either key holding `'1'` means the
864
+ panel was open before the last navigation.
865
+ - `hasPersistedOverrides()` — scans every `localStorage` key matching the
866
+ `${storagePrefix}-state` family (dash-form, §2: `-state` (v1) through every
867
+ `-state-vN`) and returns `true` when at least one holds a non-empty envelope
868
+ (malformed JSON also counts as `true` — fail open, so the panel loads and
869
+ can migrate or reject the payload rather than stranding the user with data
870
+ it can never see). This is a **content check**, not a presence check on a
871
+ specific version key — an empty `{}` / `[]` / `null` / `''` does NOT trigger
872
+ it. (zdtp itself never writes such a value: `clearPersistedState()` removes
873
+ the `-state` keys outright, so this guard only covers envelopes written by
874
+ hand or by another tool.) Overrides MUST be re-applied to `:root` even when
875
+ the panel stays hidden, otherwise hard-nav produces a FOUT.
876
+ - `shouldAutoload()` — reads `${storagePrefix}:autoload` (colon-form, §2).
877
+ Returns `true` when the flag is `'1'` (explicit, written by `enableAutoload()`)
878
+ OR `'auto'` (auto-remembered, written by opening the panel — see "Auto-remember
879
+ on open" below). This is the owner-mode signal: the panel bundle fetches
880
+ eagerly and mounts CLOSED so the element-path inspector is armed even though
881
+ the panel UI is hidden. General visitors (no flag, or `'0'`) pay zero bundle
882
+ cost. A downstream host that wants to distinguish the two populations can
883
+ test `=== '1'` directly — see "Auto-remember on open" for the caveat.
884
+ - `loadElementPathEnabled()` — reads the element-path inspector's persistence
885
+ key. Returns `true` when the inspector was left enabled. Ensures the Preact
886
+ shell is mounted (the inspector runs inside it) even when the panel UI is
887
+ hidden and no token overrides are persisted.
888
+ - `loadDomTweakerEnabled()` — reads the DOM Tweaker persistence key. Returns
889
+ `true` when `PanelConfig.domTweaker` is present and the tweaker was left
890
+ enabled. Ensures the Preact shell is mounted and the lazy boundary is
891
+ imported even when the panel UI is hidden.
892
+
893
+ When none of the five signals is present — the common case for first-time
894
+ visitors and general site visitors on a public site with owner-autoload — the
895
+ panel bundle is NOT fetched and the page is completely free of panel JS.
896
+
897
+ #### Storage-key table for §6.2 signals
898
+
899
+ | Signal | Key derivation | Owner |
900
+ |--------|---------------|-------|
901
+ | `wasVisible` | `${storagePrefix}:visible`, OR its `${storagePrefix}-open` mirror | adapter |
902
+ | `hasPersistedOverrides` | Content check across the `${storagePrefix}-state` family (`-state`, `-state-v2`, `-state-v3`, ... — every version, not a fixed list) | tweak-state |
903
+ | `shouldAutoload` | `${storagePrefix}:autoload`, matching `'1'` or `'auto'` | autoload-state |
904
+ | `loadElementPathEnabled` | `${storagePrefix}-elpath-enabled` | element-path-state |
905
+ | `loadDomTweakerEnabled` | `${storagePrefix}-domtweaker-enabled` | dom-tweaker-state |
906
+
907
+ #### DOM Tweaker config and runtime invariants
908
+
909
+ - `PanelConfig.domTweaker` is disabled by omission. When absent, the header
910
+ toggle is hidden and the persisted `-domtweaker-enabled` key is ignored by
911
+ the lazy-load gate.
912
+ - `PanelConfig.domTweaker` is a plain JSON object. The only supported field is
913
+ `themeCss?: string`; unknown fields, functions, non-string `themeCss`, and
914
+ any `@import` occurrence in `themeCss` are rejected by
915
+ `assertValidPanelConfig`.
916
+ - The eager side passes `storagePrefix`, `themeCss`, and `consoleNamespace`
917
+ explicitly into the lazy DOM Tweaker boundary. The lazy boundary MUST NOT
918
+ read module-global `PanelConfig`.
919
+ - The DOM Tweaker runtime/bridge/portal are document-global. At most one panel
920
+ instance can have DOM Tweaker active in a document. First activation wins;
921
+ a second instance's toggle is inert and emits a `console.warn` tagged with
922
+ that second instance's `consoleNamespace`.
923
+
924
+ #### Owner-autoload `enableAutoload` / `disableAutoload` contract
925
+
926
+ `enableAutoload()` (exported from the package root; also wired on
927
+ `window[consoleNamespace]` by the Astro host adapter):
928
+
929
+ 1. Sets `${storagePrefix}:autoload = '1'`.
930
+ 2. Sets `${storagePrefix}-elpath-enabled = '1'` once (arms the Alt+click
931
+ element-path inspector).
932
+ 3. Loads the panel bundle (if not already loaded).
933
+ 4. Mounts the Preact shell CLOSED so the element-path inspector is active
934
+ without opening the panel UI.
935
+
936
+ `disableAutoload()`:
937
+
938
+ 1. Clears `${storagePrefix}:autoload` (removes the key).
939
+ 2. Sets `${storagePrefix}:visible` to `'0'`.
940
+ 3. Sets `${storagePrefix}-elpath-enabled` to `'0'`.
941
+ 4. Removes the open-state key (`${storagePrefix}-open`).
942
+ 5. Unmounts the Preact shell (drives effect cleanups, removes root).
943
+
944
+ #### Auto-remember on open
945
+
946
+ Any action that shows the panel (`showDesignPanel()`, `toggleDesignPanel()`,
947
+ or the panel's header button) MUST also write
948
+ `${storagePrefix}:autoload = 'auto'` (auto-remembered provenance, distinct
949
+ from the `'1'` that `enableAutoload()` writes) — implemented by
950
+ `rememberAutoload()`. This ensures that once the owner has opened the panel
951
+ on any page, subsequent visits to the same site reload it automatically
952
+ without a second explicit `enableAutoload()` call. An existing explicit `'1'`
953
+ is never downgraded to `'auto'` by this path.
954
+
955
+ `rememberAutoload()` no-ops when `PanelConfig.autoRememberOnOpen === false`
956
+ (default `true`) — see the `PanelConfig` interface in §1. This lets a host
957
+ serve a visible "open panel" trigger to every visitor without arming
958
+ owner-mode for whoever clicks it; `enableAutoload()`'s explicit `'1'` write
959
+ is unaffected by this setting either way.
960
+
961
+ **Consequence for public-site owners:** any open trigger (a visible button, a
962
+ keyboard shortcut, etc.) becomes a de-facto owner-mode opt-in for anyone who
963
+ uses it, unless `autoRememberOnOpen` is set to `false`. Gate or omit such
964
+ triggers on public sites, rely on the console `enableAutoload()` call as the
965
+ owner's deliberate opt-in, or set `autoRememberOnOpen: false` if the site
966
+ wants the trigger visible to everyone.
967
+
968
+ **Legacy caveat.** A downstream host that reads `:autoload` directly and
969
+ tests `=== '1'` to identify only explicit owners will NOT retroactively shed
970
+ browsers that auto-remembered before this provenance split shipped — those
971
+ already hold `'1'`, and that provenance was never recorded, so it cannot be
972
+ reclassified. The `=== '1'` discrimination applies only to opens made from
973
+ this version onward.
974
+
975
+ ### 6.3 Astro view-transition lifecycle
976
+
977
+ The adapter's existing `astro:before-swap` and `astro:page-load` listeners
978
+ stay. They are Astro-specific and only register when `document` is available:
979
+
980
+ - `astro:before-swap` → unmount the Preact tree, remove the host node, snapshot/restore visibility intent.
981
+ - `astro:page-load` → re-apply persisted overrides + re-materialise the shell when any of the four gate signals (§6.2) is true.
982
+
983
+ ### 6.4 Console API
984
+
985
+ ```ts
986
+ window[consoleNamespace].showDesignPanel = () => Promise<void>;
987
+ window[consoleNamespace].hideDesignPanel = () => Promise<void>;
988
+ window[consoleNamespace].toggleDesignPanel = () => Promise<void>;
989
+ window[consoleNamespace].enableAutoload = () => Promise<void>; // owner-autoload opt-in
990
+ window[consoleNamespace].disableAutoload = () => Promise<void>; // owner-autoload teardown
991
+ ```
992
+
993
+ ### 6.5 Fixed-name global open API (`window.zdtp`)
994
+
995
+ `consoleNamespace` above is a REQUIRED host-chosen field — every consumer
996
+ historically had to know its own namespace before it could open the panel
997
+ from the console. `window.zdtp` is an ADDITIVE, fixed-name alias for the
998
+ three open/close verbs, so `zdtp.show()` works in the console of any page
999
+ that runs this package, without looking up the host's namespace first:
1000
+
1001
+ ```ts
1002
+ window.zdtp.show = () => void | Promise<void>; // open the panel
1003
+ window.zdtp.hide = () => void | Promise<void>; // close the panel
1004
+ window.zdtp.toggle = () => void | Promise<void>; // toggle the panel
1005
+ ```
1006
+
1007
+ - **Scope: `show` / `hide` / `toggle` only.** `enableAutoload()` /
1008
+ `disableAutoload()` stay on `window[consoleNamespace].*` (§6.4) and the
1009
+ package-root exports (README.md §10) — there is no
1010
+ `window.zdtp.enableAutoload`.
1011
+ - **`window[consoleNamespace].*` is unaffected.** It stays fully intact as
1012
+ the multi-tenant, per-namespace API; `window.zdtp` is sugar for the common
1013
+ single-panel case layered on top, not a replacement.
1014
+ - **Targets the default instance — with one install-site nuance.** On a
1015
+ non-Astro host, `window.zdtp.*` wraps the package-root
1016
+ `showDesignTokenPanel()` / `hideDesignTokenPanel()` / `toggleDesignPanel()`
1017
+ exports, which re-resolve `getPanelConfig()` (the current default instance)
1018
+ on every call — so it always tracks whichever instance is CURRENTLY the
1019
+ default, even if that changes after install. On an Astro host, the adapter
1020
+ binds `window.zdtp.*` to the specific `PanelInstanceHandle` captured at
1021
+ install time (whichever instance's adapter script installs the alias
1022
+ first — see the next point); it does NOT re-resolve the default on each
1023
+ call, so on a page with more than one `<DesignTokenPanelHost>` instance the
1024
+ alias keeps targeting that first instance even after a later-configured
1025
+ instance becomes the registry's default. Either way, on a multi-instance
1026
+ page use `configurePanel(cfg)`'s returned handle (`handle.open()` /
1027
+ `.close()` / `.toggle()`) to target a SPECIFIC instance unambiguously.
1028
+ - **Install sites and timing.** Two independent call sites install this
1029
+ alias, both routing through one shared installer so neither clobbers the
1030
+ other:
1031
+ - The package-root module (`index.tsx`) installs a synchronous alias at
1032
+ its own module-init bootstrap — covers non-Astro hosts that import the
1033
+ package directly.
1034
+ - The Astro host adapter installs an async-wrapped alias eagerly from its
1035
+ own `<script>` bootstrap, alongside `installConsoleApi` (§6.4) — so
1036
+ `zdtp.show()` is callable in the console BEFORE the panel bundle itself
1037
+ has loaded. Each wrapper lazily imports the panel module (the same gate
1038
+ the console API uses) on first call, then drives the captured instance
1039
+ handle.
1040
+ - In the Astro flow the adapter's bootstrap script always runs before the
1041
+ panel module's lazy dynamic import can resolve, so the adapter's alias
1042
+ always installs first ("first install wins" — see the next point). The
1043
+ package-root install site is therefore reached only by non-Astro hosts.
1044
+ - **Never clobbers a host-defined `window.zdtp`.** If `window.zdtp` already
1045
+ exists and was not installed by this package, the install is skipped with
1046
+ a `console.warn` — the host's own global is left untouched. (This also
1047
+ covers the edge case of a host that picks `consoleNamespace: 'zdtp'`: the
1048
+ namespace object installed at §6.4 is not this package's alias marker, so
1049
+ the second install site treats it as host-owned and skips.)
1050
+ - **Auto-remember carries over for free.** `zdtp.show()` routes through the
1051
+ same `showDesignTokenPanel()` / `handle.open()` core as every other open
1052
+ path, so it arms `${storagePrefix}:autoload = 'auto'` exactly like
1053
+ `showDesignPanel()` (§6.2) — no separate wiring needed, and it is subject
1054
+ to the same `autoRememberOnOpen: false` gate.
1055
+
1056
+ ---
1057
+
1058
+ ## 7. CSS contract
1059
+
1060
+ ### 7.1 Panel-private namespace
1061
+
1062
+ The panel ships its own bundled CSS. All panel-chrome variables use the
1063
+ `--tokentweak-*` prefix, scoped to the panel shell + modal class prefix:
1064
+
1065
+ ```css
1066
+ :where(.tokenpanel-shell, [data-design-token-panel-modal]) {
1067
+ --tokentweak-pad-md: …;
1068
+ --tokentweak-gap-sm: …;
1069
+ --tokentweak-color-fg: #b8b8b8;
1070
+ /* …every panel-chrome value lives here */
1071
+ }
1072
+ ```
1073
+
1074
+ - **No Tailwind dependency.** The package builds and runs without Tailwind in
1075
+ the consumer.
1076
+ - **Consumer import required.** The `./styles` sub-export must be imported
1077
+ exactly once from the consumer's static module graph:
1078
+
1079
+ ```ts
1080
+ import '@takazudo/zdtp/styles';
1081
+ ```
1082
+
1083
+ ### 7.2 Consumer's editable tokens
1084
+
1085
+ The tokens the panel writes to (the `cssVar` field on each `TierItem`) are
1086
+ entirely consumer-controlled. The package just writes them through `setProperty`
1087
+ on `:root`.
1088
+
1089
+ - **Read:** the panel never reads consumer CSS variables (it carries its own
1090
+ defaults via `TierItem.default`).
1091
+ - **Write:** the panel only writes the consumer-supplied `cssVar` strings.
1092
+
1093
+ ### 7.3 Modal class prefix + `data-design-token-panel-modal`
1094
+
1095
+ `PanelConfig.modalClassPrefix` controls the BEM root for every modal the
1096
+ panel owns. **The bundled CSS keys on the data attribute, NOT on the class
1097
+ prefix.** Every modal `<dialog>` element emits
1098
+ `data-design-token-panel-modal=""`. `panel.css` anchors all modal chrome
1099
+ rules on `[data-design-token-panel-modal]`.
1100
+
1101
+ ### 7.4 Self-contained panel chrome palette (no host theme reads)
1102
+
1103
+ The panel-chrome color tokens are declared in `panel-tokens.css` as
1104
+ concrete dark-palette values so the panel paints as a neutral dark surface
1105
+ regardless of what the host's `--color-*` tokens resolve to:
1106
+
1107
+ ```css
1108
+ :where(.tokenpanel-shell, [data-design-token-panel-modal]) {
1109
+ --tokentweak-color-fg: #b8b8b8;
1110
+ --tokentweak-color-bg: #181818;
1111
+ --tokentweak-color-muted: #888888;
1112
+ --tokentweak-color-surface: #1c1c1c;
1113
+ --tokentweak-color-accent: #d69a66;
1114
+ --tokentweak-color-accent-hover: #a7c0e3;
1115
+ --tokentweak-color-code-bg: #383838;
1116
+ --tokentweak-color-code-fg: #e0e0e0;
1117
+ --tokentweak-color-success: #93bb77;
1118
+ --tokentweak-color-danger: #da6871;
1119
+ --tokentweak-color-warning: #dfbb77;
1120
+ --tokentweak-font-mono: Menlo, Monaco, Consolas, 'Liberation Mono',
1121
+ 'Courier New', monospace;
1122
+ }
1123
+ ```
1124
+
1125
+ The panel deliberately does NOT read host `--color-*` / `--font-mono`
1126
+ tokens. The panel is a developer tool that ships inside a host page; a
1127
+ host theme change — including theme tweaks driven through this very panel
1128
+ in a demo — MUST NOT bleed into the panel chrome.
1129
+
1130
+ **Override surface for hosts:** a host that wants to retheme the panel
1131
+ chrome assigns directly to the `--tokentweak-color-*` /
1132
+ `--tokentweak-font-mono` names on `.tokenpanel-shell`,
1133
+ `[data-design-token-panel-modal]`, or any ancestor (`:where()` keeps
1134
+ specificity at 0). This single name layer is the entire host-override
1135
+ contract for panel chrome — `--color-*` reads are not part of it.
1136
+
1137
+ **Invariant:** the panel package MUST NOT read `--color-*` or
1138
+ `--font-mono` anywhere. Both `panel.css` and `panel-tokens.css` are pinned
1139
+ by acceptance grep:
1140
+
1141
+ ```bash
1142
+ grep -n 'var(--color-' src/styles/panel.css # → 0
1143
+ grep -n 'var(--font-mono' src/styles/panel.css # → 0
1144
+ grep -n 'var(--color-' src/styles/panel-tokens.css # → 0
1145
+ grep -n 'var(--font-mono' src/styles/panel-tokens.css # → 0
1146
+ ```
1147
+
1148
+ ### 7.5 Host-adapter side-effect import (paired-unit obligation)
1149
+
1150
+ Alongside the `./styles` import, the consumer MUST own a side-effect import
1151
+ for the host-adapter, paired with `<DesignTokenPanelHost>`:
1152
+
1153
+ ```astro
1154
+ <DesignTokenPanelHost config={myPanelConfig} />
1155
+
1156
+ <script>
1157
+ void import('@takazudo/zdtp/astro/host-adapter');
1158
+ </script>
1159
+ ```
1160
+
1161
+ ---
1162
+
1163
+ ## 8. Storage-key continuity & migration paths
1164
+
1165
+ ### 8.1 No default `PanelConfig`
1166
+
1167
+ The package ships **zero** baked-in identifiers. The host MUST configure the
1168
+ panel explicitly. A package import without an explicit configure-call surfaces
1169
+ a clear runtime error.
1170
+
1171
+ ### 8.2 Storage-key derivation is literal
1172
+
1173
+ For any host's chosen `storagePrefix`, the derivation produces deterministic,
1174
+ literal-equal storage keys (see §2). Unit tests pin the derived keys to
1175
+ literal strings.
1176
+
1177
+ ### 8.3 v1 / v2 → v3 in-place migration
1178
+
1179
+ On first load, `loadPersistedState` migrates forward through the chain:
1180
+
1181
+ | Source key | Target key | Action after migration |
1182
+ | ----------------------- | ------------------------ | ---------------------- |
1183
+ | `${storagePrefix}-state` (v1) | `${storagePrefix}-state-v3` | v1 key deleted |
1184
+ | `${storagePrefix}-state-v2` (v2) | `${storagePrefix}-state-v3` | v2 key deleted |
1185
+
1186
+ A user who last opened the panel before v3 landed gets their old color/spacing
1187
+ tweaks lifted into the new envelope on first load.
1188
+
1189
+ The v3 envelope adds a `tabs` map alongside the existing per-category slices:
1190
+
1191
+ ```ts
1192
+ // Simplified v3 localStorage envelope shape
1193
+ {
1194
+ // legacy category slices — preserved for round-trip compatibility
1195
+ color: { ... },
1196
+ spacing: { ... },
1197
+ typography: { ... },
1198
+ size: { ... },
1199
+ // v3 extension — generic tab overrides keyed by tab id
1200
+ tabs: {
1201
+ "my-custom-tab": { "item-id-1": "some-value", ... },
1202
+ ...
1203
+ }
1204
+ }
1205
+ ```
1206
+
1207
+ ### 8.4 Typography-id rename map
1208
+
1209
+ The optional `PanelConfig.legacyIdRenameMap` (`Record<string, string | null>`)
1210
+ enables host-controlled id rename / drop during `loadPersistedState` migration.
1211
+ `null` drops the id entirely. The default is an empty map (no renaming).
1212
+
1213
+ The historical zdtp-internal map is exported as `ZDTP_LEGACY_TYPOGRAPHY_RENAME_MAP`.
1214
+
1215
+ ---
1216
+
1217
+ ## 9. JSON export / import schema (serde v2)
1218
+
1219
+ ### 9.1 Schema versioning
1220
+
1221
+ | `$schema` value | Status | Structure |
1222
+ | ----------------------- | ------- | ------------------------------------------------------ |
1223
+ | `zudo-design-tokens/v1` | Legacy | Flat top-level `color`/`spacing`/`typography`/`size` keys |
1224
+ | `zudo-design-tokens/v2` | Current | `tabs` wrapper keyed by tab id; cssVar-keyed leaves |
1225
+
1226
+ `serialize()` always emits v2. `deserialize()` accepts both v1 and v2 and
1227
+ normalises to an internal `TweakState`.
1228
+
1229
+ ### 9.2 v2 format
1230
+
1231
+ ```jsonc
1232
+ {
1233
+ "$schema": "zudo-design-tokens/v2",
1234
+ "exportedAt": "2026-01-01T00:00:00.000Z",
1235
+ "tabs": {
1236
+ "spacing": {
1237
+ "raw": { "--myapp-spacing-md": "1.25rem" }
1238
+ },
1239
+ "font": {
1240
+ "raw": { "--myapp-scale-base": "1rem" },
1241
+ "semantic": { "--myapp-text-base": "var(--myapp-scale-base)" }
1242
+ },
1243
+ "color": {
1244
+ "palette": { "--myapp-palette-1": "#2d6cdf" },
1245
+ "semantic": { "--myapp-color-primary": 1 }
1246
+ }
1247
+ }
1248
+ }
1249
+ ```
1250
+
1251
+ Key decisions:
1252
+
1253
+ - **cssVar-keyed leaves** — portable across host id renames.
1254
+ - **Tier-2 ref values** stored as the literal `var(--tier1-cssvar)` CSS string
1255
+ (no discriminated union — keeps the format flat and hand-editable).
1256
+ - **Color `semantic` values** are palette-index integers (preserved from v1 so
1257
+ the swatch UI can render the resolved color).
1258
+
1259
+ ### 9.3 Diff-only by default
1260
+
1261
+ `serialize()` only emits tokens the user has changed relative to manifest
1262
+ defaults. Pass `includeDefaults: true` to dump the full state. A tab key is
1263
+ omitted entirely when nothing in it differs.
1264
+
1265
+ ---
1266
+
1267
+ ## 10. Out-of-scope (deferred)
1268
+
1269
+ Items this contract deliberately does NOT pin down:
1270
+
1271
+ - **Persist envelope internal shape** — frozen at the current shape so
1272
+ existing user state round-trips without migration.
1273
+ - **Schema id versioning.** `schemaId` is a configure-time string; bumping
1274
+ it is the host's responsibility.
1275
+ - **Shadow-DOM scoping.** The panel writes to `:root` by default; hosts
1276
+ that need scoped writes use `PanelConfig.applySink` (§3.5). The sink
1277
+ target's lifecycle is owned by the host — not pinned here.
1278
+ - **Theme-API surface.** The panel does not expose a programmatic API for
1279
+ reading the current overrides outside the persist envelope.
1280
+
1281
+ ---
1282
+
1283
+ ## Appendix A — section index
1284
+
1285
+ Cross-reference table — what each section pins down.
1286
+
1287
+ | Topic | Section |
1288
+ | ------------------------------------------------------------------------------------------- | ------------- |
1289
+ | `configurePanel({...})` signature, multi-instance, `PanelInstanceHandle`, per-instance toggle events | §1 |
1290
+ | Storage-key derivation | §2, §8 |
1291
+ | Default first-open geometry (coherent size+position, viewport containment, cascade, persisted-position precedence) | §2.1 |
1292
+ | `TabConfig` / `TierConfig` / `TierItem` / `TierValueKind` interfaces and apply behaviour | §3 |
1293
+ | `applySink` — optional CSS-var write target (upsert / clear / Reset full set) | §3.5 |
1294
+ | `ColorClusterExtras` shape and multi-cluster support | §4.1, §4.3 |
1295
+ | JSON-serializable constraint on color tab config | §4.2 |
1296
+ | `colorPresets` and `setPanelColorPresets()` lazy attachment | §4.4 |
1297
+ | Color apply behaviour | §4.5 |
1298
+ | Apply pipeline request / response envelopes | §5.1 |
1299
+ | Reference-implementation algorithm + native-implementation guidance | §5.2, §5.3 |
1300
+ | Routing config single-source | §5.4 |
1301
+ | Astro `<DesignTokenPanelHost>` prop, lazy-load gate (4-signal), owner-autoload, console API | §6 |
1302
+ | Fixed-name global open API (`window.zdtp.show/hide/toggle`) | §6.5 |
1303
+ | `--tokentweak-*` namespace and Tailwind-free CSS contract | §7.1 |
1304
+ | Modal class prefix and `data-design-token-panel-modal` selector contract | §7.3 |
1305
+ | Self-contained panel chrome palette (no host theme reads) | §7.4 |
1306
+ | Host-adapter side-effect import (paired-unit obligation) | §7.5 |
1307
+ | v1/v2 → v3 storage migration and typography-id rename map | §8.3, §8.4 |
1308
+ | JSON export/import schema v2 (serde v2) | §9 |
1309
+ | Out-of-scope / deferred concerns | §10 |