From 533e0b0f1702dad25ceb3b279cf6bd3bbadf19c9 Mon Sep 17 00:00:00 2001 From: rezailmi Date: Fri, 26 Jun 2026 17:40:53 +0800 Subject: [PATCH 1/7] Add design-token / CSS-variable resolution layer (plan 023) Introduce src/utils/design-tokens.ts: a pure, unit-tested layer that collects theme variables from document stylesheets, resolves var() alias chains textually, builds a color-token index, maps Tailwind color/typography utility classes back to their CSS variables, and reverse-maps resolved colors to the best-matching token. Re-export the public API from the src/utils.ts barrel. Claude-Session: https://claude.ai/code/session_01JzitXiMcdedyPbH4wqkXqX --- src/utils.ts | 17 ++ src/utils/design-tokens.test.ts | 253 +++++++++++++++++++++++++++ src/utils/design-tokens.ts | 297 ++++++++++++++++++++++++++++++++ 3 files changed, 567 insertions(+) create mode 100644 src/utils/design-tokens.test.ts create mode 100644 src/utils/design-tokens.ts diff --git a/src/utils.ts b/src/utils.ts index ec1b3b6..611bd1d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -71,6 +71,23 @@ export { import { attributeClassesForProperty } from './utils/tailwind-attribution' export { stylesToTailwind } from './utils/tailwind' import { stylesToTailwind } from './utils/tailwind' +export { + collectThemeVariables, + buildColorTokenIndexFromVariables, + getColorTokenIndex, + invalidateColorTokenIndex, + resolveColorToken, + tokenForColorClass, + tokenForColorValue, + colorClassToVarName, + getTokenAliasChain, + typographyTokenForProperty, + looksLikeColor, + resolveVarChain, + resolveTokenColor, + tokenPreferenceRank, +} from './utils/design-tokens' +export type { ThemeVariable, ColorTokenEntry, ColorTokenIndex } from './utils/design-tokens' export const propertyToCSSMap: Record = { paddingTop: 'padding-top', diff --git a/src/utils/design-tokens.test.ts b/src/utils/design-tokens.test.ts new file mode 100644 index 0000000..9edddbd --- /dev/null +++ b/src/utils/design-tokens.test.ts @@ -0,0 +1,253 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + buildColorTokenIndexFromVariables, + collectThemeVariables, + colorClassToVarName, + looksLikeColor, + resolveTokenColor, + resolveVarChain, + tokenForColorClass, + tokenForColorValue, + typographyTokenForProperty, + type ThemeVariable, +} from './design-tokens' +import { parseColorValue } from './color' + +// ---- collectThemeVariables (DOM-backed) ---- + +describe('collectThemeVariables', () => { + const injected: HTMLStyleElement[] = [] + + function injectStyle(css: string): HTMLStyleElement { + const el = document.createElement('style') + el.textContent = css + document.head.appendChild(el) + injected.push(el) + return el + } + + afterEach(() => { + for (const el of injected.splice(0)) el.remove() + }) + + it('collects --* declarations from :root with scope "base"', () => { + injectStyle(':root{--color-primary:#3B82F6;--color-blue-500:#3B82F6;--brand:rgb(10 20 30)}') + const vars = collectThemeVariables(document) + const byName = new Map(vars.map((v) => [v.name, v])) + expect(byName.get('--color-primary')?.scope).toBe('base') + expect(byName.get('--color-blue-500')?.scope).toBe('base') + expect(byName.get('--brand')?.scope).toBe('base') + expect(byName.get('--color-primary')?.rawValue).toBe('#3B82F6') + }) + + it('marks root-like dark declarations with scope "dark"', () => { + // The Step 1 ROOT_SELECTOR_RE only treats :root / :host(...) / html segments + // as root-like, so dark scope is detected when the dark hint co-occurs with a + // root-like selector (here :host([data-theme="dark"]) — the Shadow-DOM form). + injectStyle(':host([data-theme="dark"]){--color-primary:#111}') + const vars = collectThemeVariables(document) + const entry = vars.find((v) => v.name === '--color-primary' && v.rawValue === '#111') + expect(entry?.scope).toBe('dark') + }) + + it('skips a bare .dark{} block (not root-like under ROOT_SELECTOR_RE)', () => { + // Documents the actual behavior of the verbatim Step 1 code: a standalone + // `.dark` selector is not root-like, so its declarations are not collected. + injectStyle('.dark{--only-in-dark:#abcdef}') + const vars = collectThemeVariables(document) + expect(vars.find((v) => v.name === '--only-in-dark')).toBeUndefined() + }) + + it('marks prefers-color-scheme: dark media declarations as "dark"', () => { + injectStyle('@media (prefers-color-scheme: dark){:root{--color-primary:#222}}') + const vars = collectThemeVariables(document) + const entry = vars.find((v) => v.name === '--color-primary' && v.rawValue === '#222') + expect(entry?.scope).toBe('dark') + }) + + it('does NOT collect --* from non-root selectors', () => { + injectStyle('.btn{--x:#fff}') + const vars = collectThemeVariables(document) + expect(vars.find((v) => v.name === '--x')).toBeUndefined() + }) +}) + +// ---- resolveVarChain (pure) ---- + +describe('resolveVarChain', () => { + it('walks an alias chain to the terminal literal', () => { + const byName = new Map([ + ['--color-primary', 'var(--color-blue-500)'], + ['--color-blue-500', '#3B82F6'], + ]) + expect(resolveVarChain('--color-primary', byName)).toEqual([ + '--color-primary', + '--color-blue-500', + '#3B82F6', + ]) + }) + + it('terminates on cycles without hanging', () => { + const byName = new Map([ + ['--a', 'var(--b)'], + ['--b', 'var(--a)'], + ]) + const chain = resolveVarChain('--a', byName) + expect(chain).toEqual(['--a', '--b']) + }) + + it('returns [name] when the alias target is missing', () => { + const byName = new Map([['--a', 'var(--missing)']]) + expect(resolveVarChain('--a', byName)).toEqual(['--a', '--missing']) + }) +}) + +// ---- resolveTokenColor (pure) ---- + +describe('resolveTokenColor', () => { + it('parses a hex terminal to a ColorValue', () => { + const byName = new Map([['--color-primary', '#3B82F6']]) + expect(resolveTokenColor('--color-primary', byName)).toEqual(parseColorValue('#3B82F6')) + }) + + it('parses an rgb() terminal to a ColorValue', () => { + const byName = new Map([['--brand', 'rgb(10 20 30)']]) + const result = resolveTokenColor('--brand', byName) + expect(result?.hex).toBe('0A141E') + expect(result?.alpha).toBe(100) + }) + + it('returns null for a channel fragment (fails looksLikeColor)', () => { + const byName = new Map([['--background', '0 0% 100%']]) + expect(resolveTokenColor('--background', byName)).toBeNull() + }) + + it('returns null when the alias chain ends at a missing token', () => { + const byName = new Map([['--a', 'var(--missing)']]) + expect(resolveTokenColor('--a', byName)).toBeNull() + }) +}) + +// ---- looksLikeColor (pure) ---- + +describe('looksLikeColor', () => { + it('accepts hex, color functions, and named colors', () => { + expect(looksLikeColor('#3B82F6')).toBe(true) + expect(looksLikeColor('rgb(1 2 3)')).toBe(true) + expect(looksLikeColor('oklch(0.5 0.1 200)')).toBe(true) + expect(looksLikeColor('rebeccapurple')).toBe(true) + expect(looksLikeColor('transparent')).toBe(true) + }) + + it('rejects empty, none, and channel fragments', () => { + expect(looksLikeColor('')).toBe(false) + expect(looksLikeColor('none')).toBe(false) + expect(looksLikeColor('0 0% 100%')).toBe(false) + }) +}) + +// ---- buildColorTokenIndexFromVariables (pure) ---- + +describe('buildColorTokenIndexFromVariables', () => { + function base(name: string, rawValue: string): ThemeVariable { + return { name, rawValue, scope: 'base', selector: ':root' } + } + + it('excludes non-color tokens from byName', () => { + const index = buildColorTokenIndexFromVariables([ + base('--color-primary', '#3B82F6'), + base('--spacing', '4px'), + base('--background', '0 0% 100%'), + ]) + expect(index.byName.has('--color-primary')).toBe(true) + expect(index.byName.has('--spacing')).toBe(false) + expect(index.byName.has('--background')).toBe(false) + }) + + it('reverse lookup ranks semantic tokens before palette tokens', () => { + const index = buildColorTokenIndexFromVariables([ + base('--color-blue-500', '#3B82F6'), + base('--color-primary', '#3B82F6'), + ]) + const list = index.byColor.get('3B82F6:100') + expect(list?.[0]).toBe('--color-primary') + expect(list).toContain('--color-blue-500') + }) + + it('resolves alias chains when building the index', () => { + const index = buildColorTokenIndexFromVariables([ + base('--color-primary', 'var(--color-blue-500)'), + base('--color-blue-500', '#3B82F6'), + ]) + expect(index.byName.get('--color-primary')?.value).toEqual(parseColorValue('#3B82F6')) + }) +}) + +// ---- colorClassToVarName (pure) ---- + +describe('colorClassToVarName', () => { + it('maps semantic and palette classes', () => { + expect(colorClassToVarName('bg-primary')).toBe('--color-primary') + expect(colorClassToVarName('text-foreground')).toBe('--color-foreground') + expect(colorClassToVarName('bg-blue-500')).toBe('--color-blue-500') + }) + + it('strips the opacity modifier', () => { + expect(colorClassToVarName('bg-primary/50')).toBe('--color-primary') + }) + + it('maps arbitrary var() values and rejects arbitrary literals', () => { + expect(colorClassToVarName('bg-[var(--brand)]')).toBe('--brand') + expect(colorClassToVarName('bg-[#fff]')).toBeNull() + }) +}) + +// ---- tokenForColorClass (pure, index-backed) ---- + +describe('tokenForColorClass', () => { + const index = buildColorTokenIndexFromVariables([ + { name: '--color-primary', rawValue: '#3B82F6', scope: 'base', selector: ':root' }, + ]) + + it('returns the bound token for the matching property', () => { + expect(tokenForColorClass('background-color', ['bg-primary', 'flex'], index)).toBe('--color-primary') + }) + + it('returns null when no class governs the property', () => { + expect(tokenForColorClass('color', ['bg-primary', 'flex'], index)).toBeNull() + }) +}) + +// ---- tokenForColorValue (pure, index-backed) ---- + +describe('tokenForColorValue', () => { + const index = buildColorTokenIndexFromVariables([ + { name: '--color-primary', rawValue: '#3B82F6', scope: 'base', selector: ':root' }, + ]) + + it('returns the token whose value matches', () => { + expect(tokenForColorValue(parseColorValue('#3B82F6'), index)).toBe('--color-primary') + }) + + it('returns null for an unmatched value', () => { + expect(tokenForColorValue(parseColorValue('#000000'), index)).toBeNull() + }) +}) + +// ---- typographyTokenForProperty (pure) ---- + +describe('typographyTokenForProperty', () => { + it('returns the matched utility class per property', () => { + expect(typographyTokenForProperty('font-size', ['text-base', 'font-semibold'])).toBe('text-base') + expect(typographyTokenForProperty('font-weight', ['text-base', 'font-semibold'])).toBe('font-semibold') + expect(typographyTokenForProperty('line-height', ['leading-7'])).toBe('leading-7') + }) + + it('attributes arbitrary values', () => { + expect(typographyTokenForProperty('font-size', ['text-[17px]'])).toBe('text-[17px]') + }) + + it('returns null when nothing matches', () => { + expect(typographyTokenForProperty('font-size', [])).toBeNull() + }) +}) diff --git a/src/utils/design-tokens.ts b/src/utils/design-tokens.ts new file mode 100644 index 0000000..222537d --- /dev/null +++ b/src/utils/design-tokens.ts @@ -0,0 +1,297 @@ +import type { ColorValue } from '../types' +import { parseColorValue } from './color' +import { attributeClassesForProperty } from './tailwind-attribution' + +export interface ThemeVariable { + /** Custom property name including the leading dashes, e.g. '--color-primary'. */ + name: string + /** Declared value as written, e.g. 'var(--color-blue-500)' or 'oklch(...)' or '#3B82F6'. */ + rawValue: string + /** Which color scheme the declaration targets. */ + scope: 'base' | 'dark' + /** The selector text of the rule it came from (for debugging). */ + selector: string +} + +// Selectors we treat as "root-like" — where global design tokens live. +const ROOT_SELECTOR_RE = /(^|,)\s*(:root|:host(\([^)]*\))?|html)\s*(,|$)/i +// Dark-scope hints in a selector (shadcn `.dark`, data-theme, prefers-color-scheme handled via media). +const DARK_SELECTOR_RE = /\.dark\b|\[data-theme=["']?dark["']?\]|:host\(\[data-theme=["']?dark["']?\]\)/i + +function scopeForSelector(selector: string, inDarkMedia: boolean): 'base' | 'dark' { + if (inDarkMedia) return 'dark' + return DARK_SELECTOR_RE.test(selector) ? 'dark' : 'base' +} + +function collectFromRuleList( + rules: CSSRuleList, + out: ThemeVariable[], + inDarkMedia: boolean, +): void { + for (const rule of Array.from(rules)) { + // Style rule: read --* declarations if its selector is root-like. + if (rule instanceof CSSStyleRule) { + if (!ROOT_SELECTOR_RE.test(rule.selectorText)) continue + const scope = scopeForSelector(rule.selectorText, inDarkMedia) + const style = rule.style + for (let i = 0; i < style.length; i++) { + const prop = style.item(i) + if (!prop.startsWith('--')) continue + out.push({ + name: prop, + rawValue: style.getPropertyValue(prop).trim(), + scope, + selector: rule.selectorText, + }) + } + continue + } + // @media — descend; flag dark when the query mentions prefers-color-scheme: dark. + if (rule instanceof CSSMediaRule) { + const isDark = /prefers-color-scheme\s*:\s*dark/i.test(rule.conditionText || rule.media.mediaText) + collectFromRuleList(rule.cssRules, out, inDarkMedia || isDark) + continue + } + // @layer (Tailwind v4 wraps theme in `@layer theme`) and @supports — descend. + // Use duck-typing for CSSLayerBlockRule (not in all TS lib versions). + const maybeGrouping = rule as CSSRule & { cssRules?: CSSRuleList } + if (maybeGrouping.cssRules && typeof maybeGrouping.cssRules.length === 'number') { + collectFromRuleList(maybeGrouping.cssRules, out, inDarkMedia) + } + } +} + +export function collectThemeVariables(doc: Document = document): ThemeVariable[] { + const out: ThemeVariable[] = [] + for (const sheet of Array.from(doc.styleSheets)) { + let rules: CSSRuleList + try { + rules = sheet.cssRules // throws for cross-origin sheets + } catch { + continue + } + collectFromRuleList(rules, out, false) + } + return out +} + +/** True for terminal values that name a CSS color (not a channel fragment like '0 0% 100%'). */ +export function looksLikeColor(value: string): boolean { + const v = value.trim().toLowerCase() + if (!v) return false + if (v.startsWith('#')) return true + if (/^(rgb|rgba|hsl|hsla|oklch|oklab|lab|lch|color|hwb)\s*\(/.test(v)) return true + // Named colors and keywords we accept; reject bare numbers/channels. + if (/^[a-z]+$/.test(v) && v !== 'none' && v !== 'transparent') return true + if (v === 'transparent') return true + return false +} + +const VAR_REF_RE = /^var\(\s*(--[\w-]+)\s*(?:,\s*(.*))?\)$/ + +/** + * Walk the var() alias chain for `name` textually, returning the ordered list of + * token names visited and the terminal literal value (last element is the literal, + * earlier elements are '--name' tokens). Returns [] if name is unknown. + * Guards against cycles. + */ +export function resolveVarChain(name: string, byName: Map): string[] { + const chain: string[] = [] + const seen = new Set() + let current: string | null = name + while (current && current.startsWith('--')) { + if (seen.has(current)) break // cycle guard + seen.add(current) + chain.push(current) + const raw = byName.get(current) + if (raw == null) break + const m = raw.trim().match(VAR_REF_RE) + if (m) { + current = m[1] // follow the alias + } else { + chain.push(raw.trim()) // terminal literal + current = null + } + } + return chain +} + +/** Resolve a token to a ColorValue via textual chain walk + parseColorValue. Null if not a color. */ +export function resolveTokenColor(name: string, byName: Map): ColorValue | null { + const chain = resolveVarChain(name, byName) + if (chain.length === 0) return null + const terminal = chain[chain.length - 1] + if (terminal.startsWith('--')) return null // unresolved alias (target missing) + if (!looksLikeColor(terminal)) return null + const parsed = parseColorValue(terminal) + // parseColorValue never throws but returns black for unparseable input; the + // looksLikeColor guard above already filters channel fragments. + return parsed +} + +export interface ColorTokenEntry { + name: string // '--color-primary' + value: ColorValue // resolved terminal color + rawValue: string // declared value (may be a var alias) + scope: 'base' | 'dark' +} + +export interface ColorTokenIndex { + byName: Map + /** 'HEX:ALPHA' -> token names, best (most semantic) first. */ + byColor: Map +} + +function colorKey(c: ColorValue): string { + return `${c.hex}:${c.alpha}` +} + +// Lower rank sorts first in reverse lookup. Semantic names beat palette names +// (e.g. --color-primary before --color-blue-500), shorter beats longer. +const PALETTE_RE = /^--color-[a-z]+-\d+$/ +export function tokenPreferenceRank(name: string): number { + if (name.startsWith('--color-') && !PALETTE_RE.test(name)) return 0 + if (PALETTE_RE.test(name)) return 1 + if (name.startsWith('--color-')) return 2 + return 3 +} + +export function buildColorTokenIndexFromVariables(vars: ThemeVariable[]): ColorTokenIndex { + const byNameRaw = new Map() + // Base scope wins for the default index; last declaration wins on duplicates. + for (const v of vars) { + if (v.scope === 'base') byNameRaw.set(v.name, v.rawValue) + } + // If a token only exists in dark scope, still index it. + for (const v of vars) { + if (!byNameRaw.has(v.name)) byNameRaw.set(v.name, v.rawValue) + } + + const byName = new Map() + for (const [name, rawValue] of byNameRaw) { + const value = resolveTokenColor(name, byNameRaw) + if (!value) continue + const scope = vars.find((v) => v.name === name)?.scope ?? 'base' + byName.set(name, { name, value, rawValue, scope }) + } + + const byColor = new Map() + for (const entry of byName.values()) { + const key = colorKey(entry.value) + const list = byColor.get(key) ?? [] + list.push(entry.name) + byColor.set(key, list) + } + for (const [key, list] of byColor) { + list.sort((a, b) => tokenPreferenceRank(a) - tokenPreferenceRank(b) || a.length - b.length) + byColor.set(key, list) + } + + return { byName, byColor } +} + +// ---- Cached live entry point (browser) ---- +let cachedIndex: ColorTokenIndex | null = null + +export function getColorTokenIndex(doc: Document = document): ColorTokenIndex { + if (cachedIndex) return cachedIndex + cachedIndex = buildColorTokenIndexFromVariables(collectThemeVariables(doc)) + return cachedIndex +} + +export function invalidateColorTokenIndex(): void { + cachedIndex = null +} + +// Utility prefixes whose argument is a color token name. +const COLOR_CLASS_PREFIXES = ['bg', 'text', 'border', 'outline', 'ring', 'fill', 'stroke', 'from', 'via', 'to', 'decoration', 'divide', 'accent', 'caret', 'shadow'] + +/** + * Map a single Tailwind color class to its CSS variable name. + * bg-primary -> --color-primary + * text-foreground -> --color-foreground + * bg-blue-500 -> --color-blue-500 + * bg-primary/50 -> --color-primary (opacity modifier stripped) + * bg-[var(--brand)] -> --brand + * bg-[#fff] -> null (arbitrary literal, no token) + * Returns null for classes that don't carry a token. + */ +export function colorClassToVarName(cls: string): string | null { + // strip leading '!' important and any variant prefixes (handled by caller, but be safe) + const base = cls.replace(/^!/, '') + // strip opacity modifier: split on '/' that is not inside brackets + const slash = base.indexOf('/') + const noOpacity = slash > -1 && !base.includes('[') ? base.slice(0, slash) : base + + const dash = noOpacity.indexOf('-') + if (dash === -1) return null + const prefix = noOpacity.slice(0, dash) + if (!COLOR_CLASS_PREFIXES.includes(prefix)) return null + const rest = noOpacity.slice(dash + 1) + + // Arbitrary value: bg-[var(--brand)] or bg-[#fff] + if (rest.startsWith('[') && rest.endsWith(']')) { + const inner = rest.slice(1, -1) + const m = inner.match(/^var\(\s*(--[\w-]+)\s*(?:,.*)?\)$/) + return m ? m[1] : null + } + if (!/^[a-z]/.test(rest)) return null + return `--color-${rest}` +} + +/** + * Given a CSS property + the element's class list, return the bound color token + * name, verified to exist in `index`. Uses class attribution to pick the right class. + */ +export function tokenForColorClass( + cssProperty: string, + classList: string[], + index: ColorTokenIndex, +): string | null { + const { matchedClasses } = attributeClassesForProperty(cssProperty, classList) + for (const cls of matchedClasses) { + const varName = colorClassToVarName(cls) + if (varName && index.byName.has(varName)) return varName + } + return null +} + +/** Reverse-map a resolved color to the best matching token name (fallback path). */ +export function tokenForColorValue(value: ColorValue, index: ColorTokenIndex): string | null { + const list = index.byColor.get(`${value.hex}:${value.alpha}`) + return list && list.length > 0 ? list[0] : null +} + +/** + * Resolve a color token for display: prefer the class binding, fall back to value. + * Returns the var name or null. + */ +export function resolveColorToken( + cssProperty: string, + classList: string[], + value: ColorValue, + index: ColorTokenIndex = getColorTokenIndex(), +): string | null { + return tokenForColorClass(cssProperty, classList, index) ?? tokenForColorValue(value, index) +} + +/** Ordered alias chain for the popover, e.g. ['--color-primary','--color-blue-500','#3B82F6']. */ +export function getTokenAliasChain(name: string, doc: Document = document): string[] { + const vars = collectThemeVariables(doc) + const byName = new Map() + for (const v of vars) if (v.scope === 'base' || !byName.has(v.name)) byName.set(v.name, v.rawValue) + return resolveVarChain(name, byName) +} + +/** + * Return the Tailwind utility class on the element that sets `cssProperty`, or null. + * font-size -> 'text-base' | 'text-lg' | null + * font-weight -> 'font-semibold' | null + * line-height -> 'leading-7' | null + * letter-spacing -> 'tracking-tight' | null + * Only returns base (non-variant) classes — variant matches are ignored here. + */ +export function typographyTokenForProperty(cssProperty: string, classList: string[]): string | null { + const { matchedClasses } = attributeClassesForProperty(cssProperty, classList) + return matchedClasses.length > 0 ? matchedClasses[0] : null +} From 49c22d185ab19b254d23f62d5f0a1df9a5010a5f Mon Sep 17 00:00:00 2001 From: rezailmi Date: Fri, 26 Jun 2026 17:51:12 +0800 Subject: [PATCH 2/7] Show bound CSS variable on color rows via a token chip (plan 024) Add a shared TokenChip pill (shadow-DOM-safe base-ui popover) and make the inspector's color rows token-aware. ColorInput now shows the bound CSS variable (resolved via plan 023's resolveColorToken) with the hex/alpha editors moved into a detail popover; rows fall back to the existing hex/alpha inputs when no token binds. Thread elementInfo.classList into the fill, background, and border sections and invalidate the color-token index on selection change. Claude-Session: https://claude.ai/code/session_01JzitXiMcdedyPbH4wqkXqX --- src/panel.tsx | 10 ++ src/panel/border-section.tsx | 16 +++- src/panel/fill-section.test.tsx | 67 ++++++++++++- src/panel/fill-section.tsx | 164 ++++++++++++++++++++++++-------- src/panel/token-chip.test.tsx | 41 ++++++++ src/panel/token-chip.tsx | 63 ++++++++++++ 6 files changed, 317 insertions(+), 44 deletions(-) create mode 100644 src/panel/token-chip.test.tsx create mode 100644 src/panel/token-chip.tsx diff --git a/src/panel.tsx b/src/panel.tsx index 9effdda..c330e3b 100644 --- a/src/panel.tsx +++ b/src/panel.tsx @@ -36,6 +36,7 @@ import { getSelectionColors, parseColorValue, getElementDisplayName, + invalidateColorTokenIndex, } from './utils' import { InteractionOverlay } from './panel/interaction-overlay' import { SelectedCommentComposer } from './panel/selected-comment-composer' @@ -272,6 +273,12 @@ export function DirectEditPanelInner({ ) const { scrollRef, activeSection } = useSectionNav(sectionRefs) + // Re-read the host app's design tokens when the selected element changes, so a + // stale module-level color-token index never sticks (e.g. after a theme toggle). + React.useEffect(() => { + invalidateColorTokenIndex() + }, [elementInfo]) + return (
)} @@ -368,6 +376,7 @@ export function DirectEditPanelInner({ onOutlineColorChange={(value) => onUpdateColor('outlineColor', value)} onSetCSS={onSetCSS} pendingStyles={pendingStyles} + classList={elementInfo.classList} /> @@ -415,6 +424,7 @@ export function DirectEditPanelInner({ hasTextContent={elementInfo.isTextElement} showBorderColor={computedColor.borderColor.alpha > 0} showOutlineColor={computedColor.outlineColor.alpha > 0} + classList={elementInfo.classList} /> diff --git a/src/panel/border-section.tsx b/src/panel/border-section.tsx index 9be1dbd..b143bc6 100644 --- a/src/panel/border-section.tsx +++ b/src/panel/border-section.tsx @@ -10,6 +10,7 @@ import { ColorPickerGroup } from '../ui/color-picker' import { NumberInput, Tip, CollapsibleSection } from './shared' import { ColorInput } from './fill-section' import { Button } from '../ui/button' +import { getColorTokenIndex, resolveColorToken, getTokenAliasChain } from '../utils/design-tokens' import { ChevronDown, Square, @@ -85,9 +86,10 @@ interface BorderInputsProps { onPositionChange: (position: BorderPosition) => void outlineStyle?: BorderStyle outlineWidth?: number + classList?: string[] } -export function BorderInputs({ border, borderColor, outlineColor, onChange, onBatchChange, onBorderColorChange, onOutlineColorChange, onSetCSS, borderPosition, borderStyleControlPreference, onPositionChange, outlineStyle, outlineWidth }: BorderInputsProps) { +export function BorderInputs({ border, borderColor, outlineColor, onChange, onBatchChange, onBorderColorChange, onOutlineColorChange, onSetCSS, borderPosition, borderStyleControlPreference, onPositionChange, outlineStyle, outlineWidth, classList }: BorderInputsProps) { const [selectedSide, setSelectedSide] = React.useState('All') const isOutline = borderPosition === 'outline' @@ -189,6 +191,12 @@ export function BorderInputs({ border, borderColor, outlineColor, onChange, onBa const activeColor = isOutline ? outlineColor : borderColor const activeColorChange = isOutline ? onOutlineColorChange : onBorderColorChange + const activeProperty = isOutline ? 'outline-color' : 'border-color' + const activeToken = + classList && activeColor + ? resolveColorToken(activeProperty, classList, activeColor, getColorTokenIndex()) + : null + const activeAliasChain = activeToken ? getTokenAliasChain(activeToken) : undefined const currentStyleLabel = BORDER_STYLE_OPTIONS.find((o) => o.value === currentStyle)?.label ?? currentStyle return ( @@ -299,6 +307,8 @@ export function BorderInputs({ border, borderColor, outlineColor, onChange, onBa id={isOutline ? 'outline-color' : 'border-color'} value={activeColor} onChange={activeColorChange} + token={activeToken} + aliasChain={activeAliasChain} /> {borderStyleControlPreference === 'icon' &&
} @@ -320,9 +330,10 @@ interface BorderSectionProps { onOutlineColorChange?: (value: ColorValue) => void onSetCSS?: (properties: Record) => void pendingStyles?: Record + classList?: string[] } -export function BorderSection({ border, borderColor, outlineColor, borderStyleControlPreference, onChange, onBatchChange, onBorderColorChange, onOutlineColorChange, onSetCSS, pendingStyles }: BorderSectionProps) { +export function BorderSection({ border, borderColor, outlineColor, borderStyleControlPreference, onChange, onBatchChange, onBorderColorChange, onOutlineColorChange, onSetCSS, pendingStyles, classList }: BorderSectionProps) { // Auto-detect initial position from pending styles const hasOutlinePending = Boolean( pendingStyles?.['outline-style'] || pendingStyles?.['outline-width'] @@ -460,6 +471,7 @@ export function BorderSection({ border, borderColor, outlineColor, borderStyleCo onPositionChange={handlePositionChange} outlineStyle={outlineStyleValue} outlineWidth={outlineWidthValue} + classList={classList} /> ) : null} diff --git a/src/panel/fill-section.test.tsx b/src/panel/fill-section.test.tsx index bd4f5f0..373786e 100644 --- a/src/panel/fill-section.test.tsx +++ b/src/panel/fill-section.test.tsx @@ -1,7 +1,8 @@ import * as React from 'react' import { act, cleanup, render, fireEvent } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { BackgroundFillSection } from './fill-section' +import { BackgroundFillSection, ColorInput, FillSection } from './fill-section' +import { invalidateColorTokenIndex } from '../utils/design-tokens' import type { ColorValue } from '../types' vi.mock('../ui/tooltip', () => ({ @@ -134,3 +135,67 @@ describe('BackgroundFillSection', () => { expect(container.querySelectorAll('input[type="text"]').length).toBe(2) }) }) + +describe('ColorInput token awareness', () => { + const visibleColor: ColorValue = { hex: 'FF0000', alpha: 100, raw: '#FF0000' } + + it('with no token renders the hex input (today behavior)', () => { + const { container } = render() + expect(container.querySelector('input[type="text"]')).not.toBeNull() + }) + + it('with a token renders the chip label and not the inline hex input', () => { + const { container } = render( + , + ) + expect(container.textContent).toContain('--color-primary') + // The hex/alpha inputs move into the (closed) popover, so they are not in the row. + expect(container.querySelector('input')).toBeNull() + }) +}) + +describe('FillSection token resolution', () => { + let injectedStyle: HTMLStyleElement | null = null + + afterEach(() => { + injectedStyle?.remove() + injectedStyle = null + invalidateColorTokenIndex() + }) + + function injectThemeStyle(css: string) { + const el = document.createElement('style') + el.textContent = css + document.head.appendChild(el) + injectedStyle = el + invalidateColorTokenIndex() + } + + it('renders a token chip when the text color is bound to a theme variable', () => { + injectThemeStyle(':root{--color-foreground:#FAFAFA}') + const foreground: ColorValue = { hex: 'FAFAFA', alpha: 100, raw: '#FAFAFA' } + const { container } = render( + , + ) + expect(container.textContent).toContain('--color-foreground') + }) + + it('falls back to the plain hex input when no token matches', () => { + const orphan: ColorValue = { hex: '123456', alpha: 100, raw: '#123456' } + const { container } = render( + , + ) + expect(container.querySelector('input[type="text"]')).not.toBeNull() + expect(container.textContent).not.toContain('--color-') + }) +}) diff --git a/src/panel/fill-section.tsx b/src/panel/fill-section.tsx index bd254aa..55d05b4 100644 --- a/src/panel/fill-section.tsx +++ b/src/panel/fill-section.tsx @@ -6,6 +6,12 @@ import { parseFillLayers, serializeFillLayers } from '../fill-utils' import { CollapsibleSection, Tip, useEchoGuardedInput } from './shared' import { Button } from '../ui/button' import { LocateFixed, Plus, Minus } from 'lucide-react' +import { TokenChip } from './token-chip' +import { + getColorTokenIndex, + resolveColorToken, + getTokenAliasChain, +} from '../utils/design-tokens' const MAX_LAYER_COUNT = 16 @@ -14,9 +20,13 @@ interface ColorInputProps { value: ColorValue onChange: (value: ColorValue) => void className?: string + /** Bound CSS variable name (e.g. '--color-primary'); when set, shows a token chip. */ + token?: string | null + /** Ordered alias chain for the popover, from getTokenAliasChain(). */ + aliasChain?: string[] } -export function ColorInput({ id, value, onChange, className }: ColorInputProps) { +export function ColorInput({ id, value, onChange, className, token, aliasChain }: ColorInputProps) { const hex = useEchoGuardedInput(value.hex) const alpha = useEchoGuardedInput(value.alpha.toString()) @@ -48,46 +58,81 @@ export function ColorInput({ id, value, onChange, className }: ColorInputProps) } } - return ( -
-
- {/* Color swatch with popover picker */} -
- -
- -
+ const hexAlphaInputs = ( + <> + {/* Hex input */} + handleHexChange(e.target.value)} + onFocus={hex.handleFocus} + onBlur={hex.handleBlur} + className="h-full w-[68px] bg-transparent px-2 font-mono text-xs uppercase outline-none" + maxLength={6} + placeholder="FFFFFF" + /> - {/* Hex input */} - handleHexChange(e.target.value)} - onFocus={hex.handleFocus} - onBlur={hex.handleBlur} - className="h-full w-[68px] bg-transparent px-2 font-mono text-xs uppercase outline-none" - maxLength={6} - placeholder="FFFFFF" - /> + {/* Separator */} + / + + {/* Opacity input */} + handleAlphaChange(e.target.value)} + onFocus={alpha.handleFocus} + onBlur={alpha.handleBlur} + className="h-full w-10 bg-transparent px-1 text-center text-xs tabular-nums outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [appearance:textfield]" + min={0} + max={100} + /> + % + + ) - {/* Separator */} - / + // No bound token: render exactly today's row. + if (!token) { + return ( +
+
+ {/* Color swatch with popover picker */} +
+ +
+ +
+ {hexAlphaInputs} +
+
+ ) + } - {/* Opacity input */} - handleAlphaChange(e.target.value)} - onFocus={alpha.handleFocus} - onBlur={alpha.handleBlur} - className="h-full w-10 bg-transparent px-1 text-center text-xs tabular-nums outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none [appearance:textfield]" - min={0} - max={100} - /> - % + // Token mode: swatch still opens the HSV picker; chip shows the bound variable + // and opens a popover with the alias chain + the same hex/alpha editors. + return ( +
+
+ +
+ + +
+ {aliasChain && aliasChain.length > 1 && ( +
+ {aliasChain.join(' → ')} +
+ )} +
+ {hexAlphaInputs} +
+
+
) @@ -106,6 +151,7 @@ interface FillSectionProps { hasTextContent: boolean showBorderColor?: boolean showOutlineColor?: boolean + classList?: string[] } export function FillSection({ @@ -121,9 +167,18 @@ export function FillSection({ hasTextContent, showBorderColor, showOutlineColor, + classList, }: FillSectionProps) { const showDetectedColorInputs = selectionColors.length > 0 && onSelectionColorChange + const index = getColorTokenIndex() + const tokenFor = (cssProperty: string, value: ColorValue) => { + const name = classList ? resolveColorToken(cssProperty, classList, value, index) : null + return name + ? { token: name, aliasChain: getTokenAliasChain(name) } + : { token: null, aliasChain: undefined } + } + return (
@@ -140,6 +195,7 @@ export function FillSection({ id={`selection-color-${index}`} value={color} onChange={(next) => onSelectionColorChange?.(color, next)} + {...tokenFor('', color)} />
{onSelectionColorTarget && ( @@ -161,15 +217,30 @@ export function FillSection({ )} {!showDetectedColorInputs && hasTextContent && ( - + )} {!showDetectedColorInputs && showBorderColor && borderColor && onBorderColorChange && ( - + )} {!showDetectedColorInputs && showOutlineColor && outlineColor && onOutlineColorChange && ( - + )}
@@ -183,6 +254,7 @@ interface BackgroundFillSectionProps { onSetCSS?: (properties: Record) => void onCommitFillLayers?: (layers: ColorValue[]) => void pendingStyles: Record + classList?: string[] } export function BackgroundFillSection({ @@ -190,6 +262,7 @@ export function BackgroundFillSection({ onSetCSS, onCommitFillLayers, pendingStyles, + classList, }: BackgroundFillSectionProps) { const effectiveBgColor = pendingStyles['background-color'] ?? backgroundColor.raw const effectiveBgShorthand = pendingStyles['background'] ?? '' @@ -224,6 +297,13 @@ export function BackgroundFillSection({ const atLayerCap = layers.length >= MAX_LAYER_COUNT + // Only a single solid fill layer maps cleanly to one background-color token. + const singleLayerToken = + classList && layers.length === 1 + ? resolveColorToken('background-color', classList, layers[0], getColorTokenIndex()) + : null + const singleLayerAliasChain = singleLayerToken ? getTokenAliasChain(singleLayerToken) : undefined + const addLayer = () => { if (atLayerCap) return commitLayers([...layers, DEFAULT_FILL]) @@ -269,6 +349,8 @@ export function BackgroundFillSection({ id={`fill-bg-${index}`} value={layer} onChange={(next) => updateLayer(index, next)} + token={layers.length === 1 ? singleLayerToken : null} + aliasChain={layers.length === 1 ? singleLayerAliasChain : undefined} />
diff --git a/src/panel/token-chip.test.tsx b/src/panel/token-chip.test.tsx new file mode 100644 index 0000000..bbd59c8 --- /dev/null +++ b/src/panel/token-chip.test.tsx @@ -0,0 +1,41 @@ +import * as React from 'react' +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { TokenChip } from './token-chip' + +afterEach(cleanup) + +describe('TokenChip', () => { + it('renders the label text', () => { + const { container } = render() + expect(container.textContent).toContain('--color-primary') + }) + + it('renders a swatch when swatchHex is set, and none when omitted', () => { + const withSwatch = render() + expect(withSwatch.container.querySelector('span[class*="size-3.5"]')).not.toBeNull() + cleanup() + + const withoutSwatch = render() + expect(withoutSwatch.container.querySelector('span[class*="size-3.5"]')).toBeNull() + }) + + it('does not render the popover body at rest (closed)', () => { + // The popover body only mounts when the chip is open. Interactive open is + // validated manually in the dev app — base-ui's pointer-driven popover does + // not open under jsdom's synthetic events (the repo mocks it elsewhere). + render( + +
POPOVER_BODY_CONTENT
+
, + ) + expect(document.body.textContent).not.toContain('POPOVER_BODY_CONTENT') + }) + + it('renders the trigger as a button', () => { + const { container } = render() + const button = container.querySelector('button') + expect(button).not.toBeNull() + expect(button?.textContent).toContain('text-base') + }) +}) diff --git a/src/panel/token-chip.tsx b/src/panel/token-chip.tsx new file mode 100644 index 0000000..176cb75 --- /dev/null +++ b/src/panel/token-chip.tsx @@ -0,0 +1,63 @@ +import * as React from 'react' +import { Popover } from '@base-ui/react/popover' +import { ChevronDown } from 'lucide-react' +import { usePortalContainer } from '../portal-container' +import { useOutsideClickDismiss } from '../hooks/use-outside-click-dismiss' +import { cn } from '../cn' + +function TokenChipPortal(props: React.ComponentPropsWithoutRef) { + const container = usePortalContainer() + return +} + +export interface TokenChipProps { + /** Text shown in the pill, e.g. '--color-primary' or 'text-base'. */ + label: string + /** Optional swatch hex (6 chars, no #). Render a swatch when present. */ + swatchHex?: string + /** Popover body — chain, resolved value, editable inputs, etc. */ + children?: React.ReactNode + className?: string +} + +export function TokenChip({ label, swatchHex, children, className }: TokenChipProps) { + const [open, setOpen] = React.useState(false) + const popupRef = React.useRef(null) + const triggerRef = React.useRef(null) + + useOutsideClickDismiss(open, () => setOpen(false), [popupRef, triggerRef]) + + return ( + + } + className={cn( + 'flex h-7 min-w-0 flex-1 items-center gap-1.5 rounded-md border-0 bg-muted px-2 text-xs text-foreground hover:bg-muted-foreground/10 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', + className, + )} + > + {swatchHex && ( + + )} + {label} + + + {children != null && ( + + + + {children} + + + + )} + + ) +} From 0db6e73fa5c32094c99c992325aa6ecabc73c139 Mon Sep 17 00:00:00 2001 From: rezailmi Date: Fri, 26 Jun 2026 17:54:39 +0800 Subject: [PATCH 3/7] Show Tailwind utility chips on typography rows (plan 025) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the font-size, line-height, letter-spacing, and font-weight rows of the Text section token-aware. A new TypographyField wrapper shows the attributed Tailwind utility (text-base, leading-7, font-semibold, …) via the shared TokenChip from plan 024, moving the raw editor into the chip popover; rows fall back to today's controls when no utility is attributed. Resolution reuses typographyTokenForProperty from plan 023; classList is threaded from the panel. Claude-Session: https://claude.ai/code/session_01JzitXiMcdedyPbH4wqkXqX --- src/panel.tsx | 6 +- src/panel/typography-inputs.test.tsx | 84 +++++++++++++++++++ src/panel/typography-inputs.tsx | 120 +++++++++++++++++---------- 3 files changed, 167 insertions(+), 43 deletions(-) create mode 100644 src/panel/typography-inputs.test.tsx diff --git a/src/panel.tsx b/src/panel.tsx index c330e3b..a48ecac 100644 --- a/src/panel.tsx +++ b/src/panel.tsx @@ -391,7 +391,11 @@ export function DirectEditPanelInner({ {elementInfo.isTextElement && computedTypography && (
- +
)} diff --git a/src/panel/typography-inputs.test.tsx b/src/panel/typography-inputs.test.tsx new file mode 100644 index 0000000..1289fd9 --- /dev/null +++ b/src/panel/typography-inputs.test.tsx @@ -0,0 +1,84 @@ +import * as React from 'react' +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TypographyInputs } from './typography-inputs' +import type { TypographyProperties } from '../types' + +vi.mock('../ui/tooltip', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ render }: { render: React.ReactElement }) => render, + TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +vi.mock('../ui/simple-select', () => ({ + SimpleSelect: ({ + value, + onValueChange, + options, + }: { + value: string + onValueChange: (value: string) => void + options: Array<{ value: string; label: string }> + }) => ( + + ), +})) + +afterEach(cleanup) + +const typography: TypographyProperties = { + fontFamily: 'system-ui, sans-serif', + fontWeight: '600', + fontSize: { numericValue: 16, unit: 'px', raw: '16px' }, + lineHeight: { numericValue: 28, unit: 'px', raw: '28px' }, + letterSpacing: { numericValue: 0, unit: 'em', raw: '0em' }, + textAlign: 'left', + textVerticalAlign: 'flex-start', + textDecoration: 'none', + textTransform: 'none', + fontStyle: 'normal', +} + +describe('TypographyInputs token awareness', () => { + it('shows utility chips for attributed size, weight, and line-height', () => { + const { container } = render( + , + ) + expect(container.textContent).toContain('text-base') + expect(container.textContent).toContain('font-semibold') + expect(container.textContent).toContain('leading-7') + // Only letter-spacing (unattributed) stays a raw number input at the row level; + // size / line-height moved into their chip popovers. + expect(container.querySelectorAll('input[type="number"]').length).toBe(1) + }) + + it('renders the raw controls when no utility is attributed (fallback)', () => { + const { container } = render( + , + ) + // size + line-height + letter-spacing all render as raw number inputs. + expect(container.querySelectorAll('input[type="number"]').length).toBe(3) + expect(container.textContent).not.toContain('text-base') + }) + + it('attributes arbitrary font-size values', () => { + const { container } = render( + , + ) + expect(container.textContent).toContain('text-[17px]') + }) +}) diff --git a/src/panel/typography-inputs.tsx b/src/panel/typography-inputs.tsx index e102d9d..c53e039 100644 --- a/src/panel/typography-inputs.tsx +++ b/src/panel/typography-inputs.tsx @@ -3,6 +3,8 @@ import { Button } from '../ui/button' import { SimpleSelect } from '../ui/simple-select' import type { TypographyPropertyKey, TypographyProperties, CSSPropertyValue } from '../types' import { NumberInput, Tip } from './shared' +import { typographyTokenForProperty } from '../utils/design-tokens' +import { TokenChip } from './token-chip' import { Type, AlignLeft, @@ -47,9 +49,35 @@ export const FONT_WEIGHTS = [ interface TypographyInputsProps { typography: TypographyProperties onUpdate: (key: TypographyPropertyKey, value: CSSPropertyValue | string) => void + classList?: string[] } -export function TypographyInputs({ typography, onUpdate }: TypographyInputsProps) { +/** + * Renders the raw control inline when no Tailwind utility is attributed, or a + * token chip whose popover contains that same raw control when one is. + */ +function TypographyField({ + token, + children, +}: { + token: string | null + children: React.ReactNode +}) { + if (!token) return <>{children} + return ( + +
{children}
+
+ ) +} + +export function TypographyInputs({ typography, onUpdate, classList }: TypographyInputsProps) { + const cl = classList ?? [] + const fontSizeToken = typographyTokenForProperty('font-size', cl) + const lineHeightToken = typographyTokenForProperty('line-height', cl) + const letterSpacingToken = typographyTokenForProperty('letter-spacing', cl) + const fontWeightToken = typographyTokenForProperty('font-weight', cl) + const handleFontSizeChange = (value: number) => { onUpdate('fontSize', { numericValue: value, unit: 'px', raw: `${value}px` }) } @@ -89,49 +117,57 @@ export function TypographyInputs({ typography, onUpdate }: TypographyInputsProps itemClassName="relative flex cursor-default select-none items-center rounded-md py-2 pl-7 pr-2 text-xs outline-none hover:bg-muted hover:text-foreground data-[highlighted]:bg-muted data-[highlighted]:text-foreground" /> - onUpdate('fontWeight', val)} - options={FONT_WEIGHTS} - label={getFontWeightLabel(typography.fontWeight)} - icon={} - triggerClassName="w-full" - popupMinWidth="140px" - itemClassName="relative flex cursor-default select-none items-center rounded-md py-2 pl-7 pr-2 text-xs outline-none hover:bg-muted hover:text-foreground data-[highlighted]:bg-muted data-[highlighted]:text-foreground" - /> + + onUpdate('fontWeight', val)} + options={FONT_WEIGHTS} + label={getFontWeightLabel(typography.fontWeight)} + icon={} + triggerClassName="w-full" + popupMinWidth="140px" + itemClassName="relative flex cursor-default select-none items-center rounded-md py-2 pl-7 pr-2 text-xs outline-none hover:bg-muted hover:text-foreground data-[highlighted]:bg-muted data-[highlighted]:text-foreground" + /> +
- -
- - -
-
- -
- - -
-
- -
- - -
-
+ + +
+ + +
+
+
+ + +
+ + +
+
+
+ + +
+ + +
+
+
From 96933b70217d122bc65bb6147abf17180b73cde1 Mon Sep 17 00:00:00 2001 From: rezailmi Date: Fri, 26 Jun 2026 19:12:37 +0800 Subject: [PATCH 4/7] Add Tailwind + shadcn token showcase to dev harness Claude-Session: https://claude.ai/code/session_01JzitXiMcdedyPbH4wqkXqX --- dev/App.tsx | 4 ++++ dev/main.tsx | 2 ++ dev/theme.css | 46 ++++++++++++++++++++++++++++++++++++++++++ dev/token-showcase.tsx | 28 +++++++++++++++++++++++++ 4 files changed, 80 insertions(+) create mode 100644 dev/theme.css create mode 100644 dev/token-showcase.tsx diff --git a/dev/App.tsx b/dev/App.tsx index 3e8f01f..2a0b54a 100644 --- a/dev/App.tsx +++ b/dev/App.tsx @@ -3,6 +3,7 @@ import { DirectEdit } from '../src/direct-edit' import { Avatar, Button, Badge } from './components/ui' import { Button as BaseUIButton } from '@base-ui/react/button' import { CanvasPlayground } from './canvas-playground' +import { TokenShowcase } from './token-showcase' const gray = { 50: 'var(--color-gray-50, #f9fafb)', @@ -186,6 +187,9 @@ export default function App() {
+ {/* Design Tokens — uses Tailwind @theme + shadcn token utility classes */} + + {/* Layout & Flex */}
Layout & Flex
diff --git a/dev/main.tsx b/dev/main.tsx index 2d4a21f..4ff4454 100644 --- a/dev/main.tsx +++ b/dev/main.tsx @@ -1,3 +1,5 @@ +import './theme.css' + // Install React DevTools hook before React initializes import '../src/preload' diff --git a/dev/theme.css b/dev/theme.css new file mode 100644 index 0000000..cd73a76 --- /dev/null +++ b/dev/theme.css @@ -0,0 +1,46 @@ +/* Dev harness design tokens — Tailwind v4 theme + utilities only (NO preflight, + so the existing inline-styled dev app is not reset). */ +@layer theme, utilities; +@import "tailwindcss/theme.css" layer(theme); +@import "tailwindcss/utilities.css" layer(utilities); + +/* Direct Tailwind @theme tokens — these emit --color-* onto :root */ +@theme { + --color-brand: oklch(0.55 0.2 264); + --color-brand-foreground: oklch(0.98 0 0); +} + +/* shadcn-style tokens (canonical modern shadcn v4 shape) */ +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); +} +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --primary: oklch(0.985 0 0); + --primary-foreground: oklch(0.205 0 0); + --border: oklch(0.269 0 0); +} +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-destructive: var(--destructive); + --color-border: var(--border); +} diff --git a/dev/token-showcase.tsx b/dev/token-showcase.tsx new file mode 100644 index 0000000..3336ff6 --- /dev/null +++ b/dev/token-showcase.tsx @@ -0,0 +1,28 @@ +export function TokenShowcase() { + return ( +
+
+ DESIGN TOKENS (Tailwind + shadcn) +
+
+
+ + Tailwind @theme: bg-brand + + + +

+ text-muted-foreground · text-lg · font-semibold +

+ + bg-destructive + +
+
+
+ ) +} From c4e1b9e11f46b03e4a8349b813003a916cdb0d74 Mon Sep 17 00:00:00 2001 From: rezailmi Date: Fri, 26 Jun 2026 20:18:28 +0800 Subject: [PATCH 5/7] Fix oklch/lab/lch color parsing by rasterizing a pixel Claude-Session: https://claude.ai/code/session_01JzitXiMcdedyPbH4wqkXqX --- src/utils.ts | 18 ++++++++++++------ src/utils/color.test.ts | 27 ++++++++++++++++++++++++++- src/utils/color.ts | 19 +++++++++++++------ 3 files changed, 51 insertions(+), 13 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 611bd1d..cf79ae2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -400,13 +400,19 @@ function parseNamedColor(name: string): ColorValue { return { hex: '000000', alpha: 100, raw: name } } + // Paint one pixel and read it back. Round-tripping ctx.fillStyle as a string + // fails for oklch/lab/lch/color() (Chrome returns them unchanged); rasterizing + // forces real sRGB bytes. + ctx.clearRect(0, 0, 1, 1) + ctx.fillStyle = '#000000' // reset so an unparseable value yields the old black fallback ctx.fillStyle = name - const computed = ctx.fillStyle - - if (computed.startsWith('#')) { - return parseHexColor(computed) - } - return parseRgbaColor(computed) + ctx.fillRect(0, 0, 1, 1) + const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data + const hex = [r, g, b] + .map((v) => v.toString(16).padStart(2, '0')) + .join('') + .toUpperCase() + return { hex, alpha: Math.round((a / 255) * 100), raw: name } } export function parseColorValue(cssValue: string): ColorValue { diff --git a/src/utils/color.test.ts b/src/utils/color.test.ts index c3db80a..96c9345 100644 --- a/src/utils/color.test.ts +++ b/src/utils/color.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, afterEach, vi } from 'vitest' import { parseColorValue } from './color' describe('parseColorValue', () => { @@ -102,4 +102,29 @@ describe('parseColorValue', () => { expect(() => parseColorValue('not-a-color')).not.toThrow() }) }) + + describe('rasterized color path (oklch/lab/lch)', () => { + afterEach(() => vi.restoreAllMocks()) + + it('reads the painted pixel for oklch input', () => { + // jsdom has no real canvas, so stub the 2D context to return a known + // pixel. This exercises the fillRect + getImageData rasterization path + // that handles oklch/lab/lch/color() in a real browser. + const fake = { + clearRect: vi.fn(), + fillRect: vi.fn(), + set fillStyle(_v: string) {}, + get fillStyle() { + return '#000000' + }, + getImageData: () => ({ data: new Uint8ClampedArray([26, 26, 26, 255]) }), + } as unknown as CanvasRenderingContext2D + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue( + fake as never + ) + const result = parseColorValue('oklch(0.205 0 0)') + expect(result.hex).toBe('1A1A1A') + expect(result.alpha).toBe(100) + }) + }) }) diff --git a/src/utils/color.ts b/src/utils/color.ts index 41eeb89..b129132 100644 --- a/src/utils/color.ts +++ b/src/utils/color.ts @@ -86,13 +86,20 @@ function parseNamedColor(name: string): ColorValue { return { hex: '000000', alpha: 100, raw: name } } + // Paint one pixel and read it back. Round-tripping ctx.fillStyle as a string + // fails for oklch/lab/lch/color() (Chrome returns them unchanged); rasterizing + // forces real sRGB bytes. The clearRect before each paint makes the cached + // context safe to reuse across calls. + ctx.clearRect(0, 0, 1, 1) + ctx.fillStyle = '#000000' // reset so an unparseable value yields the old black fallback ctx.fillStyle = name - const computed = ctx.fillStyle - - if (computed.startsWith('#')) { - return parseHexColor(computed) - } - return parseRgbaColor(computed) + ctx.fillRect(0, 0, 1, 1) + const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data + const hex = [r, g, b] + .map((v) => v.toString(16).padStart(2, '0')) + .join('') + .toUpperCase() + return { hex, alpha: Math.round((a / 255) * 100), raw: name } } export function parseColorValue(cssValue: string): ColorValue { From db70b5d155b8059b1c2acf4358b7b8e78783466f Mon Sep 17 00:00:00 2001 From: rezailmi Date: Fri, 26 Jun 2026 20:27:50 +0800 Subject: [PATCH 6/7] Add design-token palette to the color picker (var binding) Claude-Session: https://claude.ai/code/session_01JzitXiMcdedyPbH4wqkXqX --- src/panel.tsx | 1 + src/panel/border-section.tsx | 12 +++-- src/panel/fill-section.test.tsx | 36 +++++++++++++ src/panel/fill-section.tsx | 24 ++++++--- src/types.ts | 2 + src/ui/color-picker.test.tsx | 91 +++++++++++++++++++++++++++++++++ src/ui/color-picker.tsx | 65 +++++++++++++++++++++++ src/ui/color-utils.ts | 1 + src/utils.ts | 1 + src/utils/design-tokens.test.ts | 23 +++++++++ src/utils/design-tokens.ts | 7 +++ 11 files changed, 252 insertions(+), 11 deletions(-) create mode 100644 src/ui/color-picker.test.tsx diff --git a/src/panel.tsx b/src/panel.tsx index a48ecac..48735b2 100644 --- a/src/panel.tsx +++ b/src/panel.tsx @@ -429,6 +429,7 @@ export function DirectEditPanelInner({ showBorderColor={computedColor.borderColor.alpha > 0} showOutlineColor={computedColor.outlineColor.alpha > 0} classList={elementInfo.classList} + pendingStyles={pendingStyles} />
diff --git a/src/panel/border-section.tsx b/src/panel/border-section.tsx index b143bc6..5c7210d 100644 --- a/src/panel/border-section.tsx +++ b/src/panel/border-section.tsx @@ -10,7 +10,7 @@ import { ColorPickerGroup } from '../ui/color-picker' import { NumberInput, Tip, CollapsibleSection } from './shared' import { ColorInput } from './fill-section' import { Button } from '../ui/button' -import { getColorTokenIndex, resolveColorToken, getTokenAliasChain } from '../utils/design-tokens' +import { getColorTokenIndex, resolveColorToken, getTokenAliasChain, tokenFromCssValue } from '../utils/design-tokens' import { ChevronDown, Square, @@ -87,9 +87,10 @@ interface BorderInputsProps { outlineStyle?: BorderStyle outlineWidth?: number classList?: string[] + pendingStyles?: Record } -export function BorderInputs({ border, borderColor, outlineColor, onChange, onBatchChange, onBorderColorChange, onOutlineColorChange, onSetCSS, borderPosition, borderStyleControlPreference, onPositionChange, outlineStyle, outlineWidth, classList }: BorderInputsProps) { +export function BorderInputs({ border, borderColor, outlineColor, onChange, onBatchChange, onBorderColorChange, onOutlineColorChange, onSetCSS, borderPosition, borderStyleControlPreference, onPositionChange, outlineStyle, outlineWidth, classList, pendingStyles }: BorderInputsProps) { const [selectedSide, setSelectedSide] = React.useState('All') const isOutline = borderPosition === 'outline' @@ -193,9 +194,11 @@ export function BorderInputs({ border, borderColor, outlineColor, onChange, onBa const activeColorChange = isOutline ? onOutlineColorChange : onBorderColorChange const activeProperty = isOutline ? 'outline-color' : 'border-color' const activeToken = - classList && activeColor + activeColor?.token ?? + tokenFromCssValue(pendingStyles?.[activeProperty]) ?? + (classList && activeColor ? resolveColorToken(activeProperty, classList, activeColor, getColorTokenIndex()) - : null + : null) const activeAliasChain = activeToken ? getTokenAliasChain(activeToken) : undefined const currentStyleLabel = BORDER_STYLE_OPTIONS.find((o) => o.value === currentStyle)?.label ?? currentStyle @@ -472,6 +475,7 @@ export function BorderSection({ border, borderColor, outlineColor, borderStyleCo outlineStyle={outlineStyleValue} outlineWidth={outlineWidthValue} classList={classList} + pendingStyles={pendingStyles} /> ) : null} diff --git a/src/panel/fill-section.test.tsx b/src/panel/fill-section.test.tsx index 373786e..def4424 100644 --- a/src/panel/fill-section.test.tsx +++ b/src/panel/fill-section.test.tsx @@ -3,6 +3,7 @@ import { act, cleanup, render, fireEvent } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { BackgroundFillSection, ColorInput, FillSection } from './fill-section' import { invalidateColorTokenIndex } from '../utils/design-tokens' +import { formatColorValue } from '../ui/color-utils' import type { ColorValue } from '../types' vi.mock('../ui/tooltip', () => ({ @@ -198,4 +199,39 @@ describe('FillSection token resolution', () => { expect(container.querySelector('input[type="text"]')).not.toBeNull() expect(container.textContent).not.toContain('--color-') }) + + it('renders the chip for a value whose .token is set (picker binding)', () => { + const bound: ColorValue = { + hex: '3B82F6', + alpha: 100, + raw: 'var(--color-primary)', + token: '--color-primary', + } + const { container } = render( + , + ) + expect(container.textContent).toContain('--color-primary') + }) + + it('recovers the chip from a var() in pendingStyles', () => { + const literal: ColorValue = { hex: '3B82F6', alpha: 100, raw: '#3B82F6' } + const { container } = render( + , + ) + expect(container.textContent).toContain('--color-primary') + }) +}) + +describe('formatColorValue token binding', () => { + it('returns var(--token) when token is set', () => { + expect( + formatColorValue({ hex: '3B82F6', alpha: 100, raw: '', token: '--color-primary' }), + ).toBe('var(--color-primary)') + }) }) diff --git a/src/panel/fill-section.tsx b/src/panel/fill-section.tsx index 55d05b4..b8a27bd 100644 --- a/src/panel/fill-section.tsx +++ b/src/panel/fill-section.tsx @@ -11,6 +11,7 @@ import { getColorTokenIndex, resolveColorToken, getTokenAliasChain, + tokenFromCssValue, } from '../utils/design-tokens' const MAX_LAYER_COUNT = 16 @@ -152,6 +153,7 @@ interface FillSectionProps { showBorderColor?: boolean showOutlineColor?: boolean classList?: string[] + pendingStyles?: Record } export function FillSection({ @@ -168,12 +170,16 @@ export function FillSection({ showBorderColor, showOutlineColor, classList, + pendingStyles, }: FillSectionProps) { const showDetectedColorInputs = selectionColors.length > 0 && onSelectionColorChange const index = getColorTokenIndex() - const tokenFor = (cssProperty: string, value: ColorValue) => { - const name = classList ? resolveColorToken(cssProperty, classList, value, index) : null + const tokenFor = (cssProperty: string, value: ColorValue, appliedCss?: string) => { + const name = + value.token ?? + tokenFromCssValue(appliedCss) ?? + (classList ? resolveColorToken(cssProperty, classList, value, index) : null) return name ? { token: name, aliasChain: getTokenAliasChain(name) } : { token: null, aliasChain: undefined } @@ -221,7 +227,7 @@ export function FillSection({ id="fill-text" value={textColor} onChange={onTextChange} - {...tokenFor('color', textColor)} + {...tokenFor('color', textColor, pendingStyles?.['color'])} /> )} @@ -230,7 +236,7 @@ export function FillSection({ id="fill-border" value={borderColor} onChange={onBorderColorChange} - {...tokenFor('border-color', borderColor)} + {...tokenFor('border-color', borderColor, pendingStyles?.['border-color'])} /> )} @@ -239,7 +245,7 @@ export function FillSection({ id="fill-outline" value={outlineColor} onChange={onOutlineColorChange} - {...tokenFor('outline-color', outlineColor)} + {...tokenFor('outline-color', outlineColor, pendingStyles?.['outline-color'])} /> )}
@@ -299,8 +305,12 @@ export function BackgroundFillSection({ // Only a single solid fill layer maps cleanly to one background-color token. const singleLayerToken = - classList && layers.length === 1 - ? resolveColorToken('background-color', classList, layers[0], getColorTokenIndex()) + layers.length === 1 + ? layers[0].token ?? + tokenFromCssValue(pendingStyles['background-color']) ?? + (classList + ? resolveColorToken('background-color', classList, layers[0], getColorTokenIndex()) + : null) : null const singleLayerAliasChain = singleLayerToken ? getTokenAliasChain(singleLayerToken) : undefined diff --git a/src/types.ts b/src/types.ts index 34007ab..d8ab018 100644 --- a/src/types.ts +++ b/src/types.ts @@ -93,6 +93,8 @@ export interface ColorValue { hex: string // 6-character hex without # (e.g., "DDDDDD") alpha: number // 0-100 percentage raw: string // Original CSS value + /** When set, this color is bound to a CSS variable; edits write var(--token). */ + token?: string } export interface ColorProperties { diff --git a/src/ui/color-picker.test.tsx b/src/ui/color-picker.test.tsx new file mode 100644 index 0000000..ca9a653 --- /dev/null +++ b/src/ui/color-picker.test.tsx @@ -0,0 +1,91 @@ +import * as React from 'react' +import { cleanup, render, fireEvent } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ColorPickerPopover, TokenPalette } from './color-picker' +import { invalidateColorTokenIndex } from '../utils/design-tokens' +import type { ColorValue } from '../types' + +// Render the base-ui popover parts inline so the (normally portalled, dismiss-gated) +// popup content is present in the tree for assertions. +vi.mock('@base-ui/react/popover', () => ({ + Popover: { + Root: ({ children }: { children: React.ReactNode }) => <>{children}, + Trigger: React.forwardRef< + HTMLButtonElement, + { render?: React.ReactElement; children?: React.ReactNode; className?: string } + >(({ children }, ref) => ( + + )), + Portal: ({ children }: { children: React.ReactNode }) => <>{children}, + Positioner: ({ children }: { children: React.ReactNode }) =>
{children}
, + Popup: React.forwardRef>( + ({ children }, ref) =>
{children}
+ ), + }, +})) + +let injectedStyle: HTMLStyleElement | null = null + +function injectThemeStyle(css: string) { + const el = document.createElement('style') + el.textContent = css + document.head.appendChild(el) + injectedStyle = el + invalidateColorTokenIndex() +} + +afterEach(() => { + cleanup() + injectedStyle?.remove() + injectedStyle = null + invalidateColorTokenIndex() +}) + +const black: ColorValue = { hex: '000000', alpha: 100, raw: '#000000' } + +function findRow(container: HTMLElement, label: string): HTMLButtonElement { + const btn = Array.from(container.querySelectorAll('button')).find((b) => + b.textContent?.includes(label) + ) + if (!btn) throw new Error(`No token row found for ${label}`) + return btn as HTMLButtonElement +} + +describe('TokenPalette', () => { + it('renders a row per color token and reports the pick', () => { + injectThemeStyle(':root{--color-primary:#3B82F6}') + const onPick = vi.fn() + const { container } = render() + expect(container.textContent).toContain('--color-primary') + fireEvent.click(findRow(container, '--color-primary')) + expect(onPick).toHaveBeenCalledWith('--color-primary', '3B82F6') + }) + + it('renders nothing when there are no color tokens', () => { + invalidateColorTokenIndex() + const { container } = render() + expect(container.textContent).toBe('') + }) +}) + +describe('ColorPickerPopover token palette', () => { + it('binds the color to var(--token) when a palette row is clicked', () => { + injectThemeStyle(':root{--color-primary:#3B82F6}') + const onChange = vi.fn() + const { container } = render( + +
+ + ) + expect(container.textContent).toContain('--color-primary') + fireEvent.click(findRow(container, '--color-primary')) + expect(onChange).toHaveBeenCalledWith({ + token: '--color-primary', + raw: 'var(--color-primary)', + hex: '3B82F6', + alpha: 100, + }) + }) +}) diff --git a/src/ui/color-picker.tsx b/src/ui/color-picker.tsx index 8427561..0c3251b 100644 --- a/src/ui/color-picker.tsx +++ b/src/ui/color-picker.tsx @@ -12,6 +12,7 @@ import { formatColorValue, } from './color-utils' import { useOutsideClickDismiss } from '../hooks/use-outside-click-dismiss' +import { getColorTokenIndex, tokenPreferenceRank } from '../utils/design-tokens' function ColorPickerPortal(props: React.ComponentPropsWithoutRef) { const container = usePortalContainer() @@ -174,6 +175,62 @@ function NumericInput({ ) } +export function TokenPalette({ + onPick, +}: { + onPick: (token: string, hex: string) => void +}) { + const index = React.useMemo(() => getColorTokenIndex(), []) + const entries = React.useMemo(() => { + const all = Array.from(index.byName.values()) + const semantic = all + .filter((e) => tokenPreferenceRank(e.name) !== 1) + .sort( + (a, b) => + tokenPreferenceRank(a.name) - tokenPreferenceRank(b.name) || + a.name.localeCompare(b.name) + ) + const palette = all + .filter((e) => tokenPreferenceRank(e.name) === 1) + .sort((a, b) => a.name.localeCompare(b.name)) + return { semantic, palette } + }, [index]) + + if (entries.semantic.length === 0 && entries.palette.length === 0) return null + + const Row = (e: { name: string; value: { hex: string } }) => ( + + ) + + return ( +
+
+ Variables +
+
+ {entries.semantic.map(Row)} + {entries.palette.length > 0 && ( +
+ Palette +
+ )} + {entries.palette.map(Row)} +
+
+ ) +} + interface ColorPickerPopoverProps { id?: string value: ColorValue @@ -364,6 +421,14 @@ export function ColorPickerPopover({ id, value, onChange, children }: ColorPicke %
+ + {/* Token palette — pick a CSS variable to bind to */} + { + lastSyncedHex.current = hex + onChange({ hex, alpha: 100, raw: `var(${token})`, token }) + }} + /> diff --git a/src/ui/color-utils.ts b/src/ui/color-utils.ts index 04d39b3..89906a5 100644 --- a/src/ui/color-utils.ts +++ b/src/ui/color-utils.ts @@ -1,6 +1,7 @@ import type { ColorValue } from '../types' export function formatColorValue(color: ColorValue): string { + if (color.token) return `var(${color.token})` const r = parseInt(color.hex.slice(0, 2), 16) const g = parseInt(color.hex.slice(2, 4), 16) const b = parseInt(color.hex.slice(4, 6), 16) diff --git a/src/utils.ts b/src/utils.ts index cf79ae2..7f4ac74 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -86,6 +86,7 @@ export { resolveVarChain, resolveTokenColor, tokenPreferenceRank, + tokenFromCssValue, } from './utils/design-tokens' export type { ThemeVariable, ColorTokenEntry, ColorTokenIndex } from './utils/design-tokens' diff --git a/src/utils/design-tokens.test.ts b/src/utils/design-tokens.test.ts index 9edddbd..b372137 100644 --- a/src/utils/design-tokens.test.ts +++ b/src/utils/design-tokens.test.ts @@ -8,6 +8,7 @@ import { resolveVarChain, tokenForColorClass, tokenForColorValue, + tokenFromCssValue, typographyTokenForProperty, type ThemeVariable, } from './design-tokens' @@ -234,6 +235,28 @@ describe('tokenForColorValue', () => { }) }) +// ---- tokenFromCssValue (pure) ---- + +describe('tokenFromCssValue', () => { + it('extracts the variable name from a var() value', () => { + expect(tokenFromCssValue('var(--color-primary)')).toBe('--color-primary') + }) + + it('extracts the variable name when a fallback is present', () => { + expect(tokenFromCssValue('var(--x, #fff)')).toBe('--x') + }) + + it('returns null for a literal color', () => { + expect(tokenFromCssValue('#3B82F6')).toBeNull() + }) + + it('returns null for empty or undefined input', () => { + expect(tokenFromCssValue('')).toBeNull() + expect(tokenFromCssValue(undefined)).toBeNull() + expect(tokenFromCssValue(null)).toBeNull() + }) +}) + // ---- typographyTokenForProperty (pure) ---- describe('typographyTokenForProperty', () => { diff --git a/src/utils/design-tokens.ts b/src/utils/design-tokens.ts index 222537d..2d12c2b 100644 --- a/src/utils/design-tokens.ts +++ b/src/utils/design-tokens.ts @@ -275,6 +275,13 @@ export function resolveColorToken( return tokenForColorClass(cssProperty, classList, index) ?? tokenForColorValue(value, index) } +/** Extract the variable name from a CSS value like `var(--color-primary)` (or with a fallback). */ +export function tokenFromCssValue(cssValue: string | undefined | null): string | null { + if (!cssValue) return null + const m = cssValue.trim().match(/^var\(\s*(--[\w-]+)\s*(?:,.*)?\)$/) + return m ? m[1] : null +} + /** Ordered alias chain for the popover, e.g. ['--color-primary','--color-blue-500','#3B82F6']. */ export function getTokenAliasChain(name: string, doc: Document = document): string[] { const vars = collectThemeVariables(doc) From da25e9d7f98f811730f36b3ee1df56a55760c5e1 Mon Sep 17 00:00:00 2001 From: rezailmi Date: Fri, 26 Jun 2026 21:32:39 +0800 Subject: [PATCH 7/7] Polish token feature + bump to 0.3.1 - formatColorValue: preserve alpha on bound tokens via color-mix (<100%) - design-tokens: add varForClassRule to recover shadcn @theme inline bindings from the generated utility rule; tokenForColorClass falls back to it - dedup parseColorValue: src/utils.ts now re-exports from src/utils/color.ts - bump package version 0.3.0 -> 0.3.1 Claude-Session: https://claude.ai/code/session_01JzitXiMcdedyPbH4wqkXqX --- package.json | 2 +- src/panel/fill-section.test.tsx | 6 ++ src/ui/color-utils.ts | 5 +- src/utils.ts | 147 +------------------------------- src/utils/design-tokens.test.ts | 39 +++++++++ src/utils/design-tokens.ts | 53 +++++++++++- 6 files changed, 104 insertions(+), 148 deletions(-) diff --git a/package.json b/package.json index 776fc04..c8fb931 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "made-refine", - "version": "0.3.0", + "version": "0.3.1", "packageManager": "bun@latest", "description": "Visual CSS editor for React", "main": "./dist/index.js", diff --git a/src/panel/fill-section.test.tsx b/src/panel/fill-section.test.tsx index def4424..8034e90 100644 --- a/src/panel/fill-section.test.tsx +++ b/src/panel/fill-section.test.tsx @@ -234,4 +234,10 @@ describe('formatColorValue token binding', () => { formatColorValue({ hex: '3B82F6', alpha: 100, raw: '', token: '--color-primary' }), ).toBe('var(--color-primary)') }) + + it('wraps a translucent bound token in color-mix', () => { + expect( + formatColorValue({ hex: '3B82F6', alpha: 50, raw: '', token: '--color-primary' }), + ).toBe('color-mix(in srgb, var(--color-primary) 50%, transparent)') + }) }) diff --git a/src/ui/color-utils.ts b/src/ui/color-utils.ts index 89906a5..b7d2c08 100644 --- a/src/ui/color-utils.ts +++ b/src/ui/color-utils.ts @@ -1,7 +1,10 @@ import type { ColorValue } from '../types' export function formatColorValue(color: ColorValue): string { - if (color.token) return `var(${color.token})` + if (color.token) { + if (color.alpha >= 100) return `var(${color.token})` + return `color-mix(in srgb, var(${color.token}) ${color.alpha}%, transparent)` + } const r = parseInt(color.hex.slice(0, 2), 16) const g = parseInt(color.hex.slice(2, 4), 16) const b = parseInt(color.hex.slice(4, 6), 16) diff --git a/src/utils.ts b/src/utils.ts index 7f4ac74..7950167 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -78,6 +78,7 @@ export { invalidateColorTokenIndex, resolveColorToken, tokenForColorClass, + varForClassRule, tokenForColorValue, colorClassToVarName, getTokenAliasChain, @@ -292,151 +293,7 @@ export function sizingToTailwind(dimension: 'width' | 'height', sizing: SizingVa } } -function parseHexColor(hex: string): ColorValue { - const raw = hex - let h = hex.replace('#', '') - - // Expand shorthand (#RGB -> #RRGGBB) - if (h.length === 3) { - h = h - .split('') - .map((c) => c + c) - .join('') - } - - // Handle 8-digit hex with alpha - if (h.length === 8) { - const alpha = Math.round((parseInt(h.slice(6, 8), 16) / 255) * 100) - return { hex: h.slice(0, 6).toUpperCase(), alpha, raw } - } - - return { hex: h.toUpperCase(), alpha: 100, raw } -} - -function parseRgbChannel(value: string): number | null { - const token = value.trim() - if (!token) return null - - if (token.endsWith('%')) { - const numeric = parseFloat(token.slice(0, -1)) - if (!Number.isFinite(numeric)) return null - return Math.round((Math.max(0, Math.min(100, numeric)) / 100) * 255) - } - - const numeric = parseFloat(token) - if (!Number.isFinite(numeric)) return null - return Math.round(Math.max(0, Math.min(255, numeric))) -} - -function parseRgbAlpha(value: string | undefined): number | null { - if (value == null || value.trim() === '') return 1 - const token = value.trim() - - if (token.endsWith('%')) { - const numeric = parseFloat(token.slice(0, -1)) - if (!Number.isFinite(numeric)) return null - return Math.max(0, Math.min(100, numeric)) / 100 - } - - const numeric = parseFloat(token) - if (!Number.isFinite(numeric)) return null - return Math.max(0, Math.min(1, numeric)) -} - -function parseRgbaColor(rgba: string): ColorValue { - const raw = rgba.trim() - const fnMatch = raw.match(/^rgba?\((.*)\)$/i) - if (!fnMatch) { - return { hex: '000000', alpha: 100, raw: rgba } - } - - const body = fnMatch[1].trim() - let channelTokens: [string, string, string] | null = null - let alphaToken: string | undefined - - const commaParts = body - .split(',') - .map((part) => part.trim()) - .filter(Boolean) - if (commaParts.length === 3 || commaParts.length === 4) { - channelTokens = [commaParts[0], commaParts[1], commaParts[2]] - alphaToken = commaParts[3] - } else { - const slashParts = body.split('/') - if (slashParts.length === 1 || slashParts.length === 2) { - const channels = slashParts[0].trim().split(/\s+/).filter(Boolean) - if (channels.length === 3) { - channelTokens = [channels[0], channels[1], channels[2]] - alphaToken = slashParts[1]?.trim() - } - } - } - - if (!channelTokens) { - return { hex: '000000', alpha: 100, raw: rgba } - } - - const r = parseRgbChannel(channelTokens[0]) - const g = parseRgbChannel(channelTokens[1]) - const b = parseRgbChannel(channelTokens[2]) - const a = parseRgbAlpha(alphaToken) - - if (r === null || g === null || b === null || a === null) { - return { hex: '000000', alpha: 100, raw: rgba } - } - - const hex = [r, g, b] - .map((v) => v.toString(16).padStart(2, '0')) - .join('') - .toUpperCase() - const alpha = Math.round(a * 100) - - return { hex, alpha, raw: rgba } -} - -function parseNamedColor(name: string): ColorValue { - // Use a temporary canvas to convert named colors - const ctx = document.createElement('canvas').getContext('2d') - if (!ctx) { - return { hex: '000000', alpha: 100, raw: name } - } - - // Paint one pixel and read it back. Round-tripping ctx.fillStyle as a string - // fails for oklch/lab/lch/color() (Chrome returns them unchanged); rasterizing - // forces real sRGB bytes. - ctx.clearRect(0, 0, 1, 1) - ctx.fillStyle = '#000000' // reset so an unparseable value yields the old black fallback - ctx.fillStyle = name - ctx.fillRect(0, 0, 1, 1) - const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data - const hex = [r, g, b] - .map((v) => v.toString(16).padStart(2, '0')) - .join('') - .toUpperCase() - return { hex, alpha: Math.round((a / 255) * 100), raw: name } -} - -export function parseColorValue(cssValue: string): ColorValue { - const raw = cssValue.trim() - - // Handle transparent - if (raw === 'transparent') { - return { hex: '000000', alpha: 0, raw } - } - - // Handle hex colors - if (raw.startsWith('#')) { - return parseHexColor(raw) - } - - // Handle rgb/rgba - if (raw.startsWith('rgb')) { - return parseRgbaColor(raw) - } - - // Fallback: use canvas to convert named colors - return parseNamedColor(raw) -} +export { parseColorValue } from './utils/color' export const colorPropertyToCSSMap: Record = { backgroundColor: 'background-color', diff --git a/src/utils/design-tokens.test.ts b/src/utils/design-tokens.test.ts index b372137..0db6259 100644 --- a/src/utils/design-tokens.test.ts +++ b/src/utils/design-tokens.test.ts @@ -3,6 +3,7 @@ import { buildColorTokenIndexFromVariables, collectThemeVariables, colorClassToVarName, + invalidateColorTokenIndex, looksLikeColor, resolveTokenColor, resolveVarChain, @@ -10,6 +11,7 @@ import { tokenForColorValue, tokenFromCssValue, typographyTokenForProperty, + varForClassRule, type ThemeVariable, } from './design-tokens' import { parseColorValue } from './color' @@ -274,3 +276,40 @@ describe('typographyTokenForProperty', () => { expect(typographyTokenForProperty('font-size', [])).toBeNull() }) }) + +// ---- varForClassRule + tokenForColorClass fallback (DOM-backed) ---- + +describe('varForClassRule', () => { + const injected: HTMLStyleElement[] = [] + + function injectStyle(css: string): HTMLStyleElement { + const el = document.createElement('style') + el.textContent = css + document.head.appendChild(el) + injected.push(el) + return el + } + + afterEach(() => { + for (const el of injected.splice(0)) el.remove() + invalidateColorTokenIndex() + }) + + it('recovers the real variable from a generated utility rule', () => { + injectStyle('.bg-primary{background-color:var(--primary)}') + expect(varForClassRule('bg-primary', ['background-color'])).toBe('--primary') + }) + + it('returns null when no rule matches', () => { + expect(varForClassRule('bg-primary', ['background-color'])).toBeNull() + }) + + it('falls back to the recovered var for shadcn @theme inline tokens', () => { + // Only `--primary` lives in :root (the @theme inline `--color-primary` is not + // emitted), but the generated utility rule assigns `var(--primary)`. + injectStyle(':root{--primary:#171717}.bg-primary{background-color:var(--primary)}') + const index = buildColorTokenIndexFromVariables(collectThemeVariables(document)) + expect(index.byName.has('--color-primary')).toBe(false) + expect(tokenForColorClass('background-color', ['bg-primary'], index)).toBe('--primary') + }) +}) diff --git a/src/utils/design-tokens.ts b/src/utils/design-tokens.ts index 2d12c2b..3eb4602 100644 --- a/src/utils/design-tokens.ts +++ b/src/utils/design-tokens.ts @@ -239,19 +239,70 @@ export function colorClassToVarName(cls: string): string | null { return `--color-${rest}` } +/** + * Read the generated utility rule for `.` from the document and return + * the `--var` it assigns to one of `cssProps`. Recovers shadcn `@theme inline` + * bindings (e.g. `.bg-primary { background-color: var(--primary) }`) that are not + * present as `:root` variables. Returns null if no rule/var is found. + */ +export function varForClassRule( + className: string, + cssProps: string[], + doc: Document = document, +): string | null { + const escaped = className.replace(/[^a-zA-Z0-9_-]/g, '\\$&') + for (const sheet of Array.from(doc.styleSheets)) { + let rules: CSSRuleList + try { + rules = sheet.cssRules + } catch { + continue + } + for (const rule of Array.from(rules)) { + if (!(rule instanceof CSSStyleRule)) continue + // match a simple `.class` selector (ignore compound/variant selectors) + if (rule.selectorText !== `.${escaped}` && rule.selectorText !== `.${className}`) continue + for (const prop of cssProps) { + const v = rule.style.getPropertyValue(prop) + const m = v && v.match(/var\(\s*(--[\w-]+)\s*(?:,.*)?\)/) + if (m) return m[1] + } + } + } + return null +} + +// CSS properties to inspect on a generated utility rule, keyed by the color role's +// primary CSS property. Used to recover shadcn `@theme inline` bindings. +const EXPANDED_COLOR_PROPS: Record = { + 'background-color': ['background-color', 'background'], + color: ['color'], + 'border-color': ['border-color'], + 'outline-color': ['outline-color'], +} + /** * Given a CSS property + the element's class list, return the bound color token - * name, verified to exist in `index`. Uses class attribution to pick the right class. + * name. Prefers a class that maps to a `:root` variable present in `index`; when + * that fails (e.g. shadcn `@theme inline`, where `--color-X` is inlined and not a + * `:root` variable), it falls back to reading the generated utility rule for the + * class and recovering the real `--var` it assigns. Uses class attribution to pick + * the right class. */ export function tokenForColorClass( cssProperty: string, classList: string[], index: ColorTokenIndex, + doc: Document = document, ): string | null { const { matchedClasses } = attributeClassesForProperty(cssProperty, classList) + const expandedProps = EXPANDED_COLOR_PROPS[cssProperty] ?? [cssProperty] for (const cls of matchedClasses) { const varName = colorClassToVarName(cls) if (varName && index.byName.has(varName)) return varName + // Fallback: recover the real variable from the generated utility rule. + const recovered = varForClassRule(cls, expandedProps, doc) + if (recovered) return recovered } return null }