Zudo Token Panel
GitHub repository

Type to search...

to open search from anywhere

configurePanel

Multi-instance init, the PanelConfig shape with tabs-based manifest, DOM Tweaker, PanelInstanceHandle, applySink, and the lifecycle helpers (show / hide / toggle / reapply).

The package's entire portable contract enters through a single setup function. configurePanel({...}) supports multiple independent panel instances — call it with distinct storagePrefix values to register separate instances. Each call returns a PanelInstanceHandle.

This page pins the public shape of PanelConfig, the configurePanel call, PanelInstanceHandle, the domTweaker and applySink contracts, and the runtime lifecycle helpers.

configurePanel(config)

import { configurePanel } from '@takazudo/zdtp';

const handle = configurePanel({
  storagePrefix: 'myapp-design-token-panel',
  consoleNamespace: 'myapp',
  modalClassPrefix: 'myapp-design-token-panel-modal',
  schemaId: 'zudo-design-tokens/v2',
  exportFilenameBase: 'myapp-design-tokens',
  tabs: [
    {
      id: 'spacing',
      label: 'Spacing',
      tiers: [
        {
          id: 'base',
          label: 'Base',
          items: [
            { id: 'sp-md', cssVar: '--myapp-spacing-md', label: 'MD', default: '1rem',
              type: { kind: 'length', step: 0.25, unit: 'rem' } },
          ],
        },
      ],
    },
    // additional tabs...
  ],
  applyEndpoint: 'http://localhost:4321/_dev/apply-tokens',
  applyRouting: {
    'myapp-spacing': 'src/styles/spacing.css',
  },
  domTweaker: {
    themeCss: '@theme { --color-brand: #7c3aed; }',
  },
});

// handle.instanceId === 'myapp-design-token-panel'
// handle.open() / close() / toggle() / destroy()

Required behaviours

  • Multi-instance. Calling configurePanel with a distinct storagePrefix registers a new independent instance — independent storage keys, DOM root, toggle event, and apply target. No throw.

  • Idempotent for same prefix+config. Calling configurePanel again with the same storagePrefix and structurally-equal values is a silent no-op that returns the same handle. This covers Astro view-transition reruns that re-parse the inline JSON config.

  • Same-prefix-different-config THROWS. If an instance for the prefix already exists but the config differs, configurePanel throws immediately. Call handle.destroy() first, then configurePanel again to re-configure.

  • Synchronous. No I/O, no awaits. Safe to call inline at module-init time.

  • Pure data only (except applySink). Every field on PanelConfig other than applySink MUST be JSON-serializable. The Astro frontmatter → island prop handoff stringifies the config; function fields silently disappear under that round-trip. applySink carries function references and must not be included in the Astro inline JSON config.

No defaults — explicit configure required

Importing the package without calling configurePanel(...) first leaves the panel in a minimal stub state with an empty tabs array. The panel will render "no tokens configured". Hosts MUST call configurePanel(...) to see useful behaviour.

PanelInstanceHandle

export interface PanelInstanceHandle {
  /** Stable instance id — equal to the instance's storagePrefix. */
  readonly instanceId: string;
  /** Show this instance's panel. */
  open(): void;
  /** Hide this instance's panel. */
  close(): void;
  /** Toggle this instance's panel open/closed. */
  toggle(): void;
  /**
   * Deregister this instance. Unmounts the Preact tree, removes the DOM
   * root, and unbinds the instance's toggle-event listener. After
   * destroy() the prefix can be re-configured via configurePanel.
   */
  destroy(): void;
}

Two configurePanel calls with the same storagePrefix and equal config return the same handle (referential identity is preserved across idempotent re-calls). Distinct prefixes return distinct handles.

PanelConfig interface

export interface DomTweakerConfig {
  /** Optional host Tailwind v4 theme CSS used by the lazy runtime and suggestions. */
  themeCss?: string;
}

export interface PanelConfig {
  /** Base for every derived storage key. Also the instance id. */
  storagePrefix: string;
  /** Console API namespace — installed as `window[consoleNamespace].showDesignPanel`, etc. */
  consoleNamespace: string;
  /** BEM-style prefix used by every modal in the panel (export / import / apply). */
  modalClassPrefix: string;
  /** `$schema` value emitted into export JSON and required on import. */
  schemaId: string;
  /** Default filename base — exports save as `${exportFilenameBase}.json`. */
  exportFilenameBase: string;
  /**
   * Optional window-event name that toggles THIS instance's panel.
   * The default (single-panel) instance keeps `toggle-design-token-panel`.
   * Any other instance defaults to `toggle-${storagePrefix}` when this field
   * is omitted, giving each instance its own independent toggle channel.
   */
  toggleEvent?: string;
  /**
   * Host-supplied tab configuration. Required.
   * The panel renders its tab strip from this array.
   *  - Reserved ids ('color', 'color-secondary') dispatch to built-in color tab
   *    components. Every other id dispatches to GenericTab, which renders the
   *    tab's tiers using kind-appropriate editors.
   */
  tabs: readonly TabConfig[];
  /**
   * Optional host-supplied color-scheme presets. Surfaces additional named
   * ColorScheme entries in the Color tab "Scheme..." dropdown. Defaults to {}.
   */
  colorPresets?: Record<string, ColorScheme>;
  /**
   * Optional dev-API endpoint URL. When set, the Apply button POSTs its diff payload to it.
   * When undefined, the Apply button stays disabled with a tooltip.
   */
  applyEndpoint?: string;
  /**
   * Optional CSS-var prefix → repo-relative source-file routing map.
   * Apply is gated on applyEndpoint AND a non-empty routing map.
   */
  applyRouting?: Record<string, string>;
  /**
   * Optional DOM Tweaker feature config. Presence enables the header toggle
   * and persisted revival path. The object must contain pure JSON data.
   */
  domTweaker?: DomTweakerConfig;
  /**
   * Optional apply sink. Routes this instance's CSS-var writes off :root.
   * Not JSON-serializable — supply via a custom adapter, not inline Astro config.
   * See the applySink section below.
   */
  applySink?: ApplySink;
  /**
   * Optional id rename map applied during loadPersistedState migration.
   * Keys are old ids found in persisted state; values are:
   *   - string — new canonical id; the value moves to that key.
   *   - null   — drop the id entirely.
   * Defaults to an empty map (no renaming, no drops).
   */
  legacyIdRenameMap?: Record<string, string | null>;
  /**
   * Whether opening the panel writes the owner-autoload flag
   * (`${storagePrefix}:autoload`) with `'auto'` provenance.
   * Defaults to true. Set false on a public site that shows a panel-open
   * button to every visitor. enableAutoload()'s explicit '1' write is
   * unaffected.
   */
  autoRememberOnOpen?: boolean;
}

Field reference

FieldTypeRequiredNotes
storagePrefixstringyesDrives every persisted localStorage key. Also the instance id.
consoleNamespacestringyesGlobals installed under window[consoleNamespace].
modalClassPrefixstringyesBEM root for every modal the panel owns.
schemaIdstringyesDisplay-only label surfaced in the Import modal's copy. serialize()/deserialize() always use the canonical SCHEMA_V1/SCHEMA_V2/SCHEMA_V3 constants and never read this field — set it to one of those constants for a truthful label. See Apply pipeline.
exportFilenameBasestringyesSaves as ${exportFilenameBase}.json.
toggleEventstringnoToggle-event name for this instance. Default: toggle-${storagePrefix} (non-default instances); the default instance uses toggle-design-token-panel.
tabsTabConfig[]yesTab strip definition. The Color tab reads its data from the tier model via colorExtras + TierItem[].
colorPresetsRecord<string, ColorScheme>noHost-supplied scheme presets — see Color cluster.
applyEndpointstringnoApply POST target — see Apply pipeline.
applyRoutingRecord<string, string>noApply routing map — see Apply pipeline.
domTweakerDomTweakerConfignoOpts into the dev-time Tailwind class editor. Omit it to hide the feature — see DOM Tweaker.
applySinkApplySinknoOptional CSS-var write target. Not JSON-serializable. See below.
legacyIdRenameMapRecord<string, string | null>noMigration map for persisted id renames and drops.
autoRememberOnOpenbooleannoWhether opening the panel arms owner-mode autoload (:autoload = 'auto'). Default true. Set false for a public site with a visitor-visible panel button — see Load the panel only for you.

JSON-serializable invariant

Every field on PanelConfig (including nested tabs content) other than applySink must round-trip through JSON.stringify without loss. The Astro host-adapter parses the inline <script type="application/json"> payload on every page; function fields, class instances, and Symbol keys silently disappear and surface later as cryptic runtime errors.

domTweaker — optional dev-time class editor

PanelConfig.domTweaker is enabled by presence. An empty object enables the header toggle with the bundled Tailwind suggestions; omit the field entirely to hide the toggle and ignore any persisted DOM Tweaker enabled state.

configurePanel({
  // ...required fields...
  domTweaker: {
    themeCss: `
      @theme {
        --color-brand: oklch(0.62 0.2 285);
        --spacing-card: 1.5rem;
      }
    `,
  },
});

The config object is deliberately narrow and JSON-serializable:

  • themeCss, when supplied, must be a string containing host Tailwind v4 token/theme declarations. It feeds both the lazy Tailwind runtime and host-theme suggestions.

  • Every @import occurrence is rejected. DOM Tweaker controls its own no-preflight Tailwind input and does not accept imported stylesheets through this bridge.

  • Unknown fields, functions, arrays, null, and non-string themeCss values are rejected by configurePanel validation.

See DOM Tweaker for the editing workflow, development-only positioning, themeCss bridge, and runtime limitations.

applySink — optional CSS-var write target

When PanelConfig.applySink is supplied, all CSS-var writes and clears for that instance route through the sink rather than document.documentElement. This enables embedding the panel in a shadow root, iframe document, or test spy without touching :root.

export interface ApplySink {
  /** Upsert the given var name→value pairs on the sink target. */
  apply(pairs: ReadonlyArray<readonly [string, string]>): void;
  /** Remove the given var names from the sink target. */
  clear(names: readonly string[]): void;
}

Contract:

  • apply(pairs)upsert: set each [name, value] CSS var on the sink target.

  • clear(names)remove: remove each named CSS var from the sink target.

  • Reset sends the full token set. When the user clicks Reset, sink.clear receives every var the instance can own (all palette, base-role, semantic, and non-color tab vars) — not just dirty vars — so the sink target is completely cleaned.

  • Default (no sink): writes go to document.documentElement (unchanged behaviour).

  • Sink errors are non-fatal. The pipeline swallows them with console.warn and continues.

  • The host owns the sink target's lifecycle. Keep the target alive as long as the instance is alive.

  • Not JSON-serializable. Supply it by constructing the config object with the sink already attached, or via a custom adapter that calls configurePanel directly.

// Example: routing a panel instance to a shadow root
const shadow = shadowHost.attachShadow({ mode: 'open' });

const handle = configurePanel({
  storagePrefix: 'myapp-shadow-panel',
  // ...other required fields...
  applySink: {
    apply(pairs) {
      for (const [name, value] of pairs) {
        (shadow.host as HTMLElement).style.setProperty(name, value);
      }
    },
    clear(names) {
      for (const name of names) {
        (shadow.host as HTMLElement).style.removeProperty(name);
      }
    },
  },
});

Per-instance toggle events

Each panel instance has its own toggle-event channel so multiple panels on one page do not cross-talk.

InstancetoggleEvent fieldEffective toggle-event name
Default (single-panel path)(ignored)toggle-design-token-panel
Any non-default, field omittedtoggle-${storagePrefix}
Any non-default, field supplieda custom stringthe supplied string

The default instance is whichever instance's storagePrefix equals the historical package default ('zudo-design-token-panel'). All other instances get per-prefix channels.

// Dispatch the toggle event for a non-default instance:
window.dispatchEvent(new CustomEvent('toggle-myapp-preview-panel'));
// or, if you supplied toggleEvent: 'my-custom-event':
window.dispatchEvent(new CustomEvent('my-custom-event'));

Storage-key derivation

storagePrefix is the only knob that controls every persisted key. The panel derives the keys at runtime from this single base.

Logical keyDerivationPurpose
state-v4${storagePrefix}-state-v4Current unified envelope. Same slices as v3, EXCEPT color (and optional secondary cluster slice) is an identity-keyed map — one slot per active (scheme, mode) identity — instead of a single flat object. See "Per-scheme color persistence" below.
state-v3${storagePrefix}-state-v3Legacy pre-v4 envelope (flat, single-slot color). Migrated into state-v4 on first load; left in place (not deleted) so a downgrade can still read it.
state-v2${storagePrefix}-state-v2v2 envelope — migrated to v3 (and from there into v4) on first load, then deleted.
state-v1${storagePrefix}-statePre-v2 flat-state format (Color-only). Migrated to v2 (and from there into v4) on first load, then deleted.
open${storagePrefix}-openMirror of the panel's open boolean state.
position${storagePrefix}-positionDrag position { top, right } so the panel reappears where the user left it.
visible${storagePrefix}:visibleAdapter-level visibility-intent flag.
domtweaker-enabled${storagePrefix}-domtweaker-enabledDOM Tweaker enabled bit. Meaningful only when PanelConfig.domTweaker is present.

Colon, not dash, for `visible`

The visible key uses a : separator; every other derived key uses -. This is a historical artifact preserved for storage-key continuity.

Per-scheme color persistence (v4 envelope)

Color/secondary tweaks are persisted per (scheme, mode) identity inside the single state-v4 key — there is no per-scheme key fan-out. The identity is the active scheme name the panel already resolves for seeding; a cluster without colorMode resolves one constant identity (one slot), while a light/dark cluster resolves a distinct identity per side, so a light tweak and a dark tweak occupy independent slots in the same envelope and each survives a round-trip through the other scheme. The spacing / typography / size / tabs slices stay global and unkeyed, exactly as in v1–v3 — a scheme/mode toggle never touches them. Editing color under one identity only ever overwrites that identity's slot; every other identity's slot is preserved untouched on save. A host-owned inline color-scheme (e.g. a site-level theme toggle) is never cleared by the panel — only a color-scheme value the panel itself applied is ever removed.

legacyIdRenameMap migration

loadPersistedState applies legacyIdRenameMap during the migration pass before the panel renders. Old item ids are moved to their new canonical ids; ids mapped to null are dropped entirely so stale localStorage entries don't accumulate.

Hosts whose item ids are stable (no historical renames) can omit this field — the default is an empty map (no renaming, no drops).

ZDTP_LEGACY_TYPOGRAPHY_RENAME_MAP

import { ZDTP_LEGACY_TYPOGRAPHY_RENAME_MAP } from '@takazudo/zdtp';

configurePanel({
  // ...
  legacyIdRenameMap: ZDTP_LEGACY_TYPOGRAPHY_RENAME_MAP,
});

ZDTP_LEGACY_TYPOGRAPHY_RENAME_MAP is the historical internal rename map that was applied automatically in versions prior to the legacyIdRenameMap field. It is now exported for opt-in callers whose persisted state predates that version. Hosts that never relied on the historical internal renames should leave legacyIdRenameMap undefined (or pass an empty object).

Persisted state / external SerDe

The panel exports two primitives for hosts that need to read or write panel state outside the panel itself — for example, synchronising token overrides across tabs or building a custom import/export flow.

TweakState

import type { TweakState } from '@takazudo/zdtp';

TweakState is the TypeScript type of the serialisable override state that the panel persists in localStorage and embeds in JSON exports. Import it when building typed SerDe utilities that consume or produce panel state.

emptyOverrides

import { emptyOverrides } from '@takazudo/zdtp';

emptyOverrides is a ready-made TweakState value with all override maps empty. Use it as a starting point when constructing a fresh state object or when resetting overrides programmatically.

Complete example

import { configurePanel } from '@takazudo/zdtp';
import type { TabConfig } from '@takazudo/zdtp';

const colorTab: TabConfig = {
  id: 'color',
  label: 'Color',
  tiers: [
    {
      id: 'palette',
      label: 'Palette',
      items: [
        { id: 'p0', cssVar: '--myapp-p0', label: 'P0', default: '#0f172a', type: { kind: 'color' } },
        { id: 'p1', cssVar: '--myapp-p1', label: 'P1', default: '#38bdf8', type: { kind: 'color' } },
      ],
    },
    {
      id: 'semantic',
      label: 'Semantic',
      referencesTier: 'palette',
      items: [
        { id: 'bg', cssVar: '--myapp-color-bg', label: 'Background', default: 'p0', type: { kind: 'color' } },
        { id: 'accent', cssVar: '--myapp-color-accent', label: 'Accent', default: 'p1', type: { kind: 'color' } },
      ],
    },
  ],
  colorExtras: {
    id: 'myapp',
    baseRoles: { background: '--myapp-p0', foreground: '--myapp-p1' },
    baseDefaults: { background: 0, foreground: 1 },
    defaultShikiTheme: 'github-dark',
    colorSchemes: {
      'Default Dark': {
        background: 0, foreground: 1, cursor: 1, selectionBg: 0, selectionFg: 1,
        palette: ['#0f172a', '#38bdf8'],
        shikiTheme: 'github-dark',
      },
    },
    panelSettings: { colorScheme: 'Default Dark', colorMode: false },
  },
};

const spacingTab: TabConfig = {
  id: 'spacing',
  label: 'Spacing',
  tiers: [
    {
      id: 'base',
      label: 'Base spacing',
      items: [
        { id: 'sp-sm', cssVar: '--myapp-spacing-sm', label: 'SM', default: '0.5rem', type: { kind: 'length', step: 0.125, unit: 'rem' } },
        { id: 'sp-md', cssVar: '--myapp-spacing-md', label: 'MD', default: '1rem',   type: { kind: 'length', step: 0.25,  unit: 'rem' } },
      ],
    },
  ],
};

const handle = configurePanel({
  storagePrefix: 'myapp-design-token-panel',
  consoleNamespace: 'myapp',
  modalClassPrefix: 'myapp-design-token-panel-modal',
  schemaId: 'zudo-design-tokens/v2',
  exportFilenameBase: 'myapp-design-tokens',
  tabs: [colorTab, spacingTab],
  applyEndpoint: 'http://localhost:4321/_dev/apply-tokens',
  applyRouting: {
    'myapp-p': 'src/styles/color.css',
    'myapp-color': 'src/styles/color.css',
    'myapp-spacing': 'src/styles/spacing.css',
  },
  domTweaker: {
    themeCss: '@theme { --color-brand: #38bdf8; }',
  },
});
// handle.instanceId === 'myapp-design-token-panel'

Lifecycle helpers

The package exposes four runtime helpers from its root entry. They are normally invoked via the console namespace (the host adapter installs window[consoleNamespace].showDesignPanel etc.), but a Vite-only host can import them directly.

import {
  showDesignTokenPanel,
  hideDesignTokenPanel,
  toggleDesignPanel,
  reapplyPersistedOverrides,
} from '@takazudo/zdtp';

showDesignTokenPanel(): void

Opens the panel. Idempotent — calling it while the panel is already open is a no-op. Mounts the Preact shell into a body-appended <div> whose id is derived from storagePrefix.

hideDesignTokenPanel(): void

Closes the panel. The Preact shell stays mounted (CSS hides it); only the open flag flips.

toggleDesignPanel(): void

Flips the panel between open and closed. The exported function name is toggleDesignPanel (not toggleDesignTokenPanel); the console-API helper name matches.

reapplyPersistedOverrides(): void

Applies persisted token overrides directly to :root BEFORE any Preact render. Called at adapter module init (and again on every astro:page-load) so the bundle's arrival eliminates the FOUT on hard navigation. This is a no-op when nothing is persisted; it swallows errors so corrupt state never blocks the UI thread.

Console API

The host adapter installs lazy-import wrappers under window[consoleNamespace]:

window[consoleNamespace].showDesignPanel = () => Promise<void>;
window[consoleNamespace].hideDesignPanel = () => Promise<void>;
window[consoleNamespace].toggleDesignPanel = () => Promise<void>;

Each helper lazy-imports the adapter module and forwards to its corresponding synchronous public function.

The fixed-name window.zdtp global

consoleNamespace is a required field on PanelConfig, so the console API above always lives under a host-chosen name. window.zdtp is an additive, fixed-name alias for the three open/close verbs — no namespace lookup required:

window.zdtp.show   = () => void | Promise<void>;
window.zdtp.hide   = () => void | Promise<void>;
window.zdtp.toggle = () => void | Promise<void>;
  • Scope: show / hide / toggle only — there is no window.zdtp.enableAutoload; owner-autoload stays on window[consoleNamespace].* and the package-root enableAutoload() / disableAutoload() exports.

  • Install sites: the package-root module installs a synchronous alias at module init (non-Astro hosts); the Astro host adapter installs an async-wrapped alias eagerly from its own bootstrap, alongside the console API above — so zdtp.show() works even before the panel bundle has loaded.

  • Never clobbers a host-defined window.zdtp. If the existing value exposes callable show, hide, and toggle methods, the install is skipped silently so a host can pre-claim the alias before lazily loading the panel bundle. Other existing values are also left untouched and produce a console.warn (including when a host happens to pick consoleNamespace: 'zdtp' itself).

  • Targets the default instance, exactly like showDesignTokenPanel() / hideDesignTokenPanel() / toggleDesignPanel() above. For a specific instance on a multi-instance page, call configurePanel(cfg)'s returned PanelInstanceHandle directly.

  • Opening via zdtp.show() arms the owner-autoload flag exactly like showDesignTokenPanel() — see Owner-autoload recipe.

No enableAutoload here

window.zdtp deliberately mirrors only the three open/close verbs, not the full owner-autoload surface. Reach for window[consoleNamespace].enableAutoload() / disableAutoload() (or the package-root exports) for that.

setLifecycleAdapter(adapter)

import { setLifecycleAdapter } from '@takazudo/zdtp';

setLifecycleAdapter({
  onBeforeSwap: (cb) => { /* register a before-swap hook */ },
  onPageLoad:   (cb) => { /* register a page-load hook  */ },
});

Registers lifecycle hooks for non-Astro hosts (Vite, plain HTML, Next.js, etc.). The Astro host-adapter wires these automatically via astro:before-swap and astro:page-load events; non-Astro hosts must call setLifecycleAdapter before the panel dynamically loads to ensure reapplyPersistedOverrides fires on each navigation.

Call setLifecycleAdapter after configurePanel and before the panel is first shown. Calling it more than once replaces the previous adapter.

setPanelColorPresets(presets)

import { setPanelColorPresets } from '@takazudo/zdtp';

setPanelColorPresets({
  Dracula: { /* ColorScheme */ },
  Solarized: { /* ColorScheme */ },
});

Lazy preset attachment. Hosts that don't want to ship the preset library inline in the SSR config blob can call this AFTER configurePanel from a deferred dynamic import. Same merge rules as PanelConfig.colorPresets — see Color cluster for the full merge contract.

The trailing call wins on conflict (no throw, unlike configurePanel); a host that pre-calls setPanelColorPresets before configurePanel is serviced via a holding slot inside the panel-config module.

Revision History

CreatedUpdated