@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.
- package/CHANGELOG.md +40 -0
- package/PORTABLE-CONTRACT.md +1309 -0
- package/README.md +27 -9
- package/dist/astro/host-adapter.d.ts +4 -4
- package/dist/astro/host-adapter.js +78 -47
- package/dist/astro/index.js +1 -1
- package/dist/autoload-state-CTDDBxI8.js +88 -0
- package/dist/config/panel-config.d.ts +15 -0
- package/dist/{index-t74PT2zd.js → index-BeYH-tJD.js} +2052 -2012
- package/dist/{index-CGev6ZiI.js → index-C8GR1TU-.js} +2 -2
- package/dist/index.d.ts +14 -0
- package/dist/index.js +30 -28
- package/dist/{panel-config-BNLlUc_r.js → panel-config-7Xg59uKj.js} +18 -14
- package/dist/panel.d.ts +8 -1
- package/dist/state/autoload-state.d.ts +91 -21
- package/dist/state/tweak-state.d.ts +73 -5
- package/dist/testing.js +7 -7
- package/dist/{tweak-state-Dti6XMoQ.js → tweak-state-DRKV1uYj.js} +407 -385
- package/dist/utils/document-liveness.d.ts +53 -0
- package/package.json +2 -1
- package/dist/autoload-state-CFmRjtD-.js +0 -64
|
@@ -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 |
|