Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|-------|------|-------------|
Expand All @@ -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 |
|-------|------|-------------|
Expand Down
2 changes: 1 addition & 1 deletion VISION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
187 changes: 185 additions & 2 deletions src/design-md-parser.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { DesignVariables } from './types.js';
import type { DesignVariables, SceneNode } from './types.js';
import type { Preset } from './presets.js';

/**
Expand All @@ -12,18 +12,21 @@ 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;
if (Object.keys(typography).length) variables.typography = typography;
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 {
Expand Down Expand Up @@ -243,6 +246,186 @@ function extractRadius(content: string): Record<string, number> {
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<string, SceneNode> {
const components: Record<string, SceneNode> = {};

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) */
Expand Down
18 changes: 14 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)'),
Expand All @@ -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.'),
Expand Down Expand Up @@ -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),
}],
};
Expand Down
4 changes: 3 additions & 1 deletion src/presets.ts
Original file line number Diff line number Diff line change
@@ -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<string, SceneNode>;
}

const dark: Preset = {
Expand Down
52 changes: 51 additions & 1 deletion test-design-md.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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');
Expand All @@ -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);