import { mkdirSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import type { Cell, DesignDoc, Row } from '@/design/parse' import { parseDesignDoc } from '@/design/parse' export interface RenderResult { htmlPath: string cssPath: string } export function renderDesignDoc( sourcePath: string, outDir: string, ): RenderResult { const doc = parseDesignDoc(sourcePath) mkdirSync(outDir, { recursive: true }) const cssPath = join(outDir, 'design.css') const htmlPath = join(outDir, 'index.html') writeFileSync(cssPath, buildCss(doc)) writeFileSync(htmlPath, buildHtml(doc)) return { htmlPath, cssPath } } function slug(s: string): string { return s .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/(^-|-$)/g, '') } function cell(row: Row, key: string): Cell { return row[key] ?? { value: '', tagged: false } } /** The value alone. Every swatch, sample, and custom property is built from it. */ function val(row: Row, key: string): string { return cell(row, key).value } /** The marker, rendered beside a value rather than inside it. */ function mark(row: Row, key: string): string { return cell(row, key).tagged ? ' ? verify' : '' } /** A displayed cell: its escaped text, then its marker when it carries one. */ function cellText(row: Row, key: string): string { return escape(val(row, key)) + mark(row, key) } interface Confidence { tagged: number total: number } /** * The columns the confidence ratio reads, fixed by `standards/design.md`. The * first column of each table names its row, and `Multiplier` and `When used` * restate what the row already carries, so none of them is something a source * could anchor and none belongs in the denominator. */ const ANCHORABLE = { borders: ['Radius', 'Width'], color: ['Intent', 'Value'], spacing: ['Value'], typography: ['Family', 'Weight', 'Size', 'Line height'], } as const /** * A cell counts when it carries a tag, or when it holds a value in a column a * source could anchor. A blank the record left unfilled is neither, and so is a * row name. Counting a tagged cell whichever column it sits in is what keeps a * marker the preview draws from sitting outside the ratio printed beside it. */ function confidence(doc: DesignDoc): Confidence { const tables: ReadonlyArray = [ [doc.color, ANCHORABLE.color], [doc.typography, ANCHORABLE.typography], [doc.spacing, ANCHORABLE.spacing], [doc.borders, ANCHORABLE.borders], ] let tagged = 0 let total = 0 for (const [rows, columns] of tables) { for (const row of rows) { for (const [key, c] of Object.entries(row)) { if (!c.tagged && (!c.value || !columns.includes(key))) continue total += 1 if (c.tagged) tagged += 1 } } } return { tagged, total } } function buildCss(doc: DesignDoc): string { const lines: string[] = [':root {'] for (const row of doc.color) { if (val(row, 'Value')) { lines.push(` --color-${slug(val(row, 'Role'))}: ${val(row, 'Value')};`) } } for (const row of doc.spacing) { if (val(row, 'Value')) { lines.push(` --space-${slug(val(row, 'Step'))}: ${val(row, 'Value')};`) } } for (const row of doc.typography) { if (val(row, 'Size')) { lines.push( ` --type-${slug(val(row, 'Role'))}-size: ${val(row, 'Size')};`, ) } if (val(row, 'Line height')) { lines.push( ` --type-${slug(val(row, 'Role'))}-lh: ${val(row, 'Line height')};`, ) } } for (const row of doc.borders) { if (val(row, 'Radius')) { lines.push(` --radius-${slug(val(row, 'Role'))}: ${val(row, 'Radius')};`) } } lines.push('}') return lines.join('\n') + '\n' } function escape(s: string): string { return s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') } function buildHtml(doc: DesignDoc): string { const sections = [ sectionPersonality(doc.personality), sectionColor(doc.color), sectionTypography(doc.typography), sectionSpacing(doc.spacing), sectionBorders(doc.borders), sectionLine('Motion', doc.motion), sectionLine('Iconography', doc.iconography), ] const { tagged, total } = confidence(doc) const verifyStyle = tagged ? '\n .verify { color: #a4471c; font-size: 12px; font-weight: 600; margin-left: 0.35rem; white-space: nowrap; }' : '' const verb = tagged === 1 ? 'carries' : 'carry' const summary = tagged ? `\n

${total - tagged} of ${total} cells are anchored to a source. The other ${tagged} ${verb} ? verify, so nothing anchors them yet.

` : '' return ` Design tokens

Design tokens

Generated from .claude/DESIGN.md by aitk design render. Token preview only, not a screen mock.

${summary} ${sections.join('\n')} ` } function sectionPersonality(text: string): string { if (!text) return '' return `

Personality

\n

${escape(text)}

` } function sectionColor(rows: Row[]): string { if (!rows.length) return '' const body = rows .map((r) => { const value = val(r, 'Value') const swatch = value ? `` : '' const shown = value ? escape(value) : 'unset' return `${swatch}${cellText(r, 'Role')}${cellText(r, 'Intent')}${shown}${mark(r, 'Value')}` }) .join('\n') return `

Color

\n${body}
RoleIntentValue
` } function sectionTypography(rows: Row[]): string { if (!rows.length) return '' const body = rows .map((r) => { const family = val(r, 'Family') || 'system-ui' const weight = val(r, 'Weight') || '400' const size = val(r, 'Size') || '16px' const lh = val(r, 'Line height') || '1.4' const sample = `The quick brown fox` return `${cellText(r, 'Role')}${escape(family)}${mark(r, 'Family')}${escape(weight)}${mark(r, 'Weight')}${escape(size)}${mark(r, 'Size')}${escape(lh)}${mark(r, 'Line height')}${sample}` }) .join('\n') return `

Typography

\n${body}
RoleFamilyWeightSizeLine heightSample
` } function sectionSpacing(rows: Row[]): string { if (!rows.length) return '' const body = rows .map((r) => { const value = val(r, 'Value') const bar = value ? `` : 'unset' return `${cellText(r, 'Step')}${cellText(r, 'Multiplier')}${escape(value || 'unset')}${mark(r, 'Value')}${bar}` }) .join('\n') return `

Spacing

\n${body}
StepMultiplierValueSample
` } function sectionBorders(rows: Row[]): string { if (!rows.length) return '' const body = rows .map((r) => { const radius = val(r, 'Radius') || '0' const width = val(r, 'Width') || '1px' const sample = `` return `${cellText(r, 'Role')}${escape(radius)}${mark(r, 'Radius')}${escape(width)}${mark(r, 'Width')}${cellText(r, 'When used')}${sample}` }) .join('\n') return `

Borders

\n${body}
RoleRadiusWidthWhen usedSample
` } function sectionLine(title: string, text: string): string { if (!text) return '' return `

${title}

\n

${escape(text)}

` }