From e49d1eea49c0a624981fdbd71f761c5f583c6111 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 8 Sep 2026 16:22:25 +0000 Subject: [PATCH] feat(lib): open DESIGN.md reader/writer + gstack-design-md bin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/design-md.ts implements the open DESIGN.md format (google-labs-code/ design.md, Apache-2.0): YAML front matter with the five token groups (colors, typography, rounded, spacing, components) and eight canonical `##` sections in spec order (Overview, Colors, Typography, Layout, Elevation & Depth, Shapes, Components, Do's and Don'ts), aliases mapped, extras preserved after them in their original order. parseDesignMd never throws (unparsable front matter → `unknown` with a reason); renderDesignMd re-emits the preserved front matter bytes and only `convert` writes fresh YAML through a small block-style emitter (Bun.YAML.stringify is flow style); upsertSection splices the body only; tokensFlat resolves `{path}` references to primitives and reports group, self, dangling, and cyclic refs as DESIGN_MD_TOKEN_REF_INVALID. convertLegacy turns gstack's pre-spec DESIGN.md into the open format: Product Context and Aesthetic Direction fold into Overview, Typography roles become display/body/label/mono tokens (mono carries fontFeature: tnum), Color hexes become colors (mode-qualified labels keep their qualifier; strategy lines are not colors), the Spacing scale and Layout radii become spacing and rounded, Motion / Grain Texture / Decisions Log survive as extras. The format marker lives inside the file: a YAML comment on line 2 of a spec file, an HTML comment on line 1 of a legacy file. bin/gstack-design-md.ts: `check` (DESIGN_MD_FORMAT + marker), `convert [--write]` (backup to DESIGN.md.legacy.bak, temp+rename, refuses ambiguous input with DESIGN_MD_CONVERT_REFUSED), `tokens` (flat JSON), `mark `. Exit 3 + DESIGN_MD_INTERNAL_ERROR is a gstack bug. design/src/memory.ts: updateDesignMd upserts "Extracted Design Language" through the lib (front matter bytes untouched, canonical order kept, section replaced on rerun) and creates a spec skeleton with tokens from the extraction when no file exists; readDesignConstraints leads with the flat tokens and the Overview for spec files. The design binary still bundles. test/design-md.test.ts pins all of it against gstack's own DESIGN.md. Co-Authored-By: Claude Fable 5.1 --- bin/gstack-design-md.ts | 117 +++++++++++ design/src/memory.ts | 73 +++++-- lib/design-md.ts | 423 ++++++++++++++++++++++++++++++++++++++++ test/design-md.test.ts | 358 ++++++++++++++++++++++++++++++++++ 4 files changed, 950 insertions(+), 21 deletions(-) create mode 100755 bin/gstack-design-md.ts create mode 100644 lib/design-md.ts create mode 100644 test/design-md.test.ts diff --git a/bin/gstack-design-md.ts b/bin/gstack-design-md.ts new file mode 100755 index 000000000..4b007c96c --- /dev/null +++ b/bin/gstack-design-md.ts @@ -0,0 +1,117 @@ +#!/usr/bin/env bun +/** + * gstack-design-md — inspect, convert, and read DESIGN.md in the open format. + * + * bun --no-env-file run ~/.claude/skills/gstack/bin/gstack-design-md.ts check [DESIGN.md] + * bun --no-env-file run ~/.claude/skills/gstack/bin/gstack-design-md.ts convert [DESIGN.md] [--write] + * bun --no-env-file run ~/.claude/skills/gstack/bin/gstack-design-md.ts tokens [DESIGN.md] + * bun --no-env-file run ~/.claude/skills/gstack/bin/gstack-design-md.ts mark [DESIGN.md] + * + * check DESIGN_MD_FORMAT: spec | legacy | unknown | missing (+ DESIGN_MD_REASON for unknown), + * DESIGN_MD_MARKER: spec | legacy-keep | none. Exit 0. + * convert Legacy → spec (lib/design-md.ts convertLegacy). Prints the result; with --write, + * backs the original up to DESIGN.md.legacy.bak and writes temp+rename. Refuses an + * ambiguous file (DESIGN_MD_CONVERT_REFUSED, exit 2) and a non-legacy one (exit 1). + * tokens Flat token map as JSON ({"colors.primary": "#F59E0B", ...}); {path} refs resolved; + * invalid refs listed under "errors" (DESIGN_MD_TOKEN_REF_INVALID). Exit 0. + * mark Persist the user's one-time format choice inside the file: spec files get a YAML + * comment on line 2, legacy files an HTML comment on line 1. Body bytes untouched. + * + * Exit 3 + DESIGN_MD_INTERNAL_ERROR is a gstack bug. YAML errors never propagate: a file whose + * front matter does not parse is `unknown` with a reason. + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { SENTINEL } from '../lib/design-detect-contract'; +import { + parseDesignMd, detectFormat, convertLegacy, renderDesignMd, tokensFlat, setMarker, + type DesignMdDoc, type FormatChoice, +} from '../lib/design-md'; + +function resolveFile(arg?: string): string { + return path.resolve(arg && !arg.startsWith('--') ? arg : 'DESIGN.md'); +} + +function load(file: string): { text: string; doc: DesignMdDoc } | null { + try { const text = fs.readFileSync(file, 'utf-8'); return { text, doc: parseDesignMd(text) }; } catch { return null; } +} + +function writeAtomic(file: string, content: string) { + const tmp = `${file}.tmp-${process.pid}`; + fs.writeFileSync(tmp, content); + fs.renameSync(tmp, file); +} + +export function main(argv = process.argv.slice(2)): number { + const verb = argv[0] ?? ''; + const flags = new Set(argv.filter(a => a.startsWith('--'))); + const positional = argv.slice(1).filter(a => !a.startsWith('--')); + + switch (verb) { + case 'check': { + const file = resolveFile(positional[0]); + const loaded = load(file); + const { format, reason } = detectFormat(loaded?.doc ?? null); + process.stdout.write(`${SENTINEL.DESIGN_MD_FORMAT}: ${format}\n`); + if (reason) process.stdout.write(`DESIGN_MD_REASON: ${reason}\n`); + process.stdout.write(`DESIGN_MD_MARKER: ${loaded?.doc.marker ?? 'none'}\n`); + return 0; + } + case 'convert': { + const file = resolveFile(positional[0]); + const loaded = load(file); + const { format, reason } = detectFormat(loaded?.doc ?? null); + if (format === 'unknown' && reason?.startsWith('ambiguous')) { + process.stderr.write(`${SENTINEL.DESIGN_MD_CONVERT_REFUSED}: ${reason}\n`); + return 2; + } + if (format !== 'legacy' || !loaded) { + process.stderr.write(`${SENTINEL.DESIGN_MD_FORMAT}: ${format}${reason ? ` (${reason})` : ''}; convert only accepts a legacy gstack DESIGN.md\n`); + return 1; + } + const out = renderDesignMd(convertLegacy(loaded.doc), { emitFrontmatter: true }); + if (flags.has('--write')) { + fs.writeFileSync(`${file}.legacy.bak`, loaded.text); + writeAtomic(file, out); + process.stdout.write(`${SENTINEL.DESIGN_MD_FORMAT}: spec\nDESIGN_MD_WRITTEN: ${file}\nDESIGN_MD_BACKUP: ${file}.legacy.bak\n`); + } else { + process.stdout.write(out); + } + return 0; + } + case 'tokens': { + const file = resolveFile(positional[0]); + const loaded = load(file); + const flat = tokensFlat(loaded?.doc.frontmatter ?? null); + process.stdout.write(JSON.stringify({ file, format: detectFormat(loaded?.doc ?? null).format, ...flat }, null, 2) + '\n'); + for (const e of flat.errors) process.stderr.write(e + '\n'); + return 0; + } + case 'mark': { + const choice = positional[0] as FormatChoice | undefined; + if (choice !== 'spec' && choice !== 'legacy-keep') { + process.stderr.write('usage: gstack-design-md.ts mark [DESIGN.md]\n'); + return 2; + } + const file = resolveFile(positional[1]); + const loaded = load(file); + if (!loaded) { process.stdout.write(`${SENTINEL.DESIGN_MD_FORMAT}: missing\n`); return 1; } + writeAtomic(file, renderDesignMd(setMarker(loaded.doc, choice))); + process.stdout.write(`DESIGN_MD_MARKER: ${choice}\n`); + return 0; + } + default: + process.stderr.write('usage: gstack-design-md.ts check [file] | convert [file] [--write] | tokens [file] | mark [file]\n'); + return 2; + } +} + +if (import.meta.main) { + try { + process.exitCode = main(); + } catch (err) { + const e = err as Error; + process.stderr.write(`${SENTINEL.DESIGN_MD_INTERNAL_ERROR}: ${e?.name ?? 'Error'}: ${String(e?.message ?? e).slice(0, 300)}\n`); + process.exitCode = 3; + } +} diff --git a/design/src/memory.ts b/design/src/memory.ts index 513e25d5b..8b0112f2b 100644 --- a/design/src/memory.ts +++ b/design/src/memory.ts @@ -15,6 +15,7 @@ import fs from "fs"; import path from "path"; import { requireApiKey } from "./auth"; import { receiptedFetch } from "./receipted-fetch"; +import { parseDesignMd, detectFormat, renderDesignMd, upsertSection, specSkeleton, tokensFlat } from "../../lib/design-md"; export interface ExtractedDesign { colors: { name: string; hex: string; usage: string }[]; @@ -100,8 +101,13 @@ function defaultDesign(): ExtractedDesign { /** * Write or update DESIGN.md with extracted design patterns. - * If DESIGN.md exists, appends an "Extracted from mockup" section. - * If not, creates a new one. + * + * Existing file (spec, legacy, or anything with `##` sections): the + * "## Extracted Design Language" section is upserted through lib/design-md.ts, + * which splices the body only — front matter bytes are never re-emitted, and a + * spec file keeps its canonical section order (the extracted section is an extra + * after them). New file: a spec-format skeleton whose tokens come from the + * extraction (colors by name, typography by role) plus the extracted section. */ export function updateDesignMd( repoRoot: string, @@ -110,30 +116,46 @@ export function updateDesignMd( ): void { const designPath = path.join(repoRoot, "DESIGN.md"); const timestamp = new Date().toISOString().split("T")[0]; - const section = formatExtractedSection(extracted, sourceMockup, timestamp); + const heading = "Extracted Design Language"; + const body = section.split("\n").slice(1).join("\n"); // drop the "## Extracted Design Language" line + + const write = (content: string) => { + const tmp = `${designPath}.tmp-${process.pid}`; + fs.writeFileSync(tmp, content); + fs.renameSync(tmp, designPath); + }; if (fs.existsSync(designPath)) { - // Append to existing DESIGN.md - const existing = fs.readFileSync(designPath, "utf-8"); - - // Check if there's already an extracted section, replace it - const marker = "## Extracted Design Language"; - if (existing.includes(marker)) { - const before = existing.split(marker)[0]; - fs.writeFileSync(designPath, before.trimEnd() + "\n\n" + section); - } else { - fs.writeFileSync(designPath, existing.trimEnd() + "\n\n" + section); - } + const doc = parseDesignMd(fs.readFileSync(designPath, "utf-8")); + write(renderDesignMd(upsertSection(doc, heading, body))); console.error(`Updated DESIGN.md with extracted design language`); - } else { - // Create new DESIGN.md - const content = `# Design System - -${section}`; - fs.writeFileSync(designPath, content); - console.error(`Created DESIGN.md with extracted design language`); + return; } + + const colors: Record = {}; + for (const c of extracted.colors) { + const key = c.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + if (key && /^#[0-9a-fA-F]{3,8}$/.test(c.hex) && !(key in colors)) colors[key] = c.hex; + } + const typography: Record> = {}; + for (const t of extracted.typography) { + const role = t.role.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + if (!role || typography[role]) continue; + const entry: Record = { fontFamily: t.family }; + if (t.size) entry.fontSize = t.size; + if (t.weight) entry.fontWeight = t.weight; + typography[role] = entry; + } + const frontmatter: Record = {}; + if (Object.keys(colors).length) frontmatter.colors = colors; + if (Object.keys(typography).length) frontmatter.typography = typography; + const doc = specSkeleton("Design System", frontmatter, [ + { heading: "Overview", body: `${extracted.mood}\n\nCreated by the gstack designer from an approved mockup (${path.basename(sourceMockup)}) on ${timestamp}.` }, + { heading, body }, + ]); + write(renderDesignMd(doc)); + console.error(`Created DESIGN.md with extracted design language`); } function formatExtractedSection( @@ -198,6 +220,15 @@ export function readDesignConstraints(repoRoot: string): string | null { if (!fs.existsSync(designPath)) return null; const content = fs.readFileSync(designPath, "utf-8"); + const doc = parseDesignMd(content); + if (detectFormat(doc).format === "spec") { + // Spec file: the normative tokens first, then the Overview prose. Both fit + // the brief far better than the first 2000 bytes of YAML would. + const { tokens } = tokensFlat(doc.frontmatter); + const tokenLines = Object.entries(tokens).map(([k, v]) => `${k}: ${v}`).join("; "); + const overview = doc.sections.find((s) => s.canonical === "Overview")?.body ?? ""; + return `Tokens: ${tokenLines}. ${overview}`.slice(0, 2000); + } // Truncate to first 2000 chars to keep brief reasonable return content.slice(0, 2000); } diff --git a/lib/design-md.ts b/lib/design-md.ts new file mode 100644 index 000000000..14785683c --- /dev/null +++ b/lib/design-md.ts @@ -0,0 +1,423 @@ +// lib/design-md.ts — read and write DESIGN.md in the open DESIGN.md format. +// +// Implements the DESIGN.md specification (google-labs-code/design.md, Google LLC, +// Apache-2.0): YAML front matter carrying the design tokens, a markdown body in +// eight canonical `##` sections. See NOTICE.md. Pure module: no I/O, no imports +// from scripts/; bin/gstack-design-md.ts and design/src/memory.ts do the file work. +// +// text ──► parseDesignMd ──► DesignMdDoc { frontmatterText (bytes preserved), frontmatter, marker, +// preamble, sections[] } +// ──► detectFormat ──► spec | legacy | unknown | missing (+ reason) +// ──► convertLegacy ──► gstack's pre-spec DESIGN.md (Product Context, Aesthetic Direction, +// Typography, Color, Spacing, Layout, Motion, Decisions Log) becomes +// tokens + canonical sections; Motion and Decisions Log survive as extras +// ──► upsertSection ──► body-only splice; front matter bytes are never re-emitted +// ──► renderDesignMd ──► marker, front matter, preamble, canonical sections in order, extras +// ──► tokensFlat ──► "colors.primary" → "#F59E0B"; {path} refs resolved to primitives +// +// Format marker (the user's one-time conversion answer, persisted in the file): +// spec files: line 1 `---`, line 2 `# gstack: design-md-format=spec` (a YAML comment, so +// parsers that require `---` on line 1 keep working) +// legacy files: line 1 `` + +import { SENTINEL } from './design-detect-contract'; + +export const CANONICAL_SECTIONS = [ + 'Overview', 'Colors', 'Typography', 'Layout', 'Elevation & Depth', 'Shapes', 'Components', "Do's and Don'ts", +] as const; +export type CanonicalSection = (typeof CANONICAL_SECTIONS)[number]; + +/** Spec aliases (and a few punctuation variants) → canonical heading. */ +export const SECTION_ALIASES: Record = { + 'brand & style': 'Overview', + 'brand and style': 'Overview', + 'layout & spacing': 'Layout', + 'layout and spacing': 'Layout', + 'elevation': 'Elevation & Depth', + 'elevation and depth': 'Elevation & Depth', + "do's and don'ts": "Do's and Don'ts", + 'dos and donts': "Do's and Don'ts", + "do’s and don’ts": "Do's and Don'ts", +}; + +export const TOKEN_GROUPS = ['colors', 'typography', 'rounded', 'spacing', 'components'] as const; +export type TokenGroup = (typeof TOKEN_GROUPS)[number]; + +export const FORMAT_MARKER_PREFIX = 'gstack: design-md-format='; +export type FormatChoice = 'spec' | 'legacy-keep'; +export type DesignMdFormat = 'spec' | 'legacy' | 'unknown' | 'missing'; + +/** Headings that identify gstack's pre-spec DESIGN.md. */ +export const LEGACY_HEADINGS = ['Product Context', 'Aesthetic Direction', 'Color', 'Spacing', 'Decisions Log']; + +export interface Section { + heading: string; + /** canonical name when the heading (or an alias) is one of the eight */ + canonical?: CanonicalSection; + /** body text between this heading and the next `##`, without the trailing blank run */ + body: string; +} + +export interface DesignMdDoc { + /** raw YAML between the fences, bytes preserved (null when no front matter) */ + frontmatterText: string | null; + /** parsed YAML (null when absent or unparsable) */ + frontmatter: Record | null; + frontmatterError?: string; + marker: FormatChoice | null; + /** text between the front matter (or file start) and the first `##` heading, trimmed */ + preamble: string; + sections: Section[]; +} + +// ── Parsing ────────────────────────────────────────────────────────────────── + +function canonicalFor(heading: string): CanonicalSection | undefined { + const key = heading.trim().toLowerCase(); + const direct = CANONICAL_SECTIONS.find(c => c.toLowerCase() === key); + return direct ?? SECTION_ALIASES[key]; +} + +function parseYaml(text: string): { value: Record | null; error?: string } { + try { + const v = (Bun as unknown as { YAML: { parse(s: string): unknown } }).YAML.parse(text); + if (v === null || v === undefined) return { value: {} }; + if (typeof v !== 'object' || Array.isArray(v)) return { value: null, error: 'front matter is not a mapping' }; + return { value: v as Record }; + } catch (e) { + return { value: null, error: (e as Error).message.split('\n')[0].slice(0, 200) }; + } +} + +export function parseDesignMd(text: string): DesignMdDoc { + const src = text.replace(/\r\n/g, '\n'); + let rest = src; + let marker: FormatChoice | null = null; + let frontmatterText: string | null = null; + let frontmatter: Record | null = null; + let frontmatterError: string | undefined; + + const legacyMarker = rest.match(/^\n?/); + if (legacyMarker) { + marker = legacyMarker[1] as FormatChoice; + rest = rest.slice(legacyMarker[0].length); + } + if (rest.startsWith('---\n')) { + const end = rest.indexOf('\n---', 4); + if (end !== -1 && (rest[end + 4] === '\n' || end + 4 === rest.length)) { + frontmatterText = rest.slice(4, end + 1); + const m = frontmatterText.match(/^# gstack: design-md-format=(spec|legacy-keep)\s*$/m); + if (m) marker = m[1] as FormatChoice; + const parsed = parseYaml(frontmatterText); + frontmatter = parsed.value; + frontmatterError = parsed.error; + rest = rest.slice(end + 5); + } + } + + const lines = rest.split('\n'); + const sections: Section[] = []; + const preambleLines: string[] = []; + let cur: { heading: string; lines: string[] } | null = null; + let inFence = false; + for (const line of lines) { + if (/^```/.test(line)) inFence = !inFence; + const h = !inFence ? line.match(/^## (.+?)\s*$/) : null; + if (h) { + if (cur) sections.push(finish(cur)); + cur = { heading: h[1], lines: [] }; + } else if (cur) { + cur.lines.push(line); + } else { + preambleLines.push(line); + } + } + if (cur) sections.push(finish(cur)); + return { frontmatterText, frontmatter, frontmatterError, marker, preamble: preambleLines.join('\n').trim(), sections }; + + function finish(c: { heading: string; lines: string[] }): Section { + const canonical = canonicalFor(c.heading); + return { heading: c.heading, ...(canonical ? { canonical } : {}), body: c.lines.join('\n').replace(/\s+$/, '') }; + } +} + +// ── Format detection ───────────────────────────────────────────────────────── + +export function isLegacyGstackFormat(doc: DesignMdDoc): boolean { + const headings = new Set(doc.sections.map(s => s.heading.trim().toLowerCase())); + const hits = LEGACY_HEADINGS.filter(h => headings.has(h.toLowerCase())).length; + return doc.frontmatterText === null && hits >= 2; +} + +export function hasSpecFrontmatter(doc: DesignMdDoc): boolean { + if (!doc.frontmatter) return false; + return TOKEN_GROUPS.some(g => g in doc.frontmatter!) || 'name' in doc.frontmatter; +} + +export function detectFormat(doc: DesignMdDoc | null): { format: DesignMdFormat; reason?: string } { + if (!doc) return { format: 'missing' }; + if (doc.frontmatterText !== null && doc.frontmatter === null) { + return { format: 'unknown', reason: `front matter does not parse: ${doc.frontmatterError ?? 'unknown error'}` }; + } + const spec = hasSpecFrontmatter(doc); + const legacyHeadings = doc.sections.filter(s => ['product context', 'aesthetic direction'].includes(s.heading.trim().toLowerCase())).length > 0; + if (spec && legacyHeadings) return { format: 'unknown', reason: 'ambiguous (legacy headings and front matter both present)' }; + if (spec) return { format: 'spec' }; + if (isLegacyGstackFormat(doc)) return { format: 'legacy' }; + if (doc.frontmatterText !== null) return { format: 'unknown', reason: 'front matter carries none of the five token groups' }; + return { format: 'unknown', reason: 'no front matter and no gstack legacy headings' }; +} + +// ── YAML block emitter ─────────────────────────────────────────────────────── + +function needsQuotes(s: string): boolean { + return s === '' || /^[\s#&*!|>'"%@`{[\]},:?-]|[:#]\s|\s$|^(true|false|null|yes|no|on|off|~)$|^[-+]?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?$/i.test(s); +} + +function yamlScalar(v: unknown): string { + if (typeof v === 'number' || typeof v === 'boolean') return String(v); + if (v === null || v === undefined) return '""'; + const s = String(v); + return needsQuotes(s) ? JSON.stringify(s) : s; +} + +/** Block-style YAML for nested mappings of scalars (Bun.YAML.stringify emits flow style). */ +export function emitYamlBlock(obj: Record, indent = 0): string { + const pad = ' '.repeat(indent); + const out: string[] = []; + for (const [k, v] of Object.entries(obj)) { + const key = needsQuotes(k) ? JSON.stringify(k) : k; + if (v && typeof v === 'object' && !Array.isArray(v)) { + out.push(`${pad}${key}:`); + out.push(emitYamlBlock(v as Record, indent + 2)); + } else if (Array.isArray(v)) { + out.push(`${pad}${key}:`); + for (const item of v) out.push(`${pad} - ${yamlScalar(item)}`); + } else { + out.push(`${pad}${key}: ${yamlScalar(v)}`); + } + } + return out.join('\n'); +} + +// ── Rendering ──────────────────────────────────────────────────────────────── + +export interface RenderOptions { + /** emit fresh front matter from `frontmatter` instead of the preserved bytes (convert only) */ + emitFrontmatter?: boolean; +} + +export function renderDesignMd(doc: DesignMdDoc, opts: RenderOptions = {}): string { + const parts: string[] = []; + const fm = opts.emitFrontmatter && doc.frontmatter ? emitYamlBlock(doc.frontmatter) + '\n' : doc.frontmatterText; + if (fm !== null) { + const body = fm.replace(/^# gstack: design-md-format=(spec|legacy-keep)\s*\n/m, ''); + parts.push('---'); + if (doc.marker) parts.push(`# ${FORMAT_MARKER_PREFIX}${doc.marker}`); + parts.push(body.replace(/\n$/, '')); + parts.push('---'); + if (doc.preamble) parts.push('', doc.preamble); + } else { + if (doc.marker) parts.push(``); + if (doc.preamble) parts.push(doc.preamble); + } + const canonical = CANONICAL_SECTIONS + .map(c => doc.sections.find(s => s.canonical === c)) + .filter((s): s is Section => Boolean(s)); + const extras = doc.sections.filter(s => !s.canonical); + for (const s of [...canonical, ...extras]) { + parts.push('', `## ${s.canonical ?? s.heading}`); + if (s.body.trim()) parts.push('', s.body.trim()); + } + return parts.join('\n').replace(/^\n+/, '') + '\n'; +} + +/** Replace or add a section; canonical names slot into spec order, extras append. Body-only: front matter bytes untouched. */ +export function upsertSection(doc: DesignMdDoc, heading: string, body: string): DesignMdDoc { + const canonical = canonicalFor(heading); + const sections = doc.sections.map(s => ({ ...s })); + const idx = sections.findIndex(s => (canonical ? s.canonical === canonical : s.heading.trim().toLowerCase() === heading.trim().toLowerCase())); + const next: Section = { heading: canonical ?? heading, ...(canonical ? { canonical } : {}), body: body.replace(/\s+$/, '') }; + if (idx >= 0) sections[idx] = next; else sections.push(next); + return { ...doc, sections }; +} + +export function setMarker(doc: DesignMdDoc, marker: FormatChoice): DesignMdDoc { + return { ...doc, marker }; +} + +// ── Tokens ─────────────────────────────────────────────────────────────────── + +export interface FlatTokens { + tokens: Record; + errors: string[]; +} + +/** Flatten the five token groups to dotted paths; resolve `{path}` references to primitives. */ +export function tokensFlat(frontmatter: Record | null): FlatTokens { + const tokens: Record = {}; + const errors: string[] = []; + if (!frontmatter) return { tokens, errors }; + const raw: Record = {}; + const walk = (prefix: string, v: unknown) => { + if (v && typeof v === 'object' && !Array.isArray(v)) { + for (const [k, x] of Object.entries(v as Record)) walk(prefix ? `${prefix}.${k}` : k, x); + } else if (v !== null && v !== undefined && !Array.isArray(v)) { + raw[prefix] = v; + } + }; + for (const g of TOKEN_GROUPS) if (g in frontmatter) walk(g, frontmatter[g]); + const groups = new Set(Object.keys(raw).map(k => k.split('.').slice(0, -1).join('.')).filter(Boolean)); + for (const [k, v] of Object.entries(raw)) { + const s = String(v); + const ref = s.match(/^\{([a-zA-Z0-9_.-]+)\}$/); + if (!ref) { tokens[k] = s; continue; } + const target = ref[1]; + if (target === k) { errors.push(`${SENTINEL.DESIGN_MD_TOKEN_REF_INVALID}: {${target}} (self-reference)`); continue; } + if (groups.has(target) || TOKEN_GROUPS.includes(target as TokenGroup)) { errors.push(`${SENTINEL.DESIGN_MD_TOKEN_REF_INVALID}: {${target}} (refers to a group, not a primitive)`); continue; } + let seen = 0; + let cur: unknown = raw[target]; + let curKey = target; + while (typeof cur === 'string' && /^\{[a-zA-Z0-9_.-]+\}$/.test(cur) && seen < 8) { + curKey = cur.slice(1, -1); + cur = raw[curKey]; + seen++; + } + if (cur === undefined) { errors.push(`${SENTINEL.DESIGN_MD_TOKEN_REF_INVALID}: {${target}} (no such token)`); continue; } + if (typeof cur === 'string' && /^\{/.test(cur)) { errors.push(`${SENTINEL.DESIGN_MD_TOKEN_REF_INVALID}: {${target}} (reference cycle)`); continue; } + tokens[k] = String(cur); + } + return { tokens, errors }; +} + +// ── Legacy conversion ──────────────────────────────────────────────────────── + +function slug(s: string): string { + return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'token'; +} + +/** Legacy Color bullets whose label names a strategy or a mode, not a color. */ +const NOT_COLOR_LABELS = new Set(['approach', 'semantic', 'dark mode', 'light mode', 'neutrals', 'contrast', 'strategy']); + +function bullets(body: string): Array<{ key: string; value: string }> { + const out: Array<{ key: string; value: string }> = []; + for (const line of body.split('\n')) { + const m = line.match(/^\s*-\s+\*\*(.+?):?\*\*:?\s*(.*)$/); + if (m) out.push({ key: m[1].trim().replace(/:$/, ''), value: m[2].trim() }); + } + return out; +} + +const HEX = /#[0-9a-fA-F]{3,8}\b/; + +function sectionBody(doc: DesignMdDoc, heading: string): string | undefined { + return doc.sections.find(s => s.heading.trim().toLowerCase() === heading.toLowerCase())?.body; +} + +function firstFontName(value: string): string | undefined { + const m = value.match(/^([A-Z][A-Za-z0-9 ]+?)(?:\s*\(|\s+—|\s+-\s|,|$)/); + return m ? m[1].trim() : undefined; +} + +/** + * Convert gstack's pre-spec DESIGN.md into the open format. Product Context and + * Aesthetic Direction fold into Overview; Typography roles become + * typography.display/body/label/mono; Color hexes become colors; the Spacing + * scale becomes spacing; the Layout border radii become rounded; everything else + * (Motion, Decisions Log, Grain Texture, ...) survives as an extra section in + * its original order. Idempotent: converting the render again changes nothing. + */ +export function convertLegacy(doc: DesignMdDoc, opts: { name?: string } = {}): DesignMdDoc { + const title = doc.preamble.match(/^#\s+(.+)$/m)?.[1]?.trim(); + const name = opts.name ?? (title ? title.replace(/^Design System\s*[—–-]\s*/i, '').trim() : 'Design System'); + const fm: Record = { name }; + + const overview: string[] = []; + const product = sectionBody(doc, 'Product Context'); + const aesthetic = sectionBody(doc, 'Aesthetic Direction'); + if (product) overview.push(product.trim()); + if (aesthetic) overview.push(aesthetic.trim()); + + // Typography + const typo = sectionBody(doc, 'Typography'); + const typography: Record> = {}; + if (typo) { + const roleMap: Array<[RegExp, string]> = [ + [/^display/i, 'display'], [/^hero/i, 'display'], [/^body/i, 'body'], [/^ui/i, 'label'], [/^label/i, 'label'], + [/^data/i, 'mono'], [/^code/i, 'mono'], [/^mono/i, 'mono'], + ]; + for (const b of bullets(typo)) { + const role = roleMap.find(([re]) => re.test(b.key))?.[1]; + if (!role || typography[role]) continue; + if (/same as/i.test(b.value)) { const src = b.value.match(/same as (\w+)/i)?.[1]?.toLowerCase(); if (src && typography[src]) typography[role] = { ...typography[src] }; continue; } + const family = firstFontName(b.value); + if (!family) continue; + const t: Record = { fontFamily: family }; + if (role === 'mono') t.fontFeature = 'tnum'; + typography[role] = t; + } + } + if (Object.keys(typography).length) fm.typography = typography; + + // Colors + const color = sectionBody(doc, 'Color') ?? sectionBody(doc, 'Colors'); + const colors: Record = {}; + if (color) { + for (const line of color.split('\n')) { + const hex = line.match(HEX)?.[0]; + if (!hex) continue; + const label = (line.match(/\*\*(.+?):?\*\*/)?.[1] ?? line.match(/^\s*-\s*([^:]+):/)?.[1])?.replace(/:$/, '').trim(); + if (!label || NOT_COLOR_LABELS.has(label.toLowerCase())) continue; + const key = slug(label); + if (!(key in colors)) colors[key] = hex; + } + // semantic line: "success #22C55E, warning #F59E0B, ..." + const semantic = color.match(/\*\*Semantic:\*\*\s*(.+)$/m)?.[1]; + if (semantic) for (const m of semantic.matchAll(/([a-z]+)\s+(#[0-9a-fA-F]{3,8})/g)) if (!(m[1] in colors)) colors[m[1]] = m[2]; + } + if (Object.keys(colors).length) fm.colors = colors; + + // Spacing scale "2xs(2px) xs(4px) ..." + const spacingBody = sectionBody(doc, 'Spacing'); + const spacing: Record = {}; + if (spacingBody) { + const scale = spacingBody.match(/\*\*Scale:\*\*\s*(.+)$/m)?.[1]; + if (scale) for (const m of scale.matchAll(/([0-9a-z]+)\(([^)]+)\)/g)) spacing[m[1]] = /px|rem|em$/.test(m[2]) ? m[2] : `${m[2]}px`; + } + if (Object.keys(spacing).length) fm.spacing = spacing; + + // Border radius "sm:4px, md:8px, lg:12px, full:9999px" + const layoutBody = sectionBody(doc, 'Layout'); + const rounded: Record = {}; + if (layoutBody) { + const radius = layoutBody.match(/\*\*Border radius:\*\*\s*(.+)$/m)?.[1]; + if (radius) for (const m of radius.matchAll(/([a-z0-9]+):\s*([0-9.]+(?:px|rem|em))/g)) rounded[m[1]] = m[2]; + } + if (Object.keys(rounded).length) fm.rounded = rounded; + + const consumed = new Set(['product context', 'aesthetic direction', 'typography', 'color', 'colors', 'spacing', 'layout']); + const sections: Section[] = []; + sections.push({ heading: 'Overview', canonical: 'Overview', body: overview.join('\n\n') || '(no product context recorded)' }); + if (color) sections.push({ heading: 'Colors', canonical: 'Colors', body: color.trim() }); + if (typo) sections.push({ heading: 'Typography', canonical: 'Typography', body: typo.trim() }); + const layoutParts = [layoutBody?.trim(), spacingBody ? `### Spacing\n${spacingBody.trim()}` : undefined].filter(Boolean) as string[]; + if (layoutParts.length) sections.push({ heading: 'Layout', canonical: 'Layout', body: layoutParts.join('\n\n') }); + for (const s of doc.sections) { + if (consumed.has(s.heading.trim().toLowerCase())) continue; + sections.push(s.canonical ? { ...s } : { heading: s.heading, body: s.body }); + } + return { + frontmatterText: emitYamlBlock(fm) + '\n', + frontmatter: fm, + marker: 'spec', + preamble: title ? `# ${title}` : doc.preamble, + sections, + }; +} + +/** A minimal spec-format document (used when a tool must create DESIGN.md from scratch). */ +export function specSkeleton(name: string, frontmatter: Record, sections: Array<{ heading: string; body: string }>): DesignMdDoc { + const fm = { name, ...frontmatter }; + const doc: DesignMdDoc = { frontmatterText: emitYamlBlock(fm) + '\n', frontmatter: fm, marker: 'spec', preamble: `# ${name}`, sections: [] }; + return sections.reduce((d, s) => upsertSection(d, s.heading, s.body), doc); +} diff --git a/test/design-md.test.ts b/test/design-md.test.ts new file mode 100644 index 000000000..c195b3da7 --- /dev/null +++ b/test/design-md.test.ts @@ -0,0 +1,358 @@ +/** + * lib/design-md.ts + bin/gstack-design-md.ts + design/src/memory.ts (DESIGN.md writer). + * + * Pins the open DESIGN.md format rules gstack depends on: eight canonical + * sections in order, only the five token groups in front matter, `{path}` + * references resolving to primitives, extras surviving a round trip, the + * format marker's placement (YAML comment on line 2 for spec files, HTML + * comment on line 1 for legacy), body-only upserts that never re-emit front + * matter bytes, and a legacy → spec conversion of gstack's own DESIGN.md. + */ +import { describe, test, expect } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { spawnSync } from 'child_process'; +import { + parseDesignMd, detectFormat, renderDesignMd, upsertSection, convertLegacy, tokensFlat, + emitYamlBlock, setMarker, specSkeleton, CANONICAL_SECTIONS, TOKEN_GROUPS, isLegacyGstackFormat, +} from '../lib/design-md'; +import { updateDesignMd, readDesignConstraints } from '../design/src/memory'; + +const ROOT = path.join(import.meta.dir, '..'); +const BIN = path.join(ROOT, 'bin', 'gstack-design-md.ts'); +const LEGACY = fs.readFileSync(path.join(ROOT, 'DESIGN.md'), 'utf-8'); + +const SPEC = `--- +# gstack: design-md-format=spec +name: Heritage +colors: + primary: "#1A1C1E" + accent: "#B8422E" + cta: "{colors.accent}" +typography: + display: + fontFamily: Public Sans + fontSize: 3rem +rounded: + md: 8px +spacing: + md: 16px +components: + button-primary: + backgroundColor: "{colors.cta}" + textColor: "{colors.primary}" +--- + +# Heritage + +## Overview + +Architectural minimalism. + +## Colors + +Ink and clay. + +## Typography + +Public Sans everywhere. + +## Motion + +One authored moment. + +## Decisions Log + +| Date | Decision | Rationale | +|---|---|---| +| 2026-09-08 | spec format | portable | +`; + +describe('parse + detect', () => { + test('spec file: front matter bytes preserved, marker read from line 2, sections classified', () => { + const doc = parseDesignMd(SPEC); + expect(doc.marker).toBe('spec'); + expect(doc.frontmatter?.name).toBe('Heritage'); + expect(doc.frontmatterText).toContain('primary: "#1A1C1E"'); + expect(doc.preamble).toBe('# Heritage'); + expect(doc.sections.map(s => s.canonical ?? s.heading)).toEqual(['Overview', 'Colors', 'Typography', 'Motion', 'Decisions Log']); + expect(detectFormat(doc)).toEqual({ format: 'spec' }); + }); + + test("gstack's own DESIGN.md is legacy; a fresh file is unknown; nothing is missing", () => { + const doc = parseDesignMd(LEGACY); + expect(isLegacyGstackFormat(doc)).toBe(true); + expect(detectFormat(doc)).toEqual({ format: 'legacy' }); + expect(detectFormat(parseDesignMd('# Hello\n\nJust prose.\n')).format).toBe('unknown'); + expect(detectFormat(null)).toEqual({ format: 'missing' }); + }); + + test('malformed front matter is unknown with a reason, never a throw', () => { + const doc = parseDesignMd('---\ncolors: [unclosed\n---\n\n## Overview\n\nx\n'); + expect(doc.frontmatter).toBeNull(); + const d = detectFormat(doc); + expect(d.format).toBe('unknown'); + expect(d.reason).toMatch(/front matter does not parse/); + }); + + test('legacy headings plus front matter is ambiguous', () => { + const d = detectFormat(parseDesignMd('---\nname: x\ncolors:\n a: "#fff"\n---\n\n## Product Context\n\nx\n\n## Aesthetic Direction\n\ny\n')); + expect(d.format).toBe('unknown'); + expect(d.reason).toMatch(/^ambiguous/); + }); + + test('a ## inside a code fence is not a section', () => { + const doc = parseDesignMd('## Overview\n\n```md\n## Not a section\n```\n\n## Colors\n\nx\n'); + expect(doc.sections.map(s => s.heading)).toEqual(['Overview', 'Colors']); + }); + + test('legacy marker on line 1 is read and survives a render', () => { + const doc = parseDesignMd('\n# Design System — X\n\n## Product Context\n\n- a\n\n## Color\n\n- **Primary:** #fff\n'); + expect(doc.marker).toBe('legacy-keep'); + const out = renderDesignMd(doc); + expect(out.split('\n')[0]).toBe(''); + expect(out).toContain('# Design System — X'); + }); +}); + +describe('render + upsert', () => { + test('round trip is stable and keeps canonical order with extras after', () => { + const once = renderDesignMd(parseDesignMd(SPEC)); + expect(renderDesignMd(parseDesignMd(once))).toBe(once); + const headings = [...once.matchAll(/^## (.+)$/gm)].map(m => m[1]); + expect(headings).toEqual(['Overview', 'Colors', 'Typography', 'Motion', 'Decisions Log']); + expect(once.split('\n')[0]).toBe('---'); + expect(once.split('\n')[1]).toBe('# gstack: design-md-format=spec'); + }); + + test('canonical sections re-sort into spec order when the file had them shuffled', () => { + const shuffled = '---\nname: x\ncolors:\n a: "#fff"\n---\n\n## Typography\n\nt\n\n## Overview\n\no\n\n## Shapes\n\ns\n\n## Colors\n\nc\n\n## Custom\n\nz\n'; + const out = renderDesignMd(parseDesignMd(shuffled)); + const headings = [...out.matchAll(/^## (.+)$/gm)].map(m => m[1]); + expect(headings).toEqual(['Overview', 'Colors', 'Typography', 'Shapes', 'Custom']); + const order = headings.filter(h => (CANONICAL_SECTIONS as readonly string[]).includes(h)).map(h => CANONICAL_SECTIONS.indexOf(h as any)); + expect([...order].sort((a, b) => a - b)).toEqual(order); + }); + + test('aliases map to canonical names (Brand & Style → Overview, Elevation → Elevation & Depth)', () => { + const doc = parseDesignMd('## Brand & Style\n\nx\n\n## Elevation\n\ny\n'); + expect(doc.sections.map(s => s.canonical)).toEqual(['Overview', 'Elevation & Depth']); + expect(renderDesignMd(doc)).toContain('## Elevation & Depth'); + }); + + test('upsertSection splices the body only: front matter bytes are identical before and after', () => { + const doc = parseDesignMd(SPEC); + const next = upsertSection(upsertSection(doc, 'Colors', 'Ink, clay, and one more.'), 'Extracted Design Language', 'from a mockup'); + const out = renderDesignMd(next); + const fmBefore = SPEC.slice(0, SPEC.indexOf('\n---\n', 4) + 5); + expect(out.startsWith(fmBefore)).toBe(true); + expect(out).toContain('## Colors\n\nInk, clay, and one more.'); + const headings = [...out.matchAll(/^## (.+)$/gm)].map(m => m[1]); + expect(headings).toEqual(['Overview', 'Colors', 'Typography', 'Motion', 'Decisions Log', 'Extracted Design Language']); + }); + + test('setMarker on a spec file writes the YAML comment on line 2 and nothing else moves', () => { + const noMarker = SPEC.replace('# gstack: design-md-format=spec\n', ''); + const out = renderDesignMd(setMarker(parseDesignMd(noMarker), 'spec')); + expect(out.split('\n').slice(0, 3)).toEqual(['---', '# gstack: design-md-format=spec', 'name: Heritage']); + }); +}); + +describe('tokens', () => { + test('flattens the five groups and resolves {path} references to primitives', () => { + const { tokens, errors } = tokensFlat(parseDesignMd(SPEC).frontmatter); + expect(errors).toEqual([]); + expect(tokens['colors.primary']).toBe('#1A1C1E'); + expect(tokens['colors.cta']).toBe('#B8422E'); + expect(tokens['components.button-primary.backgroundColor']).toBe('#B8422E'); + expect(tokens['components.button-primary.textColor']).toBe('#1A1C1E'); + expect(tokens['typography.display.fontSize']).toBe('3rem'); + expect(Object.keys(tokens).every(k => TOKEN_GROUPS.some(g => k.startsWith(g + '.')))).toBe(true); + expect('name' in tokens).toBe(false); + }); + + test('group refs, self refs, and dangling refs are DESIGN_MD_TOKEN_REF_INVALID', () => { + const fm = { colors: { a: '#111', group: '{colors}', self: '{colors.self}', gone: '{colors.nope}' }, components: { btn: { bg: '{colors}' } } }; + const { tokens, errors } = tokensFlat(fm); + expect(tokens['colors.a']).toBe('#111'); + expect(errors.filter(e => e.startsWith('DESIGN_MD_TOKEN_REF_INVALID: ')).length).toBe(4); + expect(errors.join('\n')).toContain('{colors} (refers to a group'); + expect(errors.join('\n')).toContain('{colors.self} (self-reference)'); + expect(errors.join('\n')).toContain('{colors.nope} (no such token)'); + }); + + test('emitYamlBlock writes block style that Bun.YAML parses back identically', () => { + const obj = { name: 'X: y', colors: { primary: '#fff', 'on-primary': '#000', weird: 'yes' }, spacing: { '2xs': '2px', md: 16 }, list: ['a', 'b'] }; + const yaml = emitYamlBlock(obj); + expect(yaml).not.toContain('{'); + expect(yaml).toContain('colors:\n primary: "#fff"'); + expect((Bun as any).YAML.parse(yaml)).toEqual(obj); + }); +}); + +describe("convertLegacy on gstack's own DESIGN.md", () => { + const converted = convertLegacy(parseDesignMd(LEGACY)); + const out = renderDesignMd(converted, { emitFrontmatter: true }); + + test('produces a spec file with the marker on line 2 and only the five token groups plus name', () => { + const doc = parseDesignMd(out); + expect(detectFormat(doc)).toEqual({ format: 'spec' }); + expect(out.split('\n')[1]).toBe('# gstack: design-md-format=spec'); + for (const k of Object.keys(doc.frontmatter!)) expect(['name', ...TOKEN_GROUPS]).toContain(k); + expect(doc.frontmatter!.name).toBe('gstack'); + }); + + test('maps roles, colors, spacing, and radii into tokens', () => { + const { tokens, errors } = tokensFlat(parseDesignMd(out).frontmatter); + expect(errors).toEqual([]); + expect(tokens['typography.display.fontFamily']).toBe('Satoshi'); + expect(tokens['typography.body.fontFamily']).toBe('DM Sans'); + expect(tokens['typography.label.fontFamily']).toBe('DM Sans'); + expect(tokens['typography.mono.fontFamily']).toBe('JetBrains Mono'); + expect(tokens['typography.mono.fontFeature']).toBe('tnum'); + expect(tokens['colors.primary-dark-mode']).toBe('#F59E0B'); + expect(tokens['colors.primary-light-mode']).toBe('#D97706'); + expect(tokens['colors.success']).toBe('#22C55E'); + expect(tokens['colors.semantic']).toBeUndefined(); + expect(tokens['spacing.md']).toBe('16px'); + expect(tokens['spacing.2xs']).toBe('2px'); + expect(tokens['rounded.lg']).toBe('12px'); + expect(tokens['rounded.full']).toBe('9999px'); + }); + + test('folds Product Context and Aesthetic Direction into Overview; Motion, Grain Texture, Decisions Log survive as extras in order', () => { + const headings = [...out.matchAll(/^## (.+)$/gm)].map(m => m[1]); + expect(headings).toEqual(['Overview', 'Colors', 'Typography', 'Layout', 'Motion', 'Grain Texture', 'Decisions Log']); + expect(out).toContain('**What this is:**'); + expect(out).toContain('**Direction:** Industrial/Utilitarian'); + expect(out).toContain('| 2026-03-21 | Grain texture |'); + expect(out).toContain('### Spacing'); + }); + + test('re-rendering the converted file is stable (idempotent write)', () => { + expect(renderDesignMd(parseDesignMd(out))).toBe(out); + }); +}); + +describe('bin/gstack-design-md.ts', () => { + const run = (args: string[], cwd: string) => { + const r = spawnSync(process.execPath, ['--no-env-file', 'run', BIN, ...args], { cwd, encoding: 'utf-8', timeout: 60_000 }); + return { code: r.status ?? -1, out: r.stdout ?? '', err: r.stderr ?? '' }; + }; + + test('check reports format + marker for spec, legacy, unknown, missing', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-design-md-')); + try { + expect(run(['check'], dir).out).toContain('DESIGN_MD_FORMAT: missing'); + fs.writeFileSync(path.join(dir, 'DESIGN.md'), SPEC); + expect(run(['check'], dir).out).toBe('DESIGN_MD_FORMAT: spec\nDESIGN_MD_MARKER: spec\n'); + fs.writeFileSync(path.join(dir, 'DESIGN.md'), LEGACY); + expect(run(['check'], dir).out).toBe('DESIGN_MD_FORMAT: legacy\nDESIGN_MD_MARKER: none\n'); + fs.writeFileSync(path.join(dir, 'DESIGN.md'), '---\n: bad: [\n---\n'); + const bad = run(['check'], dir); + expect(bad.out).toContain('DESIGN_MD_FORMAT: unknown'); + expect(bad.out).toContain('DESIGN_MD_REASON: front matter does not parse'); + expect(bad.code).toBe(0); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + test('convert --write backs up, writes atomically, refuses ambiguous and non-legacy input', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-design-md-')); + try { + fs.writeFileSync(path.join(dir, 'DESIGN.md'), LEGACY); + const dry = run(['convert'], dir); + expect(dry.code).toBe(0); + expect(dry.out.split('\n')[1]).toBe('# gstack: design-md-format=spec'); + expect(fs.readFileSync(path.join(dir, 'DESIGN.md'), 'utf-8')).toBe(LEGACY); + const wr = run(['convert', '--write'], dir); + expect(wr.code).toBe(0); + expect(wr.out).toContain('DESIGN_MD_WRITTEN:'); + expect(fs.readFileSync(path.join(dir, 'DESIGN.md.legacy.bak'), 'utf-8')).toBe(LEGACY); + expect(run(['check'], dir).out).toContain('DESIGN_MD_FORMAT: spec'); + expect(fs.readdirSync(dir).some(f => f.includes('.tmp-'))).toBe(false); + // already spec → refused as non-legacy (exit 1), not clobbered + const again = run(['convert', '--write'], dir); + expect(again.code).toBe(1); + fs.writeFileSync(path.join(dir, 'DESIGN.md'), '---\nname: x\ncolors:\n a: "#fff"\n---\n\n## Product Context\n\nx\n\n## Aesthetic Direction\n\ny\n'); + const amb = run(['convert', '--write'], dir); + expect(amb.code).toBe(2); + expect(amb.err).toContain('DESIGN_MD_CONVERT_REFUSED: ambiguous'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + test('tokens prints the flat map; mark persists the choice', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-design-md-')); + try { + fs.writeFileSync(path.join(dir, 'DESIGN.md'), SPEC); + const t = JSON.parse(run(['tokens'], dir).out); + expect(t.tokens['colors.cta']).toBe('#B8422E'); + expect(t.errors).toEqual([]); + fs.writeFileSync(path.join(dir, 'DESIGN.md'), LEGACY); + const m = run(['mark', 'legacy-keep'], dir); + expect(m.code).toBe(0); + const text = fs.readFileSync(path.join(dir, 'DESIGN.md'), 'utf-8'); + expect(text.split('\n')[0]).toBe(''); + expect(run(['check'], dir).out).toBe('DESIGN_MD_FORMAT: legacy\nDESIGN_MD_MARKER: legacy-keep\n'); + expect(run(['mark', 'maybe'], dir).code).toBe(2); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); +}); + +describe('design binary: updateDesignMd is frontmatter-safe', () => { + const extracted = { + colors: [{ name: 'Primary', hex: '#F59E0B', usage: 'buttons' }, { name: 'Surface', hex: '#141414', usage: 'cards' }], + typography: [{ role: 'heading', family: 'Satoshi', size: '48px', weight: '900' }], + spacing: ['8px base unit'], + layout: ['max-width 1200px'], + mood: 'Serious tool built with care.', + }; + + test('spec input: section appended after the canonical ones, front matter bytes untouched, replaces on rerun', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-design-md-')); + try { + fs.writeFileSync(path.join(dir, 'DESIGN.md'), SPEC); + updateDesignMd(dir, extracted, '/tmp/mock.png'); + const once = fs.readFileSync(path.join(dir, 'DESIGN.md'), 'utf-8'); + expect(once.startsWith(SPEC.slice(0, SPEC.indexOf('\n---\n', 4) + 5))).toBe(true); + expect([...once.matchAll(/^## (.+)$/gm)].map(m => m[1]).at(-1)).toBe('Extracted Design Language'); + expect(once.split('## Extracted Design Language').length - 1).toBe(1); + updateDesignMd(dir, { ...extracted, mood: 'second pass' }, '/tmp/mock2.png'); + const twice = fs.readFileSync(path.join(dir, 'DESIGN.md'), 'utf-8'); + expect(twice.split('## Extracted Design Language').length - 1).toBe(1); + expect(twice).toContain('second pass'); + expect(twice).not.toContain('Serious tool built with care.'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + test('legacy input: sections preserved, extracted section added at the end', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-design-md-')); + try { + fs.writeFileSync(path.join(dir, 'DESIGN.md'), LEGACY); + updateDesignMd(dir, extracted, '/tmp/mock.png'); + const out = fs.readFileSync(path.join(dir, 'DESIGN.md'), 'utf-8'); + expect(out.startsWith('# Design System — gstack')).toBe(true); + expect(out).toContain('## Decisions Log'); + expect([...out.matchAll(/^## (.+)$/gm)].map(m => m[1]).at(-1)).toBe('Extracted Design Language'); + expect(detectFormat(parseDesignMd(out)).format).toBe('legacy'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); + + test('absent input: a spec skeleton with tokens from the extraction; readDesignConstraints leads with tokens', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-design-md-')); + try { + updateDesignMd(dir, extracted, '/tmp/mock.png'); + const out = fs.readFileSync(path.join(dir, 'DESIGN.md'), 'utf-8'); + const doc = parseDesignMd(out); + expect(detectFormat(doc).format).toBe('spec'); + expect(out.split('\n').slice(0, 2)).toEqual(['---', '# gstack: design-md-format=spec']); + const { tokens } = tokensFlat(doc.frontmatter); + expect(tokens['colors.primary']).toBe('#F59E0B'); + expect(tokens['typography.heading.fontFamily']).toBe('Satoshi'); + expect([...out.matchAll(/^## (.+)$/gm)].map(m => m[1])).toEqual(['Overview', 'Extracted Design Language']); + const constraints = readDesignConstraints(dir)!; + expect(constraints.startsWith('Tokens: colors.primary: #F59E0B')).toBe(true); + expect(constraints).toContain('Serious tool built with care.'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); +});