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';