Architecture
How the design-token panel separates panel UI, host-adapter, and apply-pipeline so it stays portable across hosts.
The design-token panel is split into three layers that talk to each other through narrow, JSON-serializable contracts. Most of the package’s portability story falls out of that split — the panel UI never knows what host it runs in, the host-adapter never knows what tokens you ship, and the apply-pipeline never runs in the browser.
Overview
There are three concerns and the package keeps them in three separate layers:
- Panel UI — the Preact island that renders the side panel, owns tab/control state, and writes overrides to
:rootviasetProperty. Lives entirely in the browser. - Host-adapter — the thin shim a host imports as a side effect. It reads the inline JSON config, calls
configurePanel(...), installswindow.<consoleNamespace>.*, and gates the lazy-load of the panel module. - Apply-pipeline — the optional path from the panel’s “Apply” button back to disk. It’s the in-package payload builder plus the standalone Node bin server (
design-token-panel-server) that owns the file rewrites.
┌─ Your dev server (Astro / Vite / any host) ──────────────┐
│ │
│ ┌─────────────┐ reads ┌────────────────┐ │
│ │ Layout │ ──JSON────▶ │ Host adapter │ │
│ │ (config) │ │ (lazy-gate) │ │
│ └─────────────┘ └────────┬───────┘ │
│ │ dynamic import │
│ ▼ │
│ ┌────────────────┐ │
│ │ Panel UI │ writes │
│ │ (Preact) │ ──────▶ :root
│ └────────┬───────┘ │
│ │ POST /apply │
└────────────────────────────────────────┼─────────────────┘
│ (HTTP, loopback)
▼
┌──────────────────────────┐
│ Bin server │
│ design-token-panel- │
│ server │
│ • CORS allow-list │
│ • write-root sandbox │
│ • atomic file rewrites │
└──────────────────────────┘
The boundaries between these three layers are stable — every project-specific identifier (storage prefix, console namespace, palette CSS-var template, semantic names, routing) crosses a layer boundary as plain JSON. That’s the lever that lets the same package ship into Astro, Vite, Next, or a Rust SSG without code changes.
Layer responsibilities
Panel UI
The Preact tree under <code>src/<wbr/>panel.<wbr/>tsx</code> plus the per-tab components under src/tabs/. It:
- Renders one row per editable token, driven by the host-supplied
TokenManifest. - Renders the color tab (palette + base roles + semantic table) from the host-supplied
ColorClusterConfig. - Owns the in-memory
TweakStateand persists it tolocalStorageunder keys derived fromstoragePrefix. - Writes overrides to
document.documentElement.style.setProperty(...)against the consumer’s CSS-var names.
The panel UI never reads consumer CSS variables — it only writes to them. Its own chrome is scoped under the panel-private --tokentweak-* namespace.
💡 Tip
The full surface for TokenManifest and ColorClusterConfig lives at /
Host-adapter
A small bundle at <code>src/<wbr/>astro/<wbr/>host-<wbr/>adapter.<wbr/>ts</code> that the consumer’s layout imports as a side effect:
void import('@takazudo/zudo-design-token-panel/astro/host-adapter');
It owns the boundary between the host page and the panel module:
- Reads the inline config:
<DesignTokenPanelHost>emits a<script type="application/json" id="tokenpanel-config">payload; the adapterJSON.parses it and callsconfigurePanel(...)synchronously at module init. - Installs the console API (
showDesignPanel/hideDesignPanel/toggleDesignPanel) onwindow[consoleNamespace]eagerly — even before the panel module has loaded. - Gates the lazy import: probes
localStoragefor either the${storagePrefix}-state-v2payload or the${storagePrefix}:visibleflag and dynamically imports the panel module only when one is set. - Hooks Astro view-transitions (
astro:before-swap/astro:page-load) so soft navigation re-applies persisted overrides without a flash and without double-mounting.
The adapter is the only layer that knows about Astro at all. The panel UI itself is framework-agnostic.
Apply-pipeline
Two pieces stitched through HTTP:
- In-package payload builder under <code>src/apply/</code> — turns the current
TweakStateinto a routed token-overrides payload using the host-suppliedapplyRoutingmap. - Bin server under <code>src/bin/</code> and <code>src/server/</code> — a standalone Node loopback HTTP server (
design-token-panel-server) that receives the POST, validates the request against the CORS allow-list, validates each routing entry against the--write-rootsandbox, and rewrites the target CSS files atomically.
The apply-pipeline is optional. Hosts that only need export/import omit applyEndpoint and applyRouting from PanelConfig; the Apply button stays disabled with a tooltip.
ℹ️ Info
Full CLI flag surface, security model, and routing JSON shape are documented at /
Data flow
Three flows cover the moments the layers actually interact: a tweak (UI-only), an apply (UI → bin → disk), and a view-transition reapply (host re-render).
On tweak — user moves a slider
A tweak never leaves the browser. The bin server does not need to be running for the panel to be useful — export/import to JSON works without it, and :root is updated synchronously on every change.
On apply — commit overrides to disk
The browser side ends at the POST. Everything past that — origin checks, sandbox checks, atomic write, mutex serialisation, rollback-on-failure — happens in the bin process and is identical regardless of the host framework that spawned the bin.
On view-transition reapply — host re-renders
Soft navigation through Astro’s <ClientRouter /> is the only reason the host-adapter has lifecycle hooks. On non-Astro hosts the same module loads, the same console API is installed, and the same lazy-load gate runs — the astro:* listeners simply never fire.
Why this separation?
The three-layer split is what keeps the package portable. Each layer crosses its boundary through a specific contract, and every contract is JSON-serializable on purpose.
- Panel UI ↔ host: a single
PanelConfigobject. Storage prefix, console namespace, modal class prefix, schema id, the editable token list, the entire color cluster (palette + semantics + scheme registry), and the optional preset library — all host-supplied, all plain JSON. The panel UI has no compile-time knowledge of any of them. - Host-adapter ↔ panel UI: the inline
<script type="application/json" id="tokenpanel-config">payload plus theconfigurePanel(...)call. Functions, class instances, andundefinedwould silently disappear across this boundary, so the contract forbids them. - Panel UI ↔ bin: a single POST with
application/json. The bin doesn’t trust the request — it re-validates origin and routing every time. The browser can’t reach disk without going through the sandbox.
That last point is worth its own note:
⚠️ Warning
The host-adapter contract is a paired-unit contract — <DesignTokenPanelHost> AND a sibling <script>void import('...host-adapter')</script> block must appear together in your layout. Omitting the script tag emits the JSON config but never executes the adapter that reads it; the console API throws ReferenceError. See the package README §4.1.2 and §12.1 for the full rationale and bundler detail.
The benefit of this design is portability. The benefit you actually feel is that you can drop the panel into a brand-new host and only think about your PanelConfig, not about layer wiring.