diff --git a/apps/daemon/src/design-system-showcase.ts b/apps/daemon/src/design-system-showcase.ts index 6d16b94ff..75aa77d41 100644 --- a/apps/daemon/src/design-system-showcase.ts +++ b/apps/daemon/src/design-system-showcase.ts @@ -18,24 +18,80 @@ export function renderDesignSystemShowcase(id, raw) { const colors = extractColors(raw); const fonts = extractFonts(raw); + // Hints are matched against each color's role description (the prose that + // follows the name in DESIGN.md, e.g. "Primary background.") first, then + // against the color name. We use word-boundary matching so descriptive + // names like "Cardinal Red" don't accidentally satisfy a "card" hint and + // "Gem Pink" doesn't satisfy "ink". + // Hint ordering matters: more specific phrases come first so a system + // with both "Primary background" and "Page background in light mode" (e.g. + // Linear's marketing black + light-mode escape hatch) lands on the + // dominant role rather than the light-mode subtitle. We drop 'page + // background' from the bg hints entirely because in practice it almost + // always belongs to a secondary, light-mode-only entry. const bg = - pickColor(colors, ['page background', 'background', 'canvas', 'paper', 'bg ', 'page bg']) + pickColor(colors, ['primary background', 'background', 'canvas', 'paper']) + ?? firstLightish(colors) ?? '#ffffff'; + // Exclude `bg` so a token whose hex matches the page background (for + // example Warp's "Warm Parchment" doubling as primary text *and* the + // firstLightish bg fallback) doesn't make body copy invisible. const fg = - pickColor(colors, ['heading', 'foreground', 'ink', 'fg', 'text', 'navy', 'graphite']) + pickColor( + colors, + [ + 'primary text', + 'body text', + 'foreground', + 'ink primary', + 'heading', + 'ink', + 'graphite', + 'navy', + ], + [bg], + ) + ?? pickReadableForeground(bg) ?? '#0a0a0a'; const accent = - pickColor(colors, ['primary brand', 'brand primary', 'primary', 'brand', 'accent']) - ?? firstNonNeutral(colors) + pickColor(colors, [ + 'brand primary', + 'primary brand', + 'primary cta', + 'gradient origin', + 'brand mark', + 'brand color', + ]) + ?? firstNonNeutral(colors, [bg, fg]) ?? '#2f6feb'; const accent2 = - pickColor(colors, ['secondary', 'tertiary', 'highlight', 'support']) - ?? secondNonNeutral(colors, accent) + pickColor(colors, [ + 'brand secondary', + 'secondary brand', + 'gradient terminus', + 'tertiary brand', + 'tertiary', + 'highlight', + ]) + ?? secondNonNeutral(colors, [accent, bg, fg]) ?? accent; - const muted = pickColor(colors, ['muted', 'subtle', 'caption', 'meta', 'neutral']) ?? '#666666'; - const border = pickColor(colors, ['border', 'divider', 'rule', 'stroke']) ?? '#e6e6e6'; + const muted = + pickColor(colors, ['secondary text', 'caption', 'metadata', 'placeholder', 'muted', 'subtle']) + ?? '#666666'; + const border = + pickColor(colors, ['border', 'divider', 'hairline', 'rule', 'stroke']) + ?? '#e6e6e6'; const surface = - pickColor(colors, ['surface', 'card', 'background-secondary', 'panel', 'elevated']) + pickColor(colors, [ + 'secondary surface', + 'section break', + 'sidebar', + 'surface subtle', + 'surface', + 'panel', + 'elevated', + 'card surface', + ]) ?? mixSurface(bg); const display = fonts.display ?? fonts.heading ?? "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif"; @@ -598,23 +654,75 @@ function extractSubtitle(raw) { return window.split(/\n\n/)[0]?.slice(0, 240) ?? ''; } -function extractColors(raw) { +export function extractColors(raw) { const colors = []; const seen = new Set(); - function push(name, value) { - const cleanName = name.replace(/[*_`]+/g, '').replace(/\s+/g, ' ').trim(); + function push(name, value, role) { + const cleanName = String(name).replace(/[*_`]+/g, '').replace(/\s+/g, ' ').trim(); if (!cleanName || cleanName.length > 60) return; const v = normalizeHex(value); const key = `${cleanName.toLowerCase()}|${v}`; - if (seen.has(key)) return; + const cleanRole = String(role || '') + .replace(/[`*_]+/g, '') + .replace(/\s+/g, ' ') + .trim() + .replace(/[.;]+$/, ''); + if (seen.has(key)) { + // Already recorded — but if this occurrence carries a richer role + // description, upgrade the stored entry so role-based lookups don't + // fall back to the bare name. + if (cleanRole) { + const existing = colors.find( + (c) => c.name.toLowerCase() === cleanName.toLowerCase() && c.value === v, + ); + if (existing && (!existing.role || cleanRole.length > existing.role.length)) { + existing.role = cleanRole; + } + } + return; + } seen.add(key); - colors.push({ name: cleanName, value: v }); + colors.push({ name: cleanName, value: v, role: cleanRole }); } - const reA = /^[\s>*-]*\**\s*([A-Za-z][A-Za-z0-9 /&()+_-]{1,40}?)\s*\**\s*[::]\s*`?(#[0-9a-fA-F]{3,8})/gm; - let m; - while ((m = reA.exec(raw)) !== null) push(m[1], m[2]); - const reB = /\*\*([A-Za-z][A-Za-z0-9 /&()+_-]{1,40}?)\*\*\s*\(?\s*`?(#[0-9a-fA-F]{3,8})/g; - while ((m = reB.exec(raw)) !== null) push(m[1], m[2]); + + // Process the file line-by-line so multi-hex entries like Linear's + // `**Marketing Black** (\`#010102\` / \`#08090a\`): role` don't confuse a + // single global regex. We extract three pieces from each candidate line: + // - the bold (or list-prefixed) name + // - the FIRST hex on the line + // - everything after the first `:` that follows the hex (the role) + for (const rawLine of raw.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + + // Pattern A: **Name** … #hex … : role description + const bold = /\*\*([A-Za-z][A-Za-z0-9 /&()+_'’-]{1,40}?)\*\*([^\n]+)/.exec(line); + if (bold) { + const rest = bold[2] ?? ''; + const hex = /#[0-9a-fA-F]{3,8}\b/.exec(rest); + if (hex) { + const after = rest.slice((hex.index ?? 0) + hex[0].length); + const colonIdx = after.search(/[::]/); + const role = colonIdx >= 0 ? after.slice(colonIdx + 1).trim() : ''; + push(bold[1], hex[0], role); + continue; + } + } + + // Pattern B: list-prefixed spec lines like + // "- Background: `#7d2ae8`" inside a ### Buttons block. + // Also handles the `- **Name:** \`#hex\`` shape (colon inside the bold + // wrapper) used by agentic/warm-editorial: the optional `\*{0,2}` slots + // before the name and after the colon let us absorb the surrounding + // `**` markers without needing a third pattern. + // Use the name itself as the role so lookups can still see "Background" + // and "Text" labels. + const spec = /^[\s>*-]*\*{0,2}([A-Za-z][^:*\n]{1,40}?)\*{0,2}\s*[::]\s*\*{0,2}\s*`?(#[0-9a-fA-F]{3,8})/.exec(line); + if (spec) { + push(spec[1], spec[2], spec[1]); + } + } + return colors; } @@ -634,45 +742,88 @@ function extractFonts(raw) { return out; } -function pickColor(colors, hints) { +function escapeRegex(s) { + return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +// Match a hint as a whole word inside `text` (case-insensitive). We use word +// boundaries so descriptive color names like "Cardinal Red" don't satisfy a +// "card" hint, and "Gem Pink" doesn't satisfy "ink" — both real bugs the +// substring-based version produced for the Duolingo and Canva showcases. +function matchesHint(text, hint) { + if (!text) return false; + const needle = hint.toLowerCase().trim(); + if (!needle) return false; + const re = new RegExp(`\\b${escapeRegex(needle)}\\b`, 'i'); + return re.test(text); +} + +function pickColor(colors, hints, exclude = []) { + // Two-pass lookup: each hint is first checked against every color's role + // description (the prose authors use to explain how the color is used) + // and only then against the bare name. This ensures a `**Snow** … Primary + // background.` line is recognised as the page background even though the + // name "Snow" doesn't contain the word "background". + // `exclude` skips colors whose hex equals an already-chosen role (e.g. + // pass `[bg]` when picking `fg`) so two roles can't collapse to the same + // hex and erase contrast. + const blocked = new Set( + exclude + .map((v) => (v == null ? '' : String(v).toLowerCase())) + .filter((v) => v.length > 0), + ); + const isAllowed = (c) => !blocked.has(c.value.toLowerCase()); for (const hint of hints) { - const needle = hint.toLowerCase(); - const found = colors.find((c) => c.name.toLowerCase().includes(needle)); - if (found) return found.value; + const byRole = colors.find((c) => isAllowed(c) && matchesHint(c.role, hint)); + if (byRole) return byRole.value; + const byName = colors.find((c) => isAllowed(c) && matchesHint(c.name, hint)); + if (byName) return byName.value; } return null; } -function firstNonNeutral(colors) { +function colorSaturation(hex) { + const v = String(hex).replace('#', '').toLowerCase(); + if (v.length !== 6) return 0; + const r = parseInt(v.slice(0, 2), 16); + const g = parseInt(v.slice(2, 4), 16); + const b = parseInt(v.slice(4, 6), 16); + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + return max === 0 ? 0 : (max - min) / max; +} + +function colorLuminance(hex) { + const v = String(hex).replace('#', '').toLowerCase(); + if (v.length !== 6) return 0.5; + const r = parseInt(v.slice(0, 2), 16); + const g = parseInt(v.slice(2, 4), 16); + const b = parseInt(v.slice(4, 6), 16); + return (0.299 * r + 0.587 * g + 0.114 * b) / 255; +} + +function firstLightish(colors) { for (const c of colors) { - const v = c.value.replace('#', '').toLowerCase(); - if (v.length !== 6) continue; - const r = parseInt(v.slice(0, 2), 16); - const g = parseInt(v.slice(2, 4), 16); - const b = parseInt(v.slice(4, 6), 16); - const max = Math.max(r, g, b); - const min = Math.min(r, g, b); - const sat = max === 0 ? 0 : (max - min) / max; - if (sat > 0.25) return c.value; + if (colorSaturation(c.value) > 0.15) continue; + if (colorLuminance(c.value) >= 0.92) return c.value; } return null; } -function secondNonNeutral(colors, exclude) { - let seen = false; +function firstNonNeutral(colors, exclude = []) { + const set = new Set(exclude.map((v) => String(v || '').toLowerCase())); for (const c of colors) { - const v = c.value.replace('#', '').toLowerCase(); - if (v.length !== 6) continue; - const r = parseInt(v.slice(0, 2), 16); - const g = parseInt(v.slice(2, 4), 16); - const b = parseInt(v.slice(4, 6), 16); - const max = Math.max(r, g, b); - const min = Math.min(r, g, b); - const sat = max === 0 ? 0 : (max - min) / max; - if (sat > 0.25) { - if (c.value === exclude || (!seen)) { seen = true; continue; } - return c.value; - } + if (set.has(c.value.toLowerCase())) continue; + if (colorSaturation(c.value) > 0.25) return c.value; + } + return null; +} + +function secondNonNeutral(colors, exclude = []) { + const set = new Set(exclude.map((v) => String(v || '').toLowerCase())); + for (const c of colors) { + if (set.has(c.value.toLowerCase())) continue; + if (colorSaturation(c.value) > 0.25) return c.value; } return null; } diff --git a/apps/daemon/tests/design-system-showcase.test.ts b/apps/daemon/tests/design-system-showcase.test.ts new file mode 100644 index 000000000..6930a10b3 --- /dev/null +++ b/apps/daemon/tests/design-system-showcase.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { extractColors } from '../src/design-system-showcase.js'; + +type Color = { name: string; value: string; role: string }; + +function findColor(colors: Color[], name: string): Color | undefined { + return colors.find((c) => c.name.toLowerCase() === name.toLowerCase()); +} + +describe('extractColors / Pattern B', () => { + it('parses `- **Name:** `#hex`` (colon inside bold) — agentic / warm-editorial shape', () => { + const md = [ + '## 2. Color', + '', + '- **Primary:** `#FF5701` — Token from style foundations.', + '- **Secondary:** `#F6F6F1` — Token from style foundations.', + '- **Surface:** `#FFFFFF` — Token from style foundations.', + '- **Text:** `#111827` — Token from style foundations.', + ].join('\n'); + + const colors = extractColors(md); + + expect(findColor(colors, 'Primary')?.value).toBe('#ff5701'); + expect(findColor(colors, 'Secondary')?.value).toBe('#f6f6f1'); + expect(findColor(colors, 'Surface')?.value).toBe('#ffffff'); + expect(findColor(colors, 'Text')?.value).toBe('#111827'); + }); + + it('parses `- Name: `#hex`` bare list shape', () => { + const md = [ + '### Buttons', + '', + '- Background: `#7d2ae8`', + '- Text: `#ffffff`', + ].join('\n'); + + const colors = extractColors(md); + + expect(findColor(colors, 'Background')?.value).toBe('#7d2ae8'); + expect(findColor(colors, 'Text')?.value).toBe('#ffffff'); + }); + + it('parses `**Name** `#hex`: role` (Duolingo / Canva shape with role suffix)', () => { + const md = [ + '## Color', + '', + '- **Owl Green** `#58CC02`: Primary brand and CTA.', + '- **Feather Blue** `#1CB0F6`: Secondary accent.', + ].join('\n'); + + const colors = extractColors(md); + + const owl = findColor(colors, 'Owl Green'); + expect(owl?.value).toBe('#58cc02'); + expect(owl?.role).toContain('Primary brand'); + + const feather = findColor(colors, 'Feather Blue'); + expect(feather?.value).toBe('#1cb0f6'); + expect(feather?.role).toContain('Secondary accent'); + }); + + it('extracts the first hex from multi-hex `**Name** (`#a` / `#b`): role` (Linear shape)', () => { + const md = '- **Marketing Black** (`#010102` / `#08090a`): Marketing surface and dark canvas.'; + + const colors = extractColors(md); + + const black = findColor(colors, 'Marketing Black'); + expect(black?.value).toBe('#010102'); + expect(black?.role).toContain('Marketing surface'); + }); +}); diff --git a/apps/web/src/components/ExamplesTab.tsx b/apps/web/src/components/ExamplesTab.tsx index 1f9f20dfb..6b00676b2 100644 --- a/apps/web/src/components/ExamplesTab.tsx +++ b/apps/web/src/components/ExamplesTab.tsx @@ -9,6 +9,7 @@ import { fetchSkillExample } from '../providers/registry'; import { exportAsHtml, exportAsPdf, exportAsZip } from '../runtime/exports'; import { buildSrcdoc } from '../runtime/srcdoc'; import type { SkillSummary, Surface } from '../types'; +import { Icon } from './Icon'; import { PreviewModal } from './PreviewModal'; type TranslateFn = (key: keyof Dict, vars?: Record) => string; @@ -108,6 +109,10 @@ export function ExamplesTab({ skills, onUsePrompt }: Props) { const [surfaceFilter, setSurfaceFilter] = useState('all'); const [modeFilter, setModeFilter] = useState('all'); const [scenarioFilter, setScenarioFilter] = useState('all'); + // Free-text search filters by skill name + description + prompt so users + // can find a known example by typing any associated word ("airbnb", + // "wireframe", "deck") without having to click through filter pills first. + const [search, setSearch] = useState(''); const [previewSkillId, setPreviewSkillId] = useState(null); const loadPreview = useCallback( @@ -177,10 +182,15 @@ export function ExamplesTab({ skills, onUsePrompt }: Props) { }, [scenarioCounts]); const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); const matched = skills.filter((s) => { if (!matchesSurface(s, surfaceFilter) || !matchesMode(s, modeFilter)) return false; - if (scenarioFilter === 'all') return true; - return (s.scenario || 'general') === scenarioFilter; + if (scenarioFilter !== 'all' && (s.scenario || 'general') !== scenarioFilter) return false; + if (!q) return true; + const desc = localizeSkillDescription(locale, s); + const prompt = localizeSkillPrompt(locale, s) || ''; + const haystack = `${s.name} ${desc} ${prompt} ${s.scenario ?? ''}`.toLowerCase(); + return haystack.includes(q); }); // Featured magazine-style examples float to the top (lower priority // number wins). Non-featured skills keep their server-side order so @@ -194,7 +204,7 @@ export function ExamplesTab({ skills, onUsePrompt }: Props) { return a.idx - b.idx; }) .map(({ s }) => s); - }, [skills, surfaceFilter, modeFilter, scenarioFilter]); + }, [skills, surfaceFilter, modeFilter, scenarioFilter, search, locale]); if (skills.length === 0) { return
{t('examples.emptyNoSkills')}
; @@ -203,6 +213,18 @@ export function ExamplesTab({ skills, onUsePrompt }: Props) { return (
+
+ + + + setSearch(e.target.value)} + placeholder={t('examples.searchPlaceholder')} + aria-label={t('examples.searchAria')} + /> +