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

@@ -8,6 +8,7 @@ import { StyleTokenRecord, StyleTokensModel } from '@noodl-models/StyleTokensMod
import { Slot } from '@noodl-core-ui/types/global';
import { PreviewTokenInjector } from '../../services/PreviewTokenInjector';
import { DesignTokenColor, extractProjectColors } from './extractProjectColors';
export interface ProjectDesignTokenContext {
@@ -78,6 +79,11 @@ export function ProjectDesignTokenContextProvider({ children }: ProjectDesignTok
setDesignTokens(styleTokensModel.getTokens());
}, [styleTokensModel]);
// Wire preview token injector so the preview webview reflects the current token values
useEffect(() => {
PreviewTokenInjector.instance.attachModel(styleTokensModel);
}, [styleTokensModel]);
useEventListener(styleTokensModel, 'tokensChanged', () => {
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 View from '../../../../shared/view';
import { PreviewTokenInjector } from '../../services/PreviewTokenInjector';
import { VisualCanvas } from './VisualCanvas';
export class CanvasView extends View {
@@ -108,6 +109,9 @@ export class CanvasView extends View {
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();
});
@@ -180,6 +184,7 @@ export class CanvasView extends View {
this.root.unmount();
this.root = null;
}
PreviewTokenInjector.instance.clearWebview();
ipcRenderer.off('editor-api-response', this._onEditorApiResponse);
}
refresh() {