Skip to content

Preview

Preview panel — Base flavor with Layers view, all entity rowsPreview panel — Base flavor with Layers view, all entity rows

The preview panel renders a live visualization of all generated swatches for the selected theme.

Top Toolbar

Preview panel — States flavor showing per-condition rows × interaction columnsPreview panel — States flavor showing per-condition rows × interaction columns

The toolbar is split 50/50 with a transparent divider in the middle:

  • Left half — Mode selector -- dropdown showing the active mode swatch + name. Hovering an option live-previews that mode (swatches, seed background, and computed tones) without committing; arrow-key navigation drives the same preview. The select uses the same 35/65 grid (label/control) as the filter block below.
  • Right half — Layers / Scrims toggle -- switches between the layers view (surfaces, containers, content) and the scrims view (scrims with containers and content from the last surface). Uses the segmented-control style (bg-muted container, bg-background + shadow-sm active pill) — same visual as the global header section nav and the bottom bar.
  • Right half — Base / States toggle -- controls state expansion. Same segmented-control style:
    • Base -- shows base tokens only (factors 1×1, no per-state breakdown).
    • States -- shows one swatch row per enabled state (idle, hovered, pressed, …).
  • Paste to canvas -- a floating button in the preview area. Captures the preview DOM as SVG (background rects + text nodes via TreeWalker, with Range.getClientRects() for precise text positioning) and sends it to the Figma plugin sandbox which renders it via figma.createNodeFromSvg(). Zoom is temporarily reset to 100% for accurate measurements. If a Vision/CVD filter is active, colors are baked into the SVG via simulateSvg() (Figma's createNodeFromSvg doesn't support feColorMatrix). Clamp arrows are rendered with a computed contrast color (luminance-based) instead of mix-blend-mode. Works with both narrow and wide layouts. Text nodes are stripped from the SVG before paste and recreated by the plugin as real TEXT nodes — font-family is honoured (Inter for symbols ✲ → ↑ ↓ ← and labels, JetBrains Mono for monospace chips), text-anchor="middle" keeps glyphs metric-agnostic, and lineHeight is pinned to fontSize so the recreated text sits at the same optical centre as the in-panel rendering.

Zoom controls and the filters-collapse button live in the Global Header above the editor and preview panels, not in the preview toolbar. Zoom range is 25%–300% with a Fit option (toggle ⌥⇧0) that auto-scales content to the visible area on every resize. Cursor-anchored zoom-by-wheel works with / / modifiers — the point under the cursor stays put as you scroll.

Preview verbosity

A three-level setting (Settings → Display → Preview verbosity) controls how much textual scaffolding wraps the swatches:

  • Verbose -- historical look. c-N N.NN and coF X chF Y labels above and below every swatch; 96-px wide swatch pills.
  • Balanced (default) -- per-swatch labels dropped, swatches collapse to 40 × 16 px. Section / row / condition headers still render so the user can orient.
  • Terse -- every text label hidden (section headers, entity names, condition names, row labels, per-swatch labels). Only colour shapes + structural padding remain. Useful when pasting a clean preview to the Figma canvas without metadata noise.

Paste-to-canvas honours the active mode — the SVG export walks the live DOM, so labels that aren't rendered in panel never leave the export. When all swatches in a row are filtered out by Skip Duplicate Tokens / Skip Low-Contrast Tokens, balanced and verbose surface a one-line reason explaining which filter hid the row; terse drops the row entirely.

Conditions in the wide layout

When the preview is set to wide, EntityRowView flows its per-condition groups (Enabled, Selected, Disabled, …) horizontally — Enabled | Selected | Disabled side-by-side — instead of stacking them vertically. Same content as narrow, just laid out for wider viewports so users can compare conditions at a glance.

Filter Bar

A two-column block split 50/50 with a vertical divider:

  • Left column — Entity color overrides: per-entity selector that lets you swap which color is previewed for each entity, independent of the full set assigned in the editor. Each row uses a 35/65 grid (entity name / select). The select shows a swatch pill plus name; hovering an option live-previews that color override without committing; arrow-key navigation drives the preview.
  • Right column — Display filters: 3 horizontal segmented controls (each labelled left, control right):
    • Containers -- All / Inner / None. Determines whether all container levels, only the innermost, or none are shown. (Overlays view: Show / Hide.)
    • Content -- All / Inner / None. Same logic for content entities. (Overlays view: Show / Hide.)
    • Swatches -- All / Direct / Inv. Controls whether normal-direction, inverted-direction, or both swatch sets are displayed.

The Vision (CVD simulation) filter lives in the Status bar at the bottom of the plugin window (next to the chat trigger), not in the preview filter bar — see Vision Filter below. Moved out of the preview's right-hand column so the filter bar stays focused on what's currently being computed (entities, layers, swatch direction) and the global colour-vision lens reads as a viewing-mode toggle rather than another filter on top of the data.

Vision Filter (CVD Simulation)

Lives in src/lib/vision-filters.ts. Five color-vision-deficiency simulation modes are exposed via the Vision select in the filter bar:

ModePopulationDescription
Regular Vision~68%Normal trichromatic vision — all three cone types working as expected.
Protanopia~1.5%Red-blind. L-cones absent. Reds appear dark; red/orange/green become hard to tell.
Deuteranopia~1.2%Green-blind. M-cones absent. Greens shift to beige; reds shift to dark yellow.
Tritanopia~0.03%Blue-blind. S-cones absent. Blue/green merge; yellow/pink merge. Very rare.
Achromatopsia<0.01%No color perception — grayscale only. Useful to verify contrast holds.

Each mode is a 3×3 RGB color matrix (Brettel/Vienot/Mollon coefficients). The live preview applies them via SVG <defs><filter><feColorMatrix> referenced through CSS filter: url(#vision-{mode}) on the preview wrapper — GPU-accelerated, no per-frame JS. For paste-to-canvas, simulateSvg(svg, mode) walks every fill="..." and stroke="..." attribute and bakes the matrix transformation in JS, since Figma's createNodeFromSvg() doesn't honor feColorMatrix.

Layers Preview

Nested visual structure:

  1. Surface levels -- background rectangles at computed tones, nested inside each other.
  2. Containers (frames) -- colored blocks within each surface level, each at a contrast ratio from the parent surface.
  3. Inv-containers -- same containers but computed in the flipped direction (e.g., lighter chips in dark mode, darker chips in light mode).
  4. Content (top-layers) -- swatch rows rendered on top of surfaces, containers, and inv-containers.
  5. Inv-content -- content swatches in the flipped direction.

Overlays Preview

  • Scrim levels -- overlay background blocks computed from the seed color.
  • Each overlay level contains containers and content rows derived from the last surface level.

Swatch Display

Each swatch renders as a 72px-wide colored rectangle with:

  • 2px border-radius for container-type swatches.
  • Actual WCAG contrast ratio computed via MCU Contrast.ratioOfTones, displayed on the swatch.
  • Effective factors -- coF (effective contrast factor) and chF (effective chroma factor) displayed below the swatch in the format coF 1.39 chF 1.1 (space-separated, no dashes). Values exactly equal to 1 are rendered in italic to indicate they are neutral.
  • Pattern label -- for surface levels, shows the level pattern name and index. Uses CSS marginLeft instead of template literal spaces for proper SVG export spacing.
  • Interactive swatches -- rendered only when there is an idle event plus at least one interactive event. Respond to mouse events: hover shows the Hovered state color, click shows Pressed, Tab/focus shows Focused (outline, not color change), drag moves the swatch XY +/-16px to preview the Dragged state. This lets you test state transitions live without leaving the preview.
  • Adaptive dashed borders -- swatches with low contrast against the preview background get dashed outline borders (consistent rgba(0,0,0,0.25) everywhere: editor swatches, preview selects, SVG markers, color picker).
  • Empty states -- when an entity has no colors assigned, the preview shows a placeholder message instead of empty space.
  • Hover preview in settings -- easing select dropdowns show a live gradient preview on hover; color filter dropdowns show a live swatch preview on hover.
  • Copy hex on swatch click -- clicking a swatch copies the hex value to clipboard. In the Figma iframe environment (where navigator.clipboard is unavailable), uses a postMessage fallback to copy via the plugin sandbox.
  • Clamp arrows -- directional arrow indicators (→ ← ↔ ↕ ↑ ↓) appear inside swatches and on level/frame headers when adjacent tones are identical (clamped to 0 or 100). This tells the designer that increasing contrast further won't produce a different color. Rendered with computed contrast color (luminance-based) instead of mix-blend-mode for accurate SVG export.
  • Snap gamut button -- when a swatch is clamped, a single "snap H:X C:Y" button (or just one channel) with a shrink icon appears. Clicking snaps all clamped channels at once.

Drag-to-scroll (pan)

Middle-mouse-button or left-button drag on the preview scroll area pans the viewport (via @use-gesture/react). Interactive elements (buttons, inputs, InteractiveSwatch rows) are excluded so their own drag semantics are preserved. A 4px threshold (filterTaps) prevents accidental pans on single clicks. The cursor changes to grabbing during a pan.

Render pipeline & caching

The preview is plain DOM — no canvas, no SVG-as-renderer — so React reconciliation is the only redraw mechanism. Because the tree is deep (levels × frames × entities × states × swatches), two complementary layers keep every drag tick out of a full rebuild:

1. Wrapper-level structural caches. computePreviewData builds the preview tree once per theme-structure change; cached wrapper objects (PreviewStateRow[], PreviewEntityRow) stay ref-stable across colour drags so React.memo on EntityRowView / NestedLevels / OverlayPreview short-circuits the whole wrapper subtree. Keys live in src/components/PreviewPanel.tsx.

CacheKeySizeWhat it preserves
makeSwatchesCache / makeSwatchesInvCachehex points + parent tone + contrast ratios + effective factors + direction + easing + positions + contrast model4000 entriesThe PreviewSwatch[] array for a single entity/parentTone/factor tuple. Full-data — populated with color/tone/ratio at each compute pass and published to the swatch store.
stateRowsCache / stateRowsInvCacheentityStructSig (name + shape + states list + contrast slot count + surfaceScope + direction + entityType) + parent tone + shared (mode + states + model) signature2000 entriesPreviewStateRow[] arrays with stable swatch paths baked in. Colour-only edits do NOT invalidate — that's what lets InteractiveSwatchRow stay memoised during a drag.
entityRowCache / entityRowInvCacherow kind (direct / inv / container / containerInv) + entityStructSig + parent tone + shared signature2000 entriesPreviewEntityRow with swatches carrying path strings. Same colour-independence invariant as the state-rows cache — wrappers above the leaves only re-render for STRUCTURAL edits (name, shape, states list, contrast slot count, surfaceScope, direction, entityType).

Because the cache keys are structural, computePreviewData calls publishEntitySwatches(kind, entity, parentTone) BEFORE each lookup. That iterates the entity's states, calls makeSwatches(Inv) (cheap via its own LRU), and publishes every swatch to src/lib/preview-swatch-store.ts under a deterministic path — so the store always has the tick's current-colour data even when the wrapper cache keeps returning the exact same row/group objects. A module-level lastPublishedArr map short-circuits this loop when makeSwatches returns the identical cached array (unchanged-colour entities bail before doing any per-swatch work), and a per-compute publishedInThisCompute Set dedupes across repeat lookups of the same (kind, entity, parentTone) tuple from different frame/level code paths.

2. Leaf-level subscription. Terminal <SwatchAt> components (and the <InteractiveSwatchAt> variant for interactive cells that bundle 3–5 state colours into one subscribed cell) consume the swatch store via useSyncExternalStore, keyed on the stable path from the cached row. When publishSwatch(path, …) notices the data actually moved (field-level equality check), only the subscribers of that path re-render — the wrapper rows above them stay out of the React commit.

This split is what drives the benchmark numbers below:

Bench counterBefore decompAfter decomp
EntityRowView body runs per 3 s drag~59900 (structural cache hit 100%)
NestedLevels body runs~1001
entityRow / ctxGroups cache hit rate80%100%
SwatchAt body runs~27000 (one per visible swatch whose colour actually changed × the drag ticks that moved it)

The SwatchAt count looks large, but it's the LEGITIMATELY necessary work — every swatch bound to the dragged colour has to paint new pixels. Wrappers just stopped doing their part of the walk for zero DOM benefit.

computePreviewData itself is wrapped in useMemo and fed through useDeferredValue on the two heavy inputs (effectiveTheme, effectiveColorOverrides). React 18 can interrupt the resulting concurrent render when a more urgent update (slider tick, pointer move) arrives, so drag ticks don't block the main thread even when a cold cache forces a full recompute.

The dev-only perf harness (src/lib/preview-perf.ts) records hit/miss counters per cache, the wall-clock cost of each computePreviewData call, and a renders[component] counter fed by bumpRender(name) calls sprinkled in the hot-path components. window.__previewPerf.snapshot().renders returns the map after any interaction.

A collapsible panel below the preview scroll area showing live details about the swatch, surface, or container under the cursor:

  • Swatch row — column index, contrast ratio, hex value.
  • Clamp row — "Same hex as neighbour — contrast ceiling reached" or "No contrast ceiling reached".
  • Chroma row — "Chroma clipped N% — gamut limit at this tone" or "Full source chroma preserved".
  • Entity row — surface / container / content / scrim / seed.

The info updates via event delegation — a single pointermove handler on the scroll container walks up from the target to the nearest [data-preview-hover] element, avoiding React listeners on thousands of individual swatches. Pointer-leave clears to null (placeholder dashes).

Toggle: Settings → Editor Display → Preview footer. Hotkey: ⌥2 (Alt+2). State persisted as showPreviewFooterPanel in LayoutState.

Clamp Indicators

When tone reaches the boundary (0 = black, 100 = white), further contrast increases have no effect. Systema detects this and shows arrows inside the swatch:

ArrowMeaning
Same color as the next (right/lower) neighbor
Same color as the previous (left/upper) neighbor
↔ / ↕Same as both neighbors — fully clamped

These appear on:

  • Content swatches -- comparing adjacent contrast chips in the same row
  • Surface level headers -- comparing adjacent levels
  • Container frame headers -- comparing adjacent container levels

Chroma-loss Indicator

A second, softer signal for colour-identity loss. When MCU's internal gamut clip reduces the requested chroma by more than 40% to fit sRGB at the target tone (vivid seed pushed to an extreme tone), the swatch renders a single glyph after any clamp arrows, inside the swatch at the same 8px text size. This is distinct from the arrow signal — arrows mean "tone ceiling reached, same hex as neighbour"; the star means "the hue is technically different but saturation got dropped enough that the colour has drifted from its source identity".

The threshold (CHROMA_LOSS_INDICATOR_THRESHOLD = 0.4) is intentionally conservative so the marker only appears when loss is visually obvious. Low-chroma seeds (< 10 chroma in the requested shade) skip detection entirely — a gray ladder doesn't benefit from a clip indicator because there's no saturation to clip.

Hovering any preview swatch opens a consistent Tooltip component (not a native title) showing the contrast value, plus optional lines for "Same hex as neighbour" (when arrows present) and "Chroma clipped N% — gamut limit at this tone" (when the star is present). Both signals have their own shadcn Tooltip so keyboard focus surfaces the same text.

Implementation: GeneratedShade carries chroma (requested) and chromaActual (re-read via hexToHct(hex).chroma after MCU's clip). Preview's PreviewSwatch.chromaLoss is computed in makeSwatches/makeSwatchesInv as (chroma − chromaActual) / chroma.


All rights reserved.