Grouped palette tab
Wire a standalone Palette tab with multiple colour groups, OKLCH editing, and WCAG contrast checking — without colorExtras.
The Palette tab is a purpose-built tab for standalone colour palettes that are not attached to a colour cluster. Unlike the reserved 'color'/'color-secondary' tabs (which require colorExtras), a palette tab holds multiple groups of OKLCH colour tokens and exposes two interaction modes:
Edit mode — an X-Y curve editor for adjusting Lightness/Chroma/Hue across each group of steps.
Check mode — a WCAG contrast overview that evaluates every palette colour as foreground against a selected palette colour as the background.
Use this recipe when you want a free-standing set of named colour steps (e.g. base, brand, accent). These can also supply semantic roles in a separate Color tab.
Data-model conventions
Before writing the TabConfig, use these conventions for the palette tab:
colorExtrasmust be absent. Omitting it preventsresolveColorClusterFromTabfrom running, which is the only way multiplekind: 'color'tiers can safely coexist in one tab.One tier = one group. Each
TierConfigrepresents a named colour group. Itslabelbecomes the group heading in the UI.CSS variable name convention:
--palette-{group}-{n}, where{group}matches the tieridand{n}is the zero-based stable identifier (starting at0).
Step 1: Declare the palette tab
// src/lib/my-tabs.ts
import type { PanelConfig } from '@takazudo/zdtp';
type TabConfig = PanelConfig['tabs'][number];
export const paletteTab: TabConfig = {
id: 'palette',
label: 'Palette',
// colorExtras intentionally omitted — required for multiple kind:'color' tiers
// to be safe in one tab. The Palette tab relies on this absence.
tiers: [
{
id: 'base',
label: 'Base',
items: [
{
id: 'base-0',
cssVar: '--palette-base-0',
label: 'Base 0',
default: 'oklch(95% 0.005 240)',
type: { kind: 'color', format: 'oklch' },
},
{
id: 'base-1',
cssVar: '--palette-base-1',
label: 'Base 1',
default: 'oklch(80% 0.008 240)',
type: { kind: 'color', format: 'oklch' },
},
{
id: 'base-2',
cssVar: '--palette-base-2',
label: 'Base 2',
default: 'oklch(55% 0.010 240)',
type: { kind: 'color', format: 'oklch' },
},
{
id: 'base-3',
cssVar: '--palette-base-3',
label: 'Base 3',
default: 'oklch(30% 0.012 240)',
type: { kind: 'color', format: 'oklch' },
},
],
},
{
id: 'brand',
label: 'Brand',
items: [
{
id: 'brand-0',
cssVar: '--palette-brand-0',
label: 'Brand 0',
default: 'oklch(90% 0.08 250)',
type: { kind: 'color', format: 'oklch' },
},
{
id: 'brand-1',
cssVar: '--palette-brand-1',
label: 'Brand 1',
default: 'oklch(70% 0.16 250)',
type: { kind: 'color', format: 'oklch' },
},
{
id: 'brand-2',
cssVar: '--palette-brand-2',
label: 'Brand 2',
default: 'oklch(50% 0.20 250)',
type: { kind: 'color', format: 'oklch' },
},
{
id: 'brand-3',
cssVar: '--palette-brand-3',
label: 'Brand 3',
default: 'oklch(30% 0.18 250)',
type: { kind: 'color', format: 'oklch' },
},
],
},
{
id: 'accent',
label: 'Accent',
items: [
{
id: 'accent-0',
cssVar: '--palette-accent-0',
label: 'Accent 0',
default: 'oklch(88% 0.10 55)',
type: { kind: 'color', format: 'oklch' },
},
{
id: 'accent-1',
cssVar: '--palette-accent-1',
label: 'Accent 1',
default: 'oklch(68% 0.18 50)',
type: { kind: 'color', format: 'oklch' },
},
{
id: 'accent-2',
cssVar: '--palette-accent-2',
label: 'Accent 2',
default: 'oklch(48% 0.22 45)',
type: { kind: 'color', format: 'oklch' },
},
{
id: 'accent-3',
cssVar: '--palette-accent-3',
label: 'Accent 3',
default: 'oklch(30% 0.16 42)',
type: { kind: 'color', format: 'oklch' },
},
],
},
],
};Add paletteTab to PanelConfig.tabs:
// src/lib/my-panel-config.ts
import type { PanelConfig } from '@takazudo/zdtp';
import { paletteTab } from './my-tabs';
export const myPanelConfig: PanelConfig = {
storagePrefix: 'myapp-design-token-panel',
consoleNamespace: 'myapp',
modalClassPrefix: 'myapp-design-token-panel-modal',
schemaId: 'myapp-design-tokens/v1',
exportFilenameBase: 'myapp-design-tokens',
tabs: [
paletteTab,
// …other tabs…
],
applyEndpoint: 'http://127.0.0.1:24681/apply',
applyRouting: {
// 'palette' matches every --palette-* variable and routes them to
// this file. The bin rewrites the :root block atomically on Apply.
palette: 'src/styles/palette.css',
},
};Note
The applyRouting key 'palette' matches every CSS variable that starts with--palette-. If you rename the prefix (e.g. to --myapp-palette-), update the routing key to match and change all cssVar strings accordingly.
Step 2: Add matching CSS declarations in the host stylesheet
Declare every editable variable in the first bare, top-level :root block of the routed stylesheet. Keep palette, semantic, and other editable tokens together; do not split them across roots or wrap that root in @layer. Apply to source files rewrites only that first block.
/* src/styles/palette.css */
:root {
/* Base */
--palette-base-0: oklch(95% 0.005 240);
--palette-base-1: oklch(80% 0.008 240);
--palette-base-2: oklch(55% 0.010 240);
--palette-base-3: oklch(30% 0.012 240);
/* Brand */
--palette-brand-0: oklch(90% 0.08 250);
--palette-brand-1: oklch(70% 0.16 250);
--palette-brand-2: oklch(50% 0.20 250);
--palette-brand-3: oklch(30% 0.18 250);
/* Accent */
--palette-accent-0: oklch(88% 0.10 55);
--palette-accent-1: oklch(68% 0.18 50);
--palette-accent-2: oklch(48% 0.22 45);
--palette-accent-3: oklch(30% 0.16 42);
}Step 3: Use palette variables in your CSS
Use raw palette tokens for decorative teaching chips, with labels outside the chips. The panel applies live overrides to <html>. For actual component text and backgrounds, use the semantic extension below and verify each colour pair.
/* Decorative teaching chips only; keep labels outside on the host surface. */
.myapp-palette-chip {
inline-size: 3rem;
block-size: 3rem;
background-color: var(--palette-brand-1);
}Step 4: Explore the two panel modes
Open the panel (Alt+Shift+P by default, or window.zdtp.show()) and navigate to the Palette tab.
Edit mode (default)
The Edit mode shows an X-Y curve editor for each group. Drag the curve handles to adjust the Lightness, Chroma, or Hue ramp across all steps in a group simultaneously. Individual steps can be fine-tuned by clicking them directly in the chart.
All palette variables on :root are updated live as you drag.
Check mode
Use the Edit / Check segmented switcher at the top of the Palette tab to open the WCAG contrast checker. The Background (base) column on the left lists every palette colour as a selectable row with its swatch and name; the selected base row is highlighted.
The Foreground vs base column on the right evaluates every palette colour against the selected base. Each row shows a live Aa sample, the WCAG contrast ratio, and a Fail, AA, or AAA verdict chip. The chips use fixed semantic colours so their meaning stays consistent across panel themes.
Use the Flat list toolbar toggle to switch between grouped and flat listings. Enable Large text to use the 3.0 AA threshold instead of the 4.5 threshold for normal text. The footer summarizes the current result as N of M palette colors pass AA as normal/large text on {base}.
Use Check mode to validate that your text colours meet contrast requirements against your background colours before committing the palette.
Step 5: Write palette changes back to source
Once you are happy with the palette, click Apply to source files in the panel. The zdtp-server bin reads applyRouting, matches every --palette-* variable to src/, and rewrites the :root {} block in that file with the current panel values.
Start the bin alongside your dev server:
pnpm exec zdtp-server \
--routing ./panel-routing.json \
--write-root . \
--allow-origin http://localhost:5173Where panel-routing.json contains:
{
"palette": "src/styles/palette.css"
}Tip
Import the routing JSON into your PanelConfig so the UI config and the bin stay in sync:
import routing from '../../panel-routing.json' assert { type: 'json' };
export const myPanelConfig: PanelConfig = {
// …
applyRouting: routing,
};CSS variable naming: why --palette-{group}-{n}
The convention exists so applyRouting can route all palette vars with a single short key. If you add a new group, follow the same pattern:
Group tier id | Step 0 cssVar | Step 1 cssVar | … |
|---|---|---|---|
base | --palette-base-0 | --palette-base-1 | … |
brand | --palette-brand-0 | --palette-brand-1 | … |
accent | --palette-accent-0 | --palette-accent-1 | … |
Item ids follow the same {group}-{n} pattern (e.g. base-0) and must be unique across the entire tab — the persisted override map is keyed by item id.
Palette → Semantic → Component
Grouping lives inside Tier 1, as a naming convention, not a fourth tier. The palette tab holds raw ramps; a grayscale group (base here) is a lightness ramp at near-zero chroma, not a handful of unrelated greys. Tier 2 assigns roles such as background and foreground in a semantic: true tier in the color tab. Its referencesRamps lists the Tier 1 groups it can use. Components consume those roles, optionally through local Tier 3 tokens.
The zero-based {n} is a stable identifier, not a meaningful colour scale. It is explicitly not Tailwind's 100–900 convention, where 500 denotes a particular mid-tone. Keep item IDs stable because saved edits use them as keys.
Dark mode that survives panel edits
The following behaviour is available in the published @takazudo/zdtp release pinned by this documentation site (see doc/<html>. A single { ref } mapping selects one ramp item for both modes; it cannot encode separate light and dark choices. A normal dark-theme stylesheet remap loses to that inline value after a panel edit, although edits to the selected ramp still propagate.
Use colorExtras.semanticDefaults for { literal: { light, dark } } defaults. The panel emits light-dark(…), and the strings can contain var() references so both branches remain connected to the palette. A plain TierItem.default string becomes a single-mode literal unless it is recognised as a reference; it cannot supply the per-mode object. Keys in semanticDefaults are item IDs.
// Add this export to src/lib/my-tabs.ts, then add colorTab to PanelConfig.tabs.
export const colorTab: TabConfig = {
id: 'color',
label: 'Color',
tiers: [{
id: 'semantic',
label: 'Semantic',
semantic: true,
referencesRamps: [
{ tab: 'palette', tier: 'base' },
{ tab: 'palette', tier: 'brand' },
],
items: [
{ id: 'bg', cssVar: '--palette-color-bg', label: 'Background',
default: 'base:base-0', type: { kind: 'color', format: 'oklch' } },
{ id: 'fg', cssVar: '--palette-color-fg', label: 'Foreground',
default: 'base:base-3', type: { kind: 'color', format: 'oklch' } },
{ id: 'decoration', cssVar: '--palette-color-decoration', label: 'Decoration',
default: 'brand:brand-1', type: { kind: 'color', format: 'oklch' } },
],
}],
colorExtras: {
id: 'myapp-colors', label: 'My app colors',
baseRoles: {}, baseDefaults: {}, colorSchemes: {},
defaultShikiTheme: 'github-light',
panelSettings: { colorScheme: 'default', colorMode: false },
semanticDefaults: {
bg: { literal: {
light: 'var(--palette-base-0)', dark: 'var(--palette-base-3)',
} },
fg: { literal: {
light: 'var(--palette-base-3)', dark: 'var(--palette-base-0)',
} },
},
},
};Here decoration derives a direct { ref } from brand:brand-1. Its grouped ramp selector can switch the referenced step. The bg and fg overrides instead use the per-mode literal editor; that editor is not a pair of ramp selectors. These example role names share the --palette- prefix so the existing routing entry covers them too.
The host owns color-scheme. The panel writes an inline color-scheme: light dark fallback when it applies per-mode literals, but only when the host has not already set color-scheme inline on <html> (#879, fixed in 0.6.0). This recipe declares color-scheme through a stylesheet rule rather than inline, so keep the !important below — it is what keeps the host's declaration authoritative over the panel's inline fallback regardless of edit order. Have the host set data-theme="light" initially and change it on theme toggles.
/* Merge these declarations into the FIRST :root from Step 2. */
:root {
--palette-color-bg: light-dark(var(--palette-base-0), var(--palette-base-3));
--palette-color-fg: light-dark(var(--palette-base-3), var(--palette-base-0));
--palette-color-decoration: var(--palette-brand-1);
}
:root[data-theme="light"] { color-scheme: light !important; }
:root[data-theme="dark"] { color-scheme: dark !important; }
.myapp-card {
--_card-bg: var(--palette-color-bg);
background: var(--_card-bg);
color: var(--palette-color-fg);
border-inline-start: 3px solid var(--palette-color-decoration);
}Do not add a second root block when copying this fragment: merge the three token declarations into Step 2's existing root. Check actual foreground/background pairs in both modes; the decorative brand colour is not approved as text or as a text-bearing fill.
Picker limitation
The colour picker cannot parse a var() value: it opens at black, not the current computed colour. Opening it does not mutate the token. Editing replaces that mode's reference with a concrete colour and detaches it from the ramp. Reset to the configured defaults to restore the connection. Reference-aware picker initialisation is tracked in#878.
See the minimal demo for two small ramps and the playground for Base, Brand, Accent, and State groups with semantic roles. Both run the workspace build; their provenance badges distinguish that build from this docs site's pinned release.
Related
Custom token manifest — all
TabConfig,TierConfig, andTierItemfield variants.Apply pipeline setup — full wiring for the bin server, CORS, and the routing JSON.
Token manifest reference —
TierValueKindand thekind: 'color'/format: 'oklch'contract.Apply pipeline reference — how
applyRoutingmaps variable prefixes to source files.