diff --git a/README.md b/README.md index 60c3258..2cce2b1 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ List available style guide presets. No params. Returns preset names and descript ### `apply_preset` -Apply a style guide preset to a canvas. Merges preset design tokens into the canvas variables. +Apply a style guide preset to a canvas. Merges preset design tokens into the canvas variables, and copies in any reusable components (`button`, `card`, `badge`) the preset defines so they can be instanced. | Param | Type | Description | |-------|------|-------------| @@ -156,7 +156,7 @@ Apply a style guide preset to a canvas. Merges preset design tokens into the can ### `import_design_md` -Import a [DESIGN.md](https://github.com/VoltAgent/awesome-design-md) file as a design system preset. Parses the Google Stitch format and extracts colors, typography, spacing, and border radius. After importing, use `apply_preset` to apply it to any canvas. +Import a [DESIGN.md](https://github.com/VoltAgent/awesome-design-md) file as a design system preset. Parses the Google Stitch format and extracts colors, typography, spacing, and border radius. It also extracts reusable component skeletons (`button`, `card`, `badge`) from the "Component Styling" section — `apply_preset` then makes them available as instanceable components on the canvas. After importing, use `apply_preset` to apply it to any canvas. | Param | Type | Description | |-------|------|-------------| diff --git a/VISION.md b/VISION.md index aed85a2..ae82b19 100644 --- a/VISION.md +++ b/VISION.md @@ -230,7 +230,7 @@ Reference in nodes: `"color": "$text-primary"`, `"gap": "$spacing.md"` - [x] Responsive font scaling — `clamp()` so fonts >= 24px shrink on smaller breakpoints - [x] Viewer navbar adaptation — detail-page toolbar wraps to two rows on viewports <= 640px - [x] DESIGN.md parser: filter out non-color values (e.g. full box-shadow strings) from colors map -- [ ] DESIGN.md parser: extract component patterns (buttons, cards, badges) as reusable canvas components +- [x] DESIGN.md parser: extract component patterns (buttons, cards, badges) as reusable canvas components ### Phase 5 — Evaluation & AI Loops (v0.5) - [x] Heuristic design scoring (`canvas_evaluate`) — 5 weighted categories (spacing, color, typography, structure, consistency), 0–100 overall score diff --git a/src/design-md-parser.ts b/src/design-md-parser.ts index 0f4a7f0..4342db3 100644 --- a/src/design-md-parser.ts +++ b/src/design-md-parser.ts @@ -1,4 +1,4 @@ -import type { DesignVariables } from './types.js'; +import type { DesignVariables, SceneNode } from './types.js'; import type { Preset } from './presets.js'; /** @@ -12,6 +12,7 @@ export function parseDesignMd(content: string, name?: string): Preset { const spacing = extractSpacing(content); const radius = extractRadius(content); const description = extractDescription(content); + const components = extractComponents(content); const variables: DesignVariables = {}; if (Object.keys(colors).length) variables.colors = colors; @@ -19,11 +20,13 @@ export function parseDesignMd(content: string, name?: string): Preset { if (Object.keys(spacing).length) variables.spacing = spacing; if (Object.keys(radius).length) variables.radius = radius; - return { + const preset: Preset = { name: slugify(systemName), description: description || `Design system: ${systemName}`, variables, }; + if (Object.keys(components).length) preset.components = components; + return preset; } function extractName(content: string): string { @@ -243,6 +246,186 @@ function extractRadius(content: string): Record { return radius; } +/** + * Extract reusable component skeletons (button, card, badge) from the + * "Component Styling" section. Best-effort: pulls whatever fill / text + * color / padding / radius / border / font props it can find and falls + * back to sensible defaults for the rest. Components not described in + * the DESIGN.md are simply omitted. + */ +function extractComponents(content: string): Record { + const components: Record = {}; + + const buttonChunk = getComponentChunk(content, 'button'); + if (buttonChunk) components.button = buildButton(extractStyleProps(buttonChunk)); + + const cardChunk = getComponentChunk(content, 'card'); + if (cardChunk) components.card = buildCard(extractStyleProps(cardChunk)); + + const badgeChunk = getComponentChunk(content, 'badge'); + if (badgeChunk) components.badge = buildBadge(extractStyleProps(badgeChunk)); + + return components; +} + +/** Find the text describing one component — a sub-section heading if present, else a list item. */ +function getComponentChunk(content: string, keyword: string): string | null { + const sub = getSection(content, keyword); + if (sub) return sub; + + const compSection = getSection(content, 'Component Styling') || getSection(content, 'Component'); + if (!compSection) return null; + + const lines = compSection.split('\n'); + const re = new RegExp(keyword, 'i'); + const idx = lines.findIndex((l) => re.test(l)); + if (idx === -1) return null; + + // Grab the matching line plus any continuation lines until the next list item / heading / blank. + const chunk = [lines[idx]]; + for (let i = idx + 1; i < lines.length; i++) { + if (lines[i].trim() === '' || /^#{1,6}\s/.test(lines[i]) || /^\s*[-*]\s/.test(lines[i])) break; + chunk.push(lines[i]); + } + return chunk.join('\n'); +} + +interface StyleProps { + fill?: string; + color?: string; + padding?: number | [number, number]; + cornerRadius?: number; + stroke?: string; + strokeWidth?: number; + fontSize?: number; + fontWeight?: number; +} + +const COLOR_TOKEN = '#[0-9a-f]{3,8}|rgba?\\([^)]*\\)|hsla?\\([^)]*\\)'; + +/** Pull CSS-ish style properties out of a free-text component description. */ +function extractStyleProps(text: string): StyleProps { + const props: StyleProps = {}; + + const bg = text.match(new RegExp(`(?:background(?:-color)?|fill)[^#\\n]{0,30}?(${COLOR_TOKEN})`, 'i')); + if (bg) props.fill = bg[1]; + + const tc = text.match(new RegExp(`text(?:\\s+colou?r)?[^#\\n]{0,30}?(${COLOR_TOKEN})`, 'i')); + if (tc) props.color = tc[1]; + + const padTwo = text.match(/padding[^\d\n]{0,20}?(\d+)\s*px\s+(\d+)\s*px/i); + const padOne = text.match(/padding[^\d\n]{0,20}?(\d+)\s*px/i); + if (padTwo) props.padding = [parseInt(padTwo[1], 10), parseInt(padTwo[2], 10)]; + else if (padOne) props.padding = parseInt(padOne[1], 10); + + if (/(?:border-)?radius[^\d\n]{0,15}?9999|pill|fully?\s+rounded/i.test(text)) { + props.cornerRadius = 9999; + } else { + const r = text.match(/(?:border-)?radius[^\d\n]{0,15}?(\d+)\s*px/i); + if (r) props.cornerRadius = parseInt(r[1], 10); + } + + const border = text.match(new RegExp(`border[^\\d\\n]{0,15}?(\\d+)\\s*px\\s+solid\\s+(${COLOR_TOKEN})`, 'i')); + if (border) { + props.strokeWidth = parseInt(border[1], 10); + props.stroke = border[2]; + } + + const fs = text.match(/font-?size[^\d\n]{0,15}?(\d+)\s*px/i); + if (fs) props.fontSize = parseInt(fs[1], 10); + + const fw = text.match(/(?:font-?weight|weight)[^\d\n]{0,15}?(\d{3})/i); + if (fw) props.fontWeight = parseInt(fw[1], 10); + + return props; +} + +function buildButton(p: StyleProps): SceneNode { + return { + id: 'button', + type: 'component', + name: 'button', + layout: 'horizontal', + alignItems: 'center', + justifyContent: 'center', + padding: p.padding ?? [12, 24], + fill: p.fill ?? '#3b82f6', + cornerRadius: p.cornerRadius ?? 8, + ...(p.stroke ? { stroke: p.stroke, strokeWidth: p.strokeWidth ?? 1 } : {}), + children: [ + { + id: 'button-label', + type: 'text', + name: 'label', + content: 'Button', + color: p.color ?? '#ffffff', + fontSize: p.fontSize ?? 14, + fontWeight: p.fontWeight ?? 600, + }, + ], + }; +} + +function buildCard(p: StyleProps): SceneNode { + return { + id: 'card', + type: 'component', + name: 'card', + layout: 'vertical', + gap: 8, + width: 320, + padding: p.padding ?? 24, + fill: p.fill ?? '#1a1a1a', + cornerRadius: p.cornerRadius ?? 12, + ...(p.stroke ? { stroke: p.stroke, strokeWidth: p.strokeWidth ?? 1 } : {}), + children: [ + { + id: 'card-title', + type: 'text', + name: 'title', + content: 'Card title', + color: p.color ?? '#ffffff', + fontSize: p.fontSize ?? 18, + fontWeight: p.fontWeight ?? 600, + }, + { + id: 'card-body', + type: 'text', + name: 'body', + content: 'Card body text goes here.', + color: p.color ?? '#ffffffa0', + fontSize: 14, + }, + ], + }; +} + +function buildBadge(p: StyleProps): SceneNode { + return { + id: 'badge', + type: 'component', + name: 'badge', + layout: 'horizontal', + alignItems: 'center', + justifyContent: 'center', + padding: p.padding ?? [4, 10], + fill: p.fill ?? '#3b82f6', + cornerRadius: p.cornerRadius ?? 9999, + ...(p.stroke ? { stroke: p.stroke, strokeWidth: p.strokeWidth ?? 1 } : {}), + children: [ + { + id: 'badge-label', + type: 'text', + name: 'label', + content: 'Badge', + color: p.color ?? '#ffffff', + fontSize: p.fontSize ?? 12, + fontWeight: p.fontWeight ?? 500, + }, + ], + }; +} + // --- Helpers --- /** Get a section by partial heading match (## N. Title) */ diff --git a/src/index.ts b/src/index.ts index e85ca5a..d16c4ec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -357,7 +357,7 @@ server.tool( // --- apply_preset --- server.tool( 'apply_preset', - 'Apply a style guide preset to a canvas. Merges preset design tokens into the canvas variables.', + 'Apply a style guide preset to a canvas. Merges preset design tokens into the canvas variables, and copies in any reusable components (button, card, badge) the preset defines.', { canvasId: z.string().describe('Canvas ID'), preset: z.string().describe('Preset name (dark, light, material, minimal)'), @@ -370,15 +370,24 @@ server.tool( if (!p) return { content: [{ type: 'text', text: `Error: Preset "${preset}" not found. Use list_presets to see available presets.` }], isError: true }; const result = setVariables(canvas, p.variables); + + const components: string[] = []; + if (p.components) { + for (const [key, node] of Object.entries(p.components)) { + canvas.components[key] = structuredClone(node); + components.push(key); + } + } + touchCanvas(canvasId); - return { content: [{ type: 'text', text: JSON.stringify({ applied: preset, variables: result }, null, 2) }] }; + return { content: [{ type: 'text', text: JSON.stringify({ applied: preset, variables: result, components }, null, 2) }] }; } ); // --- import_design_md --- server.tool( 'import_design_md', - `Import a DESIGN.md file as a design system preset. Parses the Google Stitch / awesome-design-md format and extracts colors, typography, spacing, and border radius into a reusable preset. After importing, use apply_preset to apply it to any canvas. Accepts either a file path or raw DESIGN.md content.`, + `Import a DESIGN.md file as a design system preset. Parses the Google Stitch / awesome-design-md format and extracts colors, typography, spacing, border radius, and reusable component skeletons (button, card, badge) into a preset. After importing, use apply_preset to apply it to any canvas. Accepts either a file path or raw DESIGN.md content.`, { content: z.string().optional().describe('Raw DESIGN.md content. Provide this OR filePath.'), filePath: z.string().optional().describe('Absolute path to a DESIGN.md file. Provide this OR content.'), @@ -415,7 +424,8 @@ server.tool( spacing: Object.keys(preset.variables.spacing || {}), radius: Object.keys(preset.variables.radius || {}), }, - usage: `Use apply_preset with preset="${preset.name}" to apply this design system to a canvas.`, + components: Object.keys(preset.components || {}), + usage: `Use apply_preset with preset="${preset.name}" to apply this design system (tokens + components) to a canvas.`, }, null, 2), }], }; diff --git a/src/presets.ts b/src/presets.ts index 5f246f7..51c772f 100644 --- a/src/presets.ts +++ b/src/presets.ts @@ -1,9 +1,11 @@ -import type { DesignVariables } from './types.js'; +import type { DesignVariables, SceneNode } from './types.js'; export interface Preset { name: string; description: string; variables: DesignVariables; + /** Reusable component skeletons (button, card, badge) keyed by slug. */ + components?: Record; } const dark: Preset = { diff --git a/test-design-md.ts b/test-design-md.ts index 93ab1c4..bd396ff 100644 --- a/test-design-md.ts +++ b/test-design-md.ts @@ -1,8 +1,10 @@ /** - * DESIGN.md parser smoke test — focused on the colors-map filter. + * DESIGN.md parser smoke test — colors-map filter + component extraction. * Run: npx tsx test-design-md.ts */ import { parseDesignMd } from './src/design-md-parser.js'; +import { renderToHtml } from './src/renderer.js'; +import type { Canvas, SceneNode } from './src/types.js'; let passed = 0; let failed = 0; @@ -28,6 +30,17 @@ const md = `# Design System: Test - **Duo** (\`#fff, #000\`) - **Bad Hex** (\`#12345\`) - **Spacing Note** (\`8px\`) + +## 6. Component Stylings + +### Buttons +Primary buttons use background \`#3b82f6\` with text color \`#ffffff\`, padding \`12px 24px\`, border-radius \`8px\`, font-size \`15px\`, font-weight \`600\`. + +### Cards +Cards have a background \`#1a1a1a\`, padding \`24px\`, border-radius \`12px\`, and a border \`1px solid #2a2a2a\`. + +### Badges +Badges use background \`#22c55e\`, text \`#052e16\`, padding \`4px 10px\`, fully rounded pill radius, font-size \`12px\`. `; const preset = parseDesignMd(md, 'Test'); @@ -50,5 +63,42 @@ assert(!('duo' in colors), 'comma-separated color list rejected'); assert(!('bad-hex' in colors), 'malformed 5-digit hex rejected'); assert(!('spacing-note' in colors), 'non-color (8px) rejected'); +// Component extraction +const components = preset.components ?? {}; +const button = components.button; +const card = components.card; +const badge = components.badge; + +assert(!!button && button.type === 'component', 'button component extracted'); +assert(button?.fill === '#3b82f6', 'button fill parsed'); +assert(JSON.stringify(button?.padding) === '[12,24]', 'button padding parsed'); +assert(button?.cornerRadius === 8, 'button radius parsed'); +assert(button?.children?.[0]?.color === '#ffffff', 'button label color parsed'); +assert(button?.children?.[0]?.fontSize === 15, 'button font-size parsed'); + +assert(!!card && card.type === 'component', 'card component extracted'); +assert(card?.fill === '#1a1a1a', 'card fill parsed'); +assert(card?.padding === 24, 'card padding parsed'); +assert(card?.stroke === '#2a2a2a' && card?.strokeWidth === 1, 'card border parsed'); + +assert(!!badge && badge.type === 'component', 'badge component extracted'); +assert(badge?.fill === '#22c55e', 'badge fill parsed'); +assert(badge?.cornerRadius === 9999, 'badge pill radius parsed'); + +// Components resolve when instanced — mirrors what apply_preset + an instance node do. +const root: SceneNode = { + id: 'root', type: 'document', width: 400, height: 200, children: [ + { id: 'i1', type: 'instance', componentId: 'button' }, + ], +}; +const canvas: Canvas = { + id: 'c', name: 'c', root, variables: {}, + components: { button: button!, card: card!, badge: badge! }, + createdAt: '', lastModified: '', +}; +const html = renderToHtml(root, 400, 200, canvas); +assert(html.includes('background-color: #3b82f6'), 'instanced button renders its fill'); +assert(html.includes('Button'), 'instanced button renders its label'); + console.log(`\n${passed}/${passed + failed} checks passed`); process.exit(failed ? 1 : 0);