feat(daemon): parse DESIGN.md YAML frontmatter for presets (#2110)

* feat(daemon): parse DESIGN.md YAML frontmatter for presets

Falls back to frontmatter name/description/category/surface behind
existing Markdown heuristics; frontmatter `colors` wins over Markdown
swatches.

Closes #1857
Contract: runs/2026-05-18T08-09-54_open-design_issue-feat/contract.md

* test(daemon): cover DESIGN.md frontmatter fallback chain

* fix(daemon): keep Markdown swatch row when frontmatter colors miss slots

Material Design 3 token names (`on-surface`, `outline`) in totality-
festival's frontmatter left fg/support unmatched, regressing its picker
swatches; fall back to Markdown when the frontmatter row would use hard-
coded defaults.

Refs: https://github.com/nexu-io/open-design/pull/2110#discussion_r3258871692
This commit is contained in:
Ethan Guo 2026-05-19 13:52:11 +08:00 committed by GitHub
parent 226bc57471
commit 477dd627b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 323 additions and 18 deletions

View file

@ -3,6 +3,11 @@
// with only DESIGN.md remain valid. Without a manifest, title comes from the
// first H1, category from a `> Category: <name>` blockquote line beneath the
// H1, and summary from the first paragraph between the H1 and next heading.
//
// YAML frontmatter (Google spec, issue #1857): frontmatter `colors` wins
// over Markdown swatches only when its row fills every semantic slot;
// otherwise Markdown wins. Other fields (`name`/`description`/`category`/
// `surface`) fall back to frontmatter when the body has none.
import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
@ -12,6 +17,9 @@ import {
summarizeComponentsManifestForPrompt,
} from '@open-design/contracts';
import { parseFrontmatter } from './frontmatter.js';
import type { FrontmatterObject, FrontmatterValue } from './frontmatter.js';
export type DesignSystemSurface = 'web' | 'image' | 'video' | 'audio';
export type DesignSystemSummary = {
@ -25,6 +33,7 @@ export type DesignSystemSummary = {
};
type ColorToken = { name: string; value: string };
type SwatchRow = { values: string[]; filledAllSlots: boolean };
type DesignSystemProjectManifest = {
schemaVersion: 'od-design-system-project/v1';
id: string;
@ -55,15 +64,34 @@ export async function listDesignSystems(root: string): Promise<DesignSystemSumma
const stats = await stat(designPath);
if (!stats.isFile()) continue;
const raw = await readFile(designPath, 'utf8');
const titleMatch = /^#\s+(.+?)\s*$/m.exec(raw);
const title = manifest?.name ?? cleanTitle(titleMatch?.[1] ?? entry.name);
const { data: frontmatter, body } = parseFrontmatter(raw);
const titleMatch = /^#\s+(.+?)\s*$/m.exec(body);
const markdownTitle =
titleMatch?.[1] !== undefined ? cleanTitle(titleMatch[1]) : '';
const localTitle =
markdownTitle || stringField(frontmatter, 'name') || entry.name;
const title = manifest?.name ?? localTitle;
const markdownSummary = summarize(body);
const markdownSwatches = extractSwatches(body);
const frontmatterSwatchRow = swatchesFromFrontmatter(frontmatter);
const swatches = pickFinalSwatchRow(frontmatterSwatchRow, markdownSwatches);
out.push({
id: entry.name,
title,
category: manifest?.category ?? extractCategory(raw) ?? 'Uncategorized',
summary: manifest?.description?.trim() || summarize(raw),
swatches: extractSwatches(raw),
surface: extractSurface(raw),
category:
manifest?.category
?? extractCategory(body)
?? stringField(frontmatter, 'category')
?? 'Uncategorized',
summary:
(manifest?.description?.trim() || markdownSummary)
|| stringField(frontmatter, 'description')
|| '',
swatches,
surface:
extractSurface(body)
?? frontmatterSurface(frontmatter)
?? 'web',
body: raw,
});
} catch {
@ -73,6 +101,44 @@ export async function listDesignSystems(root: string): Promise<DesignSystemSumma
return out;
}
function stringField(data: FrontmatterObject, key: string): string {
const v: FrontmatterValue | undefined = data[key];
return typeof v === 'string' ? v.trim() : '';
}
function frontmatterSurface(data: FrontmatterObject): DesignSystemSurface | undefined {
const v = stringField(data, 'surface').toLowerCase();
return isDesignSystemSurface(v) ? v : undefined;
}
function swatchesFromFrontmatter(data: FrontmatterObject): SwatchRow | null {
const raw = data['colors'];
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null;
const colors: ColorToken[] = [];
const seen = new Set<string>();
for (const [name, value] of Object.entries(raw)) {
if (typeof value !== 'string') continue;
const hex = normalizeHex(value);
if (!hex) continue;
const cleanName = name.replace(/\s+/g, ' ').trim().toLowerCase();
const key = `${cleanName}|${hex}`;
if (seen.has(key)) continue;
seen.add(key);
colors.push({ name: cleanName, value: hex });
}
if (colors.length === 0) return null;
return pickSwatchRow(colors);
}
function pickFinalSwatchRow(
frontmatter: SwatchRow | null,
markdownSwatches: string[],
): string[] {
if (frontmatter !== null && frontmatter.filledAllSlots) return frontmatter.values;
if (markdownSwatches.length > 0) return markdownSwatches;
return frontmatter?.values ?? [];
}
export async function readDesignSystem(root: string, id: string): Promise<string | null> {
const brandRoot = path.join(root, id);
const manifest = await readProjectManifest(brandRoot, id);
@ -309,11 +375,11 @@ function extractCategory(raw: string): string | undefined {
}
const KNOWN_SURFACES = new Set<DesignSystemSurface>(['web', 'image', 'video', 'audio']);
function extractSurface(raw: string): DesignSystemSurface {
function extractSurface(raw: string): DesignSystemSurface | undefined {
const m = /^>\s*Surface:\s*(.+?)\s*$/im.exec(raw);
if (!m) return 'web';
if (!m) return undefined;
const v = m[1]?.trim().toLowerCase();
return isDesignSystemSurface(v) ? v : 'web';
return isDesignSystemSurface(v) ? v : undefined;
}
function isDesignSystemSurface(value: string | undefined): value is DesignSystemSurface {
@ -365,7 +431,10 @@ function extractSwatches(raw: string): string[] {
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] ?? '');
if (colors.length === 0) return [];
return pickSwatchRow(colors).values;
}
function pickSwatchRow(colors: ColorToken[]): SwatchRow {
function pick(hints: string[]): string | null {
for (const h of hints) {
const found = colors.find((c) => c.name.includes(h));
@ -381,25 +450,28 @@ function extractSwatches(raw: string): string[] {
return Math.max(r, g, b) - Math.min(r, g, b) < 10;
}
const bg =
pick(['page background', 'background', 'canvas', 'paper', 'surface'])
?? '#ffffff';
const fg =
pick(['heading', 'foreground', 'ink', 'fg', 'text', 'navy', 'graphite'])
?? '#111111';
const bgHit = pick(['page background', 'background', 'canvas', 'paper', 'surface']);
const fgHit = pick(['heading', 'foreground', 'ink', 'fg', 'text', 'navy', 'graphite']);
const accentHit = pick(['primary brand', 'brand primary', 'accent', 'brand', 'primary']);
const supportHit = pick(['border', 'divider', 'rule', 'muted', 'secondary', 'subtle']);
const bg = bgHit ?? '#ffffff';
const fg = fgHit ?? '#111111';
const accent =
pick(['primary brand', 'brand primary', 'accent', 'brand', 'primary'])
accentHit
?? colors.find((c) => !isNeutral(c.value))?.value
?? colors[0]?.value
?? '#888888';
const support =
pick(['border', 'divider', 'rule', 'muted', 'secondary', 'subtle'])
supportHit
?? colors.find(
(c) => isNeutral(c.value) && c.value !== bg && c.value !== fg,
)?.value
?? '#cccccc';
return [bg, support, fg, accent];
const filledAllSlots =
bgHit !== null && fgHit !== null && accentHit !== null && supportHit !== null;
return { values: [bg, support, fg, accent], filledAllSlots };
}
function normalizeHex(raw: string): string | null {

View file

@ -0,0 +1,233 @@
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { listDesignSystems } from '../src/design-systems.js';
function fresh(): string {
return mkdtempSync(path.join(tmpdir(), 'od-design-systems-frontmatter-'));
}
function brandDir(root: string, id: string): string {
const dir = path.join(root, id);
mkdirSync(dir, { recursive: true });
return dir;
}
function writeDesignMd(root: string, id: string, body: string): void {
const dir = brandDir(root, id);
writeFileSync(path.join(dir, 'DESIGN.md'), body);
}
describe('listDesignSystems frontmatter parsing (issue #1857)', () => {
it('uses frontmatter name/description/category/surface when no Markdown equivalents are present', async () => {
const root = fresh();
writeDesignMd(
root,
'google-only',
[
'---',
'name: Google Material',
'description: A clean material-inspired system.',
'category: Productivity',
'surface: web',
'---',
'',
'Some rationale text without an H1 or Category blockquote.',
].join('\n'),
);
const out = await listDesignSystems(root);
expect(out).toHaveLength(1);
const ds = out[0]!;
expect(ds.id).toBe('google-only');
expect(ds.title).toBe('Google Material');
expect(ds.category).toBe('Productivity');
expect(ds.summary).toBe('A clean material-inspired system.');
expect(ds.surface).toBe('web');
});
it('extracts swatches from frontmatter colors when the Markdown body has no hex swatches', async () => {
const root = fresh();
writeDesignMd(
root,
'colors-only',
[
'---',
'name: Colors Only',
'description: Frontmatter-driven palette.',
'colors:',
' background: "#fafafa"',
' text: "#111111"',
' accent: "#ff3366"',
' border: "#dddddd"',
'---',
'',
'Body prose with no hex codes at all.',
].join('\n'),
);
const [ds] = await listDesignSystems(root);
expect(ds?.swatches).toEqual(['#fafafa', '#dddddd', '#111111', '#ff3366']);
});
it('returns identical summary shape for legacy Markdown-only DESIGN.md (no frontmatter, regression guard)', async () => {
const root = fresh();
writeDesignMd(
root,
'legacy',
[
'# Design System Inspired by Legacy',
'',
'> Category: Productivity',
'',
'A productivity-oriented system used by the picker today.',
'',
'- **Background:** `#ffffff`',
'- **Text:** `#222222`',
'- **Accent:** `#ff3366`',
'- **Border:** `#dddddd`',
].join('\n'),
);
const [ds] = await listDesignSystems(root);
expect(ds?.title).toBe('Legacy');
expect(ds?.category).toBe('Productivity');
expect(ds?.summary).toBe('A productivity-oriented system used by the picker today.');
expect(ds?.swatches).toEqual(['#ffffff', '#dddddd', '#222222', '#ff3366']);
expect(ds?.surface).toBe('web');
});
it('prefers frontmatter colors over Markdown swatches when both are present', async () => {
const root = fresh();
writeDesignMd(
root,
'hybrid-colors',
[
'---',
'colors:',
' background: "#fafafa"',
' text: "#111111"',
' accent: "#ff3366"',
' border: "#dddddd"',
'---',
'',
'# Hybrid Colors',
'',
'> Category: Productivity',
'',
'Body with explicit swatches.',
'',
'- **Background:** `#000000`',
'- **Text:** `#ffffff`',
'- **Accent:** `#abcdef`',
'- **Border:** `#999999`',
].join('\n'),
);
const [ds] = await listDesignSystems(root);
expect(ds?.swatches).toEqual(['#fafafa', '#dddddd', '#111111', '#ff3366']);
});
it('falls back to Markdown swatches when frontmatter colors use unrecognized token names (totality-festival regression)', async () => {
const root = fresh();
writeDesignMd(
root,
'totality-style',
[
'---',
'colors:',
' surface: "#121318"',
' on-surface: "#e3e1e9"',
' on-surface-variant: "#d0c6ab"',
' outline: "#999077"',
' primary: "#fff6df"',
' secondary: "#bdf4ff"',
' background: "#121318"',
'---',
'',
'# Design System Inspired by Totality',
'',
'> Category: Themed & Unique',
'',
'Cosmic-premium dark system.',
'',
'- **Surface:** `#121318`',
'- **Text:** `#e3e1e9`',
'- **Text Muted:** `#d0c6ab`',
'- **Primary:** `#fff6df`',
].join('\n'),
);
const [ds] = await listDesignSystems(root);
expect(ds?.swatches).toEqual(['#121318', '#d0c6ab', '#e3e1e9', '#fff6df']);
});
it('prefers Markdown H1 and Markdown Category over frontmatter when a hybrid file has both', async () => {
const root = fresh();
writeDesignMd(
root,
'hybrid',
[
'---',
'name: Frontmatter Title',
'description: Frontmatter summary that loses to Markdown.',
'category: Frontmatter Category',
'---',
'',
'# Markdown H1 Title',
'',
'> Category: Markdown Category',
'',
'Markdown summary paragraph wins.',
].join('\n'),
);
const [ds] = await listDesignSystems(root);
expect(ds?.title).toBe('Markdown H1 Title');
expect(ds?.category).toBe('Markdown Category');
expect(ds?.summary).toBe('Markdown summary paragraph wins.');
});
it('does not throw on malformed or empty frontmatter and still surfaces the brand from body heuristics', async () => {
const root = fresh();
writeDesignMd(
root,
'malformed',
[
'---',
'---',
'',
'# Body Only',
'',
'> Category: From Body',
'',
'Body summary line.',
].join('\n'),
);
const [ds] = await listDesignSystems(root);
expect(ds?.title).toBe('Body Only');
expect(ds?.category).toBe('From Body');
expect(ds?.summary).toBe('Body summary line.');
});
it('keeps the body field as the verbatim file content including the frontmatter delimiters', async () => {
const root = fresh();
const raw = [
'---',
'name: Verbatim Body',
'description: Body must include the frontmatter.',
'---',
'',
'Trailing prose.',
'',
].join('\n');
writeDesignMd(root, 'verbatim', raw);
const [ds] = await listDesignSystems(root);
expect(ds?.body).toBe(raw);
});
});