@takazudo/zdtp 0.4.10 → 0.4.12

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