diff --git a/packages/noodl-core-ui/src/components/inputs/TokenPicker/TokenPicker.module.scss b/packages/noodl-core-ui/src/components/inputs/TokenPicker/TokenPicker.module.scss new file mode 100644 index 0000000..681a00d --- /dev/null +++ b/packages/noodl-core-ui/src/components/inputs/TokenPicker/TokenPicker.module.scss @@ -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; +} diff --git a/packages/noodl-core-ui/src/components/inputs/TokenPicker/TokenPicker.tsx b/packages/noodl-core-ui/src/components/inputs/TokenPicker/TokenPicker.tsx new file mode 100644 index 0000000..c48ef2c --- /dev/null +++ b/packages/noodl-core-ui/src/components/inputs/TokenPicker/TokenPicker.tsx @@ -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: + * ({ ...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 = { + 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(); + 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(null); + const searchRef = useRef(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(() => { + // 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 ( +
+ {label && {label}} + + {/* Trigger */} + + )} + {!selectedItem && ( + + ▾ + + )} + + + {/* Dropdown */} + {isOpen && ( +
+ {/* Search */} +
+ setSearch(e.target.value)} + aria-label="Search tokens" + /> +
+ + {/* Groups */} +
+ {totalTokenCount === 0 &&

No tokens match

} + {visibleGroups.map((group) => ( +
+ {group.label} + {group.tokens.map((item) => { + const isSelected = item.name === selectedToken; + const swatch = + isColorCategory(item.category) && item.resolvedValue && looksLikeColor(item.resolvedValue) + ? item.resolvedValue + : null; + + return ( + + ); + })} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/packages/noodl-core-ui/src/components/inputs/TokenPicker/index.ts b/packages/noodl-core-ui/src/components/inputs/TokenPicker/index.ts new file mode 100644 index 0000000..4f4b406 --- /dev/null +++ b/packages/noodl-core-ui/src/components/inputs/TokenPicker/index.ts @@ -0,0 +1,2 @@ +export { TokenPicker } from './TokenPicker'; +export type { TokenPickerItem, TokenPickerGroup, TokenPickerProps } from './TokenPicker'; diff --git a/packages/noodl-editor/src/editor/src/contexts/ProjectDesignTokenContext/ProjectDesignTokenContext.tsx b/packages/noodl-editor/src/editor/src/contexts/ProjectDesignTokenContext/ProjectDesignTokenContext.tsx index 5a10ef6..128a7d3 100644 --- a/packages/noodl-editor/src/editor/src/contexts/ProjectDesignTokenContext/ProjectDesignTokenContext.tsx +++ b/packages/noodl-editor/src/editor/src/contexts/ProjectDesignTokenContext/ProjectDesignTokenContext.tsx @@ -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()); }); diff --git a/packages/noodl-editor/src/editor/src/services/PreviewTokenInjector.ts b/packages/noodl-editor/src/editor/src/services/PreviewTokenInjector.ts new file mode 100644 index 0000000..6bc5cbb --- /dev/null +++ b/packages/noodl-editor/src/editor/src/services/PreviewTokenInjector.ts @@ -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