feat(styles): STYLE-001 Phase 3+4 — TokenPicker component and preview CSS injection

- TokenPicker: searchable grouped dropdown for selecting design tokens
  with colour swatch previews, clear button, category filtering, custom
  group override support (noodl-core-ui)
- PreviewTokenInjector: singleton service that injects project design
  tokens as a <style id='noodl-design-tokens'> into the preview webview
  via executeJavaScript on every dom-ready and tokensChanged event
- CanvasView: calls notifyDomReady() after valid dom-ready sessions and
  clearWebview() on dispose
- ProjectDesignTokenContext: attaches PreviewTokenInjector to the
  StyleTokensModel on mount
This commit is contained in:
Richard Osborne
2026-02-18 16:20:13 +01:00
parent 297dfe0269
commit 8ee374d21e
6 changed files with 819 additions and 0 deletions

View File

@@ -0,0 +1,299 @@
/**
* STYLE-001 Phase 3: TokenPicker styles
* All colours via design tokens — no hardcoded values.
*/
.TokenPicker {
position: relative;
display: flex;
flex-direction: column;
gap: var(--spacing-1, 4px);
width: 100%;
}
// ── Label ────────────────────────────────────────────────────────────────────
.TokenPicker-label {
font-size: var(--font-size-xsmall, 11px);
font-weight: 500;
color: var(--theme-color-fg-default-shy);
text-transform: uppercase;
letter-spacing: 0.05em;
user-select: none;
}
// ── Trigger button ───────────────────────────────────────────────────────────
.TokenPicker-trigger {
display: flex;
align-items: center;
gap: var(--spacing-1, 4px);
width: 100%;
padding: var(--spacing-1, 4px) var(--spacing-2, 8px);
background: var(--theme-color-bg-3);
color: var(--theme-color-fg-default);
border: 1px solid var(--theme-color-border-default);
border-radius: var(--border-radius-small, 4px);
font-size: var(--font-size-small, 12px);
cursor: pointer;
text-align: left;
transition: border-color 100ms ease, background 100ms ease;
min-height: 26px;
&:hover:not(:disabled) {
background: var(--theme-color-bg-2);
border-color: var(--theme-color-primary);
}
&:focus-visible {
outline: 2px solid var(--theme-color-primary);
outline-offset: 1px;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
// When a token is selected — highlight border subtly
&--hasValue {
border-color: color-mix(in srgb, var(--theme-color-primary) 40%, var(--theme-color-border-default));
}
}
// Colour swatch in the trigger
.TokenPicker-swatch {
display: inline-block;
width: 14px;
height: 14px;
border-radius: 2px;
border: 1px solid rgba(255, 255, 255, 0.15);
flex-shrink: 0;
}
.TokenPicker-triggerText {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.TokenPicker-clearBtn {
flex-shrink: 0;
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--theme-color-fg-default-shy);
font-size: 14px;
line-height: 1;
cursor: pointer;
border-radius: 2px;
padding: 0;
&:hover {
color: var(--theme-color-fg-default);
background: var(--theme-color-bg-2);
}
}
.TokenPicker-chevron {
flex-shrink: 0;
font-size: 10px;
color: var(--theme-color-fg-default-shy);
line-height: 1;
pointer-events: none;
}
// ── Dropdown panel ───────────────────────────────────────────────────────────
.TokenPicker-dropdown {
position: absolute;
top: calc(100% + var(--spacing-1, 4px));
left: 0;
right: 0;
z-index: 300;
background: var(--theme-color-bg-2);
border: 1px solid var(--theme-color-border-default);
border-radius: var(--border-radius-small, 4px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
max-height: 320px;
overflow: hidden;
min-width: 200px;
}
// ── Search row ───────────────────────────────────────────────────────────────
.TokenPicker-searchRow {
padding: var(--spacing-1, 4px) var(--spacing-2, 8px);
border-bottom: 1px solid var(--theme-color-border-default);
flex-shrink: 0;
}
.TokenPicker-search {
width: 100%;
padding: 3px var(--spacing-1, 4px);
background: var(--theme-color-bg-3);
color: var(--theme-color-fg-default);
border: 1px solid var(--theme-color-border-default);
border-radius: 3px;
font-size: var(--font-size-small, 12px);
outline: none;
&::placeholder {
color: var(--theme-color-fg-default-shy);
}
&:focus {
border-color: var(--theme-color-primary);
}
}
// ── Token list ───────────────────────────────────────────────────────────────
.TokenPicker-list {
overflow-y: auto;
flex: 1;
}
.TokenPicker-empty {
padding: var(--spacing-2, 8px) var(--spacing-3, 12px);
font-size: var(--font-size-small, 12px);
color: var(--theme-color-fg-default-shy);
text-align: center;
}
// ── Token group ───────────────────────────────────────────────────────────────
.TokenPicker-group {
& + & {
border-top: 1px solid var(--theme-color-border-default);
}
}
.TokenPicker-groupLabel {
display: block;
padding: var(--spacing-1, 4px) var(--spacing-2, 8px);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--theme-color-fg-default-shy);
user-select: none;
position: sticky;
top: 0;
background: var(--theme-color-bg-2);
z-index: 1;
}
// ── Token option ─────────────────────────────────────────────────────────────
.TokenPicker-option {
display: flex;
align-items: center;
gap: var(--spacing-1, 4px);
padding: 3px var(--spacing-2, 8px);
background: transparent;
color: var(--theme-color-fg-default);
border: none;
font-size: var(--font-size-small, 12px);
cursor: pointer;
text-align: left;
width: 100%;
transition: background 60ms ease;
&:hover {
background: var(--theme-color-bg-3);
}
&--active {
color: var(--theme-color-primary);
background: color-mix(in srgb, var(--theme-color-primary) 10%, transparent);
&:hover {
background: color-mix(in srgb, var(--theme-color-primary) 15%, transparent);
}
}
// Custom tokens get a subtle tint
&--custom {
.TokenPicker-optionName {
font-style: italic;
}
}
}
.TokenPicker-optionPreview {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
}
.TokenPicker-optionSwatch {
display: block;
width: 14px;
height: 14px;
border-radius: 3px;
border: 1px solid rgba(255, 255, 255, 0.12);
flex-shrink: 0;
}
.TokenPicker-optionValueBadge {
display: block;
font-size: 9px;
font-weight: 600;
color: var(--theme-color-fg-default-shy);
text-transform: uppercase;
letter-spacing: 0;
max-width: 20px;
overflow: hidden;
text-overflow: clip;
white-space: nowrap;
}
.TokenPicker-optionBody {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 1px;
}
.TokenPicker-optionName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--font-size-small, 12px);
line-height: 1.3;
}
.TokenPicker-optionValue {
font-size: 10px;
color: var(--theme-color-fg-default-shy);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.2;
}
.TokenPicker-customBadge {
flex-shrink: 0;
font-size: 9px;
color: var(--theme-color-primary);
opacity: 0.7;
}
.TokenPicker-checkmark {
flex-shrink: 0;
font-size: 10px;
color: var(--theme-color-primary);
line-height: 1;
}

View File

@@ -0,0 +1,397 @@
/**
* STYLE-001 Phase 3: TokenPicker
*
* Reusable dropdown component for selecting a design token to bind to a CSS property.
* Displays the current token (with colour swatch preview for colour tokens) and opens
* a searchable, grouped list of all available tokens.
*
* This component is fully dumb — it receives tokens as plain data and fires callbacks.
* Token resolution (var() → actual CSS value) must be done by the caller before passing
* items in. This keeps noodl-core-ui free of editor-specific model dependencies.
*
* Usage:
* <TokenPicker
* tokens={allTokens.map(t => ({ ...t, resolvedValue: model.resolveToken(t.name) }))}
* selectedToken="--primary"
* onTokenSelect={(cssVar) => applyValue(cssVar)}
* onClear={() => applyValue('')}
* filterCategories={['color-semantic', 'color-palette']}
* label="Background Color"
* />
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import css from './TokenPicker.module.scss';
// ─── Public types (no editor dependencies) ───────────────────────────────────
export interface TokenPickerItem {
/** CSS custom property name, e.g. '--primary' */
name: string;
/** Raw token value — may be a var() reference */
value: string;
/** Resolved CSS value for preview rendering, e.g. '#3b82f6'. Pre-resolved by caller. */
resolvedValue?: string;
/** Category string matching TokenCategory in the editor, e.g. 'color-semantic' */
category: string;
/** Whether this token has been customised from the project defaults */
isCustom?: boolean;
/** Optional description shown in the dropdown */
description?: string;
}
export interface TokenPickerGroup {
/** Display label for the group header */
label: string;
/** Tokens belonging to this group */
tokens: TokenPickerItem[];
}
export interface TokenPickerProps {
/** All available tokens to show in the dropdown. */
tokens: TokenPickerItem[];
/**
* Groups to use for displaying tokens in the dropdown.
* If provided, overrides the auto-grouping from `groupBy`.
*/
groups?: TokenPickerGroup[];
/**
* Derive the display group label from a category string.
* Defaults to capitalising the category's prefix (e.g. 'color-semantic' → 'Colors').
*/
groupBy?: (category: string) => string;
/**
* Currently selected token name (CSS custom property name, without var() wrapper).
* Pass null or undefined if no token is currently selected.
*/
selectedToken: string | null | undefined;
/**
* Called when the user picks a token.
* The argument is the full CSS `var(--token-name)` string, ready to use as a style value.
*/
onTokenSelect: (cssVar: string) => void;
/**
* Called when the user clears the token selection.
* If not provided, the clear button is hidden.
*/
onClear?: () => void;
/** Filter to specific category strings. When empty/undefined, all tokens are shown. */
filterCategories?: string[];
/** Label shown above the trigger button. */
label?: string;
/** Placeholder text when no token is selected. Defaults to 'Choose token'. */
placeholder?: string;
/** Disable the picker. */
disabled?: boolean;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
/** Detect whether a value (or category) represents a colour. */
function isColorCategory(category: string): boolean {
return category.startsWith('color');
}
/** Check whether a resolved CSS value looks like a colour (hex, rgb, hsl, named). */
function looksLikeColor(value: string): boolean {
const v = value.trim();
return (
v.startsWith('#') || v.startsWith('rgb') || v.startsWith('hsl') || /^[a-z]+$/.test(v) // css named colours
);
}
/**
* Format a token name for display.
* '--color-primary' → 'color-primary'
* '--space-4' → 'space-4'
*/
function formatTokenLabel(name: string): string {
return name.replace(/^--/, '');
}
/**
* Derive a group label from a category string.
* 'color-semantic' → 'Colors'
* 'typography-size' → 'Typography'
* 'border-radius' → 'Borders'
*/
function defaultGroupBy(category: string): string {
const prefix = category.split('-')[0];
const map: Record<string, string> = {
color: 'Colors',
spacing: 'Spacing',
typography: 'Typography',
border: 'Borders',
shadow: 'Effects',
animation: 'Animation'
};
return map[prefix] ?? prefix.charAt(0).toUpperCase() + prefix.slice(1);
}
/** Group a flat token list into labelled groups, preserving insertion order. */
function buildGroups(tokens: TokenPickerItem[], groupBy: (cat: string) => string): TokenPickerGroup[] {
const map = new Map<string, TokenPickerItem[]>();
for (const token of tokens) {
const label = groupBy(token.category);
if (!map.has(label)) map.set(label, []);
map.get(label)!.push(token);
}
return Array.from(map.entries()).map(([label, items]) => ({ label, tokens: items }));
}
// ─── Component ───────────────────────────────────────────────────────────────
export function TokenPicker({
tokens,
groups: groupsProp,
groupBy = defaultGroupBy,
selectedToken,
onTokenSelect,
onClear,
filterCategories,
label,
placeholder = 'Choose token',
disabled = false
}: TokenPickerProps) {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
// Resolve the currently selected token item
const selectedItem = useMemo(
() => (selectedToken ? tokens.find((t) => t.name === selectedToken) ?? null : null),
[tokens, selectedToken]
);
// Filter & group tokens for the dropdown
const visibleGroups = useMemo<TokenPickerGroup[]>(() => {
// Start from explicit groups or auto-build them
const source = groupsProp ?? buildGroups(tokens, groupBy);
// Apply category filter
const categorized =
filterCategories && filterCategories.length > 0
? source.map((g) => ({ ...g, tokens: g.tokens.filter((t) => filterCategories.includes(t.category)) }))
: source;
// Apply search filter
const q = search.trim().toLowerCase();
if (!q) return categorized.filter((g) => g.tokens.length > 0);
return categorized
.map((g) => ({
...g,
tokens: g.tokens.filter(
(t) =>
t.name.toLowerCase().includes(q) ||
(t.description ?? '').toLowerCase().includes(q) ||
(t.resolvedValue ?? t.value).toLowerCase().includes(q)
)
}))
.filter((g) => g.tokens.length > 0);
}, [tokens, groupsProp, groupBy, filterCategories, search]);
// ── Event handlers ──────────────────────────────────────────────────────
const open = useCallback(() => {
if (!disabled) {
setIsOpen(true);
setSearch('');
}
}, [disabled]);
const close = useCallback(() => {
setIsOpen(false);
setSearch('');
}, []);
const handleSelect = useCallback(
(item: TokenPickerItem) => {
close();
onTokenSelect(`var(${item.name})`);
},
[close, onTokenSelect]
);
const handleClear = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
close();
onClear?.();
},
[close, onClear]
);
// Focus search on open
useEffect(() => {
if (isOpen) {
requestAnimationFrame(() => searchRef.current?.focus());
}
}, [isOpen]);
// Close on outside click
useEffect(() => {
if (!isOpen) return;
function onPointerDown(e: PointerEvent) {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
close();
}
}
document.addEventListener('pointerdown', onPointerDown);
return () => document.removeEventListener('pointerdown', onPointerDown);
}, [isOpen, close]);
// Close on Escape, navigate with keyboard
useEffect(() => {
if (!isOpen) return;
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') close();
}
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [isOpen, close]);
// ── Trigger swatch ─────────────────────────────────────────────────────
const triggerSwatch =
selectedItem && isColorCategory(selectedItem.category) && selectedItem.resolvedValue
? selectedItem.resolvedValue
: null;
const totalTokenCount = visibleGroups.reduce((n, g) => n + g.tokens.length, 0);
// ── Render ──────────────────────────────────────────────────────────────
return (
<div className={css['TokenPicker']} ref={containerRef}>
{label && <span className={css['TokenPicker-label']}>{label}</span>}
{/* Trigger */}
<button
type="button"
className={[css['TokenPicker-trigger'], selectedItem ? css['TokenPicker-trigger--hasValue'] : '']
.filter(Boolean)
.join(' ')}
onClick={open}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={isOpen}
title={selectedItem ? formatTokenLabel(selectedItem.name) : placeholder}
>
{triggerSwatch && (
<span className={css['TokenPicker-swatch']} style={{ background: triggerSwatch }} aria-hidden />
)}
<span className={css['TokenPicker-triggerText']}>
{selectedItem ? formatTokenLabel(selectedItem.name) : placeholder}
</span>
{selectedItem && onClear && (
<button
type="button"
className={css['TokenPicker-clearBtn']}
onClick={handleClear}
title="Clear token"
aria-label="Clear token selection"
>
×
</button>
)}
{!selectedItem && (
<span className={css['TokenPicker-chevron']} aria-hidden>
</span>
)}
</button>
{/* Dropdown */}
{isOpen && (
<div className={css['TokenPicker-dropdown']} role="listbox">
{/* Search */}
<div className={css['TokenPicker-searchRow']}>
<input
ref={searchRef}
type="text"
className={css['TokenPicker-search']}
placeholder="Search tokens..."
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label="Search tokens"
/>
</div>
{/* Groups */}
<div className={css['TokenPicker-list']}>
{totalTokenCount === 0 && <p className={css['TokenPicker-empty']}>No tokens match</p>}
{visibleGroups.map((group) => (
<div key={group.label} className={css['TokenPicker-group']}>
<span className={css['TokenPicker-groupLabel']}>{group.label}</span>
{group.tokens.map((item) => {
const isSelected = item.name === selectedToken;
const swatch =
isColorCategory(item.category) && item.resolvedValue && looksLikeColor(item.resolvedValue)
? item.resolvedValue
: null;
return (
<button
key={item.name}
type="button"
role="option"
aria-selected={isSelected}
className={[
css['TokenPicker-option'],
isSelected ? css['TokenPicker-option--active'] : '',
item.isCustom ? css['TokenPicker-option--custom'] : ''
]
.filter(Boolean)
.join(' ')}
onClick={() => handleSelect(item)}
title={item.description ?? item.resolvedValue ?? item.value}
>
{/* Colour swatch or value preview */}
<span className={css['TokenPicker-optionPreview']} aria-hidden>
{swatch ? (
<span className={css['TokenPicker-optionSwatch']} style={{ background: swatch }} />
) : (
<span className={css['TokenPicker-optionValueBadge']}>
{(item.resolvedValue ?? item.value).slice(0, 4)}
</span>
)}
</span>
<span className={css['TokenPicker-optionBody']}>
<span className={css['TokenPicker-optionName']}>{formatTokenLabel(item.name)}</span>
{item.resolvedValue && (
<span className={css['TokenPicker-optionValue']}>{item.resolvedValue}</span>
)}
</span>
{item.isCustom && (
<span className={css['TokenPicker-customBadge']} title="Custom token" aria-hidden>
</span>
)}
{isSelected && (
<span className={css['TokenPicker-checkmark']} aria-hidden>
</span>
)}
</button>
);
})}
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { TokenPicker } from './TokenPicker';
export type { TokenPickerItem, TokenPickerGroup, TokenPickerProps } from './TokenPicker';

View File

@@ -8,6 +8,7 @@ import { StyleTokenRecord, StyleTokensModel } from '@noodl-models/StyleTokensMod
import { Slot } from '@noodl-core-ui/types/global'; import { Slot } from '@noodl-core-ui/types/global';
import { PreviewTokenInjector } from '../../services/PreviewTokenInjector';
import { DesignTokenColor, extractProjectColors } from './extractProjectColors'; import { DesignTokenColor, extractProjectColors } from './extractProjectColors';
export interface ProjectDesignTokenContext { export interface ProjectDesignTokenContext {
@@ -78,6 +79,11 @@ export function ProjectDesignTokenContextProvider({ children }: ProjectDesignTok
setDesignTokens(styleTokensModel.getTokens()); setDesignTokens(styleTokensModel.getTokens());
}, [styleTokensModel]); }, [styleTokensModel]);
// Wire preview token injector so the preview webview reflects the current token values
useEffect(() => {
PreviewTokenInjector.instance.attachModel(styleTokensModel);
}, [styleTokensModel]);
useEventListener(styleTokensModel, 'tokensChanged', () => { useEventListener(styleTokensModel, 'tokensChanged', () => {
setDesignTokens(styleTokensModel.getTokens()); setDesignTokens(styleTokensModel.getTokens());
}); });

View File

@@ -0,0 +1,110 @@
/**
* STYLE-001 Phase 4: PreviewTokenInjector
*
* Injects design token CSS custom properties into the preview webview so that
* var(--token-name) references in user projects resolve to the correct values.
*
* Architecture:
* - Singleton service, initialised once at app startup
* - CanvasView calls `notifyDomReady(webview)` after each dom-ready event
* - Subscribes to StyleTokensModel 'tokensChanged' and re-injects on every change
* - Uses `executeJavaScript` to insert/update a <style id="noodl-design-tokens"> in the
* preview's <head>. The style element is created on first injection and updated in place
* on subsequent calls, avoiding repeated DOM mutations.
*
* CSS escaping:
* - The CSS block is passed as a JSON-encoded string inside the script so that backticks,
* backslashes, and dollar signs in token values cannot break template literal parsing.
*/
import { StyleTokensModel } from '../models/StyleTokensModel';
const STYLE_ELEMENT_ID = 'noodl-design-tokens';
export class PreviewTokenInjector {
private static _instance: PreviewTokenInjector | null = null;
private _webview: Electron.WebviewTag | null = null;
private _tokensModel: StyleTokensModel | null = null;
private constructor() {}
static get instance(): PreviewTokenInjector {
if (!PreviewTokenInjector._instance) {
PreviewTokenInjector._instance = new PreviewTokenInjector();
}
return PreviewTokenInjector._instance;
}
/**
* Attach a StyleTokensModel instance. Called once when the project loads.
* The injector subscribes to 'tokensChanged' and re-injects whenever tokens change.
*/
attachModel(model: StyleTokensModel): void {
// Detach previous model if any — off(context) removes all listeners bound to `this`
this._tokensModel?.off(this);
this._tokensModel = model;
model.on('tokensChanged', () => this._inject(), this);
}
/**
* Called by CanvasView after each dom-ready event (once the session is valid).
* Stores the webview reference and immediately injects the current tokens.
*/
notifyDomReady(webview: Electron.WebviewTag): void {
this._webview = webview;
this._inject();
}
/**
* Clear webview reference (e.g. when the canvas is destroyed).
*/
clearWebview(): void {
this._webview = null;
}
// ─── Private ─────────────────────────────────────────────────────────────────
private _inject(): void {
if (!this._webview || !this._tokensModel) return;
const css = this._tokensModel.generateCss();
if (!css) return;
// JSON-encode the CSS to safely pass it through executeJavaScript without
// worrying about backticks, backslashes, or $ signs in token values.
const encodedCss = JSON.stringify(css);
const script = `
(function() {
var id = '${STYLE_ELEMENT_ID}';
var el = document.getElementById(id);
if (!el) {
el = document.createElement('style');
el.id = id;
(document.head || document.documentElement).appendChild(el);
}
el.textContent = ${encodedCss};
})();
`;
// executeJavaScript returns a Promise — we intentionally don't await it here
// because injection is best-effort and we don't want to block the caller.
// Errors are swallowed because the webview may navigate away at any time.
this._webview.executeJavaScript(script).catch(() => {
// Webview navigated or was destroyed — no action needed.
});
}
}
/**
* One-time initialisation: wire the injector to the global EventDispatcher so it
* can pick up the StyleTokensModel when a project loads.
*
* Call this from the editor bootstrap (e.g. EditorTopBar or App startup).
*/
export function initPreviewTokenInjector(tokensModel: StyleTokensModel): void {
PreviewTokenInjector.instance.attachModel(tokensModel);
}

View File

@@ -5,6 +5,7 @@ import { platform } from '@noodl/platform';
import { EventDispatcher } from '../../../../shared/utils/EventDispatcher'; import { EventDispatcher } from '../../../../shared/utils/EventDispatcher';
import View from '../../../../shared/view'; import View from '../../../../shared/view';
import { PreviewTokenInjector } from '../../services/PreviewTokenInjector';
import { VisualCanvas } from './VisualCanvas'; import { VisualCanvas } from './VisualCanvas';
export class CanvasView extends View { export class CanvasView extends View {
@@ -108,6 +109,9 @@ export class CanvasView extends View {
this.webview.executeJavaScript(`NoodlEditorHighlightAPI.selectNode('${this.selectedNodeId}')`); this.webview.executeJavaScript(`NoodlEditorHighlightAPI.selectNode('${this.selectedNodeId}')`);
} }
// Inject project design tokens into the preview so var(--token-name) resolves correctly.
PreviewTokenInjector.instance.notifyDomReady(this.webview);
this.updateViewportSize(); this.updateViewportSize();
}); });
@@ -180,6 +184,7 @@ export class CanvasView extends View {
this.root.unmount(); this.root.unmount();
this.root = null; this.root = null;
} }
PreviewTokenInjector.instance.clearWebview();
ipcRenderer.off('editor-api-response', this._onEditorApiResponse); ipcRenderer.off('editor-api-response', this._onEditorApiResponse);
} }
refresh() { refresh() {