VarPicker — unified design-token CRUD

The single surface for every design token in a site: palette colors, typography presets, built-in styleGuide tokens (radii, spacing scale, form colors, link colors, …), and user-created custom tokens. Replaces the per-row dropdowns previ…

The single surface for every design token in a site: palette colors, typography presets, built-in styleGuide tokens (radii, spacing scale, form colors, link colors, …), and user-created custom tokens. Replaces the per-row dropdowns previously living in StylesTab.

Entry points

  1. Property-input type chip — clicking the {} braces in any property-row's TypeSelector opens VarPicker anchored to the chip, applies-to-filtered by the current input's tailwindKey / propTag. Picking a row writes var(--name) (or font-(--name) for fontFamily) and closes the panel. Wired by packages/sdk/src/chrome/toolbar/inputs/universal-input/UniversalInputView.tsx.
  2. StylesTab "Manage Tokens →" button — opens centered, no applies-to filter. Used for editing built-ins / creating custom tokens without going through a property input. Lives in packages/sdk/src/chrome/viewport/design-system/components/StylesTab.tsx.

Both entry points render the same <VarPicker> component — only the anchor and propTag / tailwindKey differ.

Three-stage panel

list   ──"+ New Token"──▶  create
list   ──row Edit hover──▶ edit
create ──Save──▶  list (token now selectable)
edit   ──Save / Delete / Cancel──▶ list
StageFileRole
OrchestratorVarPicker/index.tsxFloatingPanel chrome + stage state machine.
ListVarPicker/VarList.tsxSearch field, scrollable rows, hover-revealed Edit pencil (styleGuide tokens only), sticky + New Token footer.
EditorVarPicker/VarEditor.tsxType-driven create / edit form, in-use guards, delete button.

The orchestrator owns the stage state — children don't unmount FloatingPanel on transitions, so panel position + drag state survive list ↔ editor moves.

Storage — no new top-level buckets

Custom tokens piggyback on the existing storage that already flows through useDesignVars and designSystemVars.ts:

Token kindStorageCRUD path
Palette colortheme.palette / theme.darkPalette{name,color}[]Existing CreateTokenDialog + DesignSystemPalette. VarPicker delegates — palette rows show in the list but their Edit pencil is hidden (use the existing palette manager).
Typography presettheme.typography[]Existing TextStyleEditorPanel. Same story — VarPicker shows them but doesn't edit them.
Built-in styleGuide tokentheme.styleGuide[key]VarEditor — name + type + appliesTo are LOCKED, only value is editable.
Custom styleGuide tokentheme.styleGuide[key] + theme.styleGuideMeta[key]VarEditor — full CRUD, type-driven editor.

The new sidecar prop:

// On ROOT.props.theme — optional, sparse
styleGuideMeta?: Record<string, {
  appliesTo?: ("palette" | "colors" | "spacing" | "typography" | "other")[];
  custom?: boolean;  // true if user-created
}>;

For built-in keys we already know type + category from the registry, so most tokens never need a styleGuideMeta entry. Custom keys carry custom: true so the UI can show Delete + the list can label them as "(Custom)".

Built-in registry

Every styleGuide key surfaced in the picker is listed in styleTokenRegistry.ts:

export const STYLE_TOKEN_REGISTRY: Record<
  string,
  {
    label: string;
    category: "spacing" | "colors" | "other" | "typography";
    type: "color" | "dimension" | "number" | "text";
    quickPicks?: { value: string; label: string }[];
  }
>;
  • category drives the relevance filter (which inputs show this token in the list).
  • type drives the value editor (color → SketchPicker, dimension → UniversalInputControlled, number → number input, text → text input).
  • quickPicks shows click-to-fill chips above the value editor — useful for tokens with a small enumerated value set (shadowStyle, linkUnderline, padding shorthands, clamp() spatial scale).

Adding a new built-in styleGuide key: add the registry entry + the default value in DEFAULT_STYLE_GUIDE (packages/sdk/src/utils/defaults.ts). useDesignVars and designSystemVars.ts pick it up automatically.

NON_TOKEN_STYLE_KEYS (in the same file) is the small skip-list — currently just spacingDensity, which is a global multiplier surfaced as a slider in StylesTab rather than a per-token row.

In-use guard — useTokenUsage

hooks/useTokenUsage.ts — given a --var-name, returns { count, nodeIds } for nodes whose stringified props contain var(--name).

Used to disable rename + delete when a token is in use:

  • VarEditor — name input disabled, Delete button disabled, in-use banner.
  • CreateTokenDialog (palette CRUD) — same guard added (rename + delete blocked when refs exist).

Match scope: substring search on JSON.stringify(node.data.props). Catches className / mobileClassName / tabletClassName, inline style, attrs, anywhere a string holds var(--brand). Misses indirect alias chains (no aliases in v1).

Cost: walks every node once per call, memoized against the editor's tree-update counter. Sub-ms on typical PageHub trees.

useDesignVars data flow

hooks/useDesignVars.ts is the single read entry point for the picker:

  1. Palette — iterate theme.palette (or DEFAULT_PALETTE). Emit category palette, source palette.
  2. Typography — iterate theme.typography. Per font, emit 6 vars (family / size / weight / line-height / letter-spacing / text-transform). Category typography, source typography.
  3. StyleGuide — iterate the merged DEFAULT_STYLE_GUIDE + theme.styleGuide:
    • If the key is in STYLE_TOKEN_REGISTRY → use its label + category, source styleGuide.
    • Else → user-created. Category derived from styleGuideMeta[key].appliesTo[0] ?? value-heuristic (oklch( / #colors; 1remspacing; etc.). Mark custom: true, label as "<HumanizedKey> (Custom)".

The DesignVar type carries custom? + key + source so VarPicker can route Edit clicks correctly and lock vs. unlock fields.

UniversalInput View / Controlled split

VarEditor's dimension-typed value editor reuses the full UniversalInput UX (autocomplete, calc dialog, var picker chaining, type chip) — but UniversalInput is hard-wired to CraftJS's useNode() which throws when there's no <NodeProvider> ancestor. The split:

FileRole
UniversalInputView.tsxPure presentational shell. Takes a state bag + handlers + flat config props. No Craft hooks.
index.tsx (UniversalInput)Craft-bound wrapper: useUniversalInputState + useInputHandlers → render View.
UniversalInputControlled.tsxLocal-state-only wrapper for VarEditor. Same UX, no node writes.
hooks/useControlledInputState.tsMirror of useUniversalInputState minus the Craft hooks; sources currentValue from the controlled value prop, dispatches via onChange.

Both wrappers expose the same prop surface to View, so behavior is identical. Existing callers of UniversalInput are unchanged.

StylesTab simplification

StylesTab.tsx used to host 6 ToolbarSections of hard-coded preset dropdowns (Spacing & Layout, Spatial Scale, Effects & Borders, Sizing, Form Inputs, Links — ~600 lines). All of those rows are now individual built-in tokens editable in VarPicker. The tab keeps:

  • TokensManage Tokens → button + read-only summary (X palette · Y spacing · Z custom).
  • Density — global Spacing Density slider (multiplies every spatial-scale token; not itself a token).
  • Breakpoints (Advanced) — unchanged.
  • Editor preferences — unchanged.

Caveats

  • Rename a custom token — name input is disabled while useTokenUsage(varName).count > 0. User must clear references in the canvas first. v1 doesn't auto-rewrite references.
  • Delete a custom token — same guard. Built-in tokens never expose Delete (they're required by the design system).
  • Custom key uniqueness — when creating, the slug is derived from the name (camelCase) and validated against STYLE_TOKEN_REGISTRY to prevent shadowing a built-in. Inline error if it would collide.
  • Type-change after create — disabled. The stored value is type-shaped; switching color → dimension would orphan the value.
  • Palette CRUD — VarPicker doesn't reimplement it. Palette rows are select-only; clicking a palette token in the list applies it. Edits go through the existing CreateTokenDialog + DesignSystemPalette UI inside the color picker popover.

Related

  • theme.mdtheme.palette / theme.styleGuide / theme.typography shape and CSS-var emission.
  • text-styles.md — typography preset CRUD (separate surface from VarPicker).
  • dropdown-variants.mdUnifiedDropdown (multi-column tailwind autocomplete) vs ToolbarDropdown (single-column Listbox). VarPicker is its own thing — neither.
  • editor-popover-pattern.md — FloatingPanel + trigger ↔ panel split. VarPicker uses FloatingPanel directly (no trigger split, since the trigger is the type-chip inside an already-rendered input).