import pc from 'picocolors'; import { createServer } from 'node:http'; import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; /** * `omd book` — browse a project's adopted design system on a local port. * * Storybook shows rendered stories. This shows the *contract*: every token with * the decision that produced it, every component's state matrix including the * states that deliberately do not apply, measured contrast against the pairs the * system promised, and the preset lineage. Source of truth is the compiled * graph (`.omd/system/graph.json`); a project that only has `DESIGN.md` still * gets a readable book from the markdown. */ export interface BookToken { name: string; type: string; value: string; description: string; decisions: string[]; } export interface BookState { name: string; applicability: string; reason?: string; } export interface BookComponent { id: string; anatomy: string[]; states: BookState[]; semantics?: string; presets: string[]; } export interface BookContrastPair { foreground: string; background: string; minimum: number; actual?: number; status: 'pass' | 'fail' | 'unresolved'; } export interface BookDecision { path: string; sourceClass: string; evidence: string[]; } export interface BookPreset { id: string; title: string; group: string; cited: boolean; } export interface BookSystem { source: 'graph' | 'design-md'; root: string; name: string; kind?: string; summary?: string; principles: string[]; direction: string[]; avoid: string[]; tokens: BookToken[]; contrastPairs: BookContrastPair[]; components: BookComponent[]; decisions: BookDecision[]; presets: BookPreset[]; } const DECISION_RE = /\bD-[A-Za-z0-9]+-\d+\b/g; const PRESET_RE = /\bP-[A-Z]{2}-[A-Za-z0-9-]+\b/g; function uniq(values: string[]): string[] { return [...new Set(values)]; } export function resolvePackageRoot(from = dirname(fileURLToPath(import.meta.url))): string | undefined { let cur = from; for (let i = 0; i < 8; i += 1) { if (existsSync(join(cur, 'package.json')) && existsSync(join(cur, 'skills'))) return cur; const parent = dirname(cur); if (parent === cur) break; cur = parent; } return undefined; } // ── colour maths ──────────────────────────────────────────────────────────── function parseColor(raw: string): [number, number, number] | undefined { const value = raw.trim().toLowerCase(); const hex = value.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/); if (hex) { const body = hex[1]; const full = body.length === 3 ? body.split('').map((c) => c + c).join('') : body; return [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16)) as [number, number, number]; } const rgb = value.match(/^rgba?\(([^)]+)\)$/); if (rgb) { const parts = rgb[1].split(',').map((p) => Number.parseFloat(p.trim())); if (parts.length >= 3 && parts.slice(0, 3).every((n) => Number.isFinite(n))) { return [parts[0], parts[1], parts[2]] as [number, number, number]; } } return undefined; } function relativeLuminance([r, g, b]: [number, number, number]): number { const channel = (c: number) => { const s = c / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; }; return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); } export function contrastRatio(a: string, b: string): number | undefined { const ca = parseColor(a); const cb = parseColor(b); if (!ca || !cb) return undefined; const la = relativeLuminance(ca); const lb = relativeLuminance(cb); const [hi, lo] = la >= lb ? [la, lb] : [lb, la]; return (hi + 0.05) / (lo + 0.05); } // ── loading ───────────────────────────────────────────────────────────────── function findGraph(root: string): string | undefined { const direct = join(root, '.omd', 'system', 'graph.json'); return existsSync(direct) ? direct : undefined; } function readPresetCatalog(cited: Set): BookPreset[] { const pkgRoot = resolvePackageRoot(); if (!pkgRoot) return []; const dir = join(pkgRoot, 'skills', 'omd-autopilot', 'references', 'presets'); if (!existsSync(dir)) return []; const presets: BookPreset[] = []; const walk = (current: string, group: string) => { for (const entry of readdirSync(current)) { const full = join(current, entry); if (statSync(full).isDirectory()) { walk(full, entry); continue; } if (!entry.endsWith('.md') || entry === 'INDEX.md') continue; const text = readFileSync(full, 'utf8'); for (const line of text.split('\n')) { const heading = line.match(/^##\s+(P-[A-Z]{2}-[A-Za-z0-9-]+)\s+(.+)$/); if (heading) { presets.push({ id: heading[1], title: heading[2].trim(), group: group === 'presets' ? entry.replace(/\.md$/, '') : `${group}/${entry.replace(/\.md$/, '')}`, cited: cited.has(heading[1]), }); } } } }; walk(dir, 'presets'); return presets; } function loadFromGraph(root: string, graphPath: string, designMd: string): BookSystem { const graph = JSON.parse(readFileSync(graphPath, 'utf8')) as Record; const identity = graph.identity ?? {}; const experience = graph.experience ?? {}; const foundations = graph.foundations ?? {}; const componentsStates = graph.components_states ?? {}; const governance = graph.governance ?? {}; const tokens: BookToken[] = Object.entries(foundations.tokens ?? {}).map(([name, raw]) => { const token = raw as Record; const description = String(token.$description ?? ''); return { name, type: String(token.$type ?? 'unknown'), value: typeof token.$value === 'object' ? JSON.stringify(token.$value) : String(token.$value ?? ''), description, decisions: uniq(description.match(DECISION_RE) ?? []), }; }); const byName = new Map(tokens.map((t) => [t.name, t.value])); const contrastPairs: BookContrastPair[] = (foundations.contrast_pairs ?? []).map((pair: any) => { const fgValue = byName.get(pair.foreground) ?? pair.foreground; const bgValue = byName.get(pair.background) ?? pair.background; const minimum = Number(pair.minimum_ratio ?? 4.5); const actual = contrastRatio(fgValue, bgValue); return { foreground: pair.foreground, background: pair.background, minimum, actual, status: actual === undefined ? 'unresolved' : actual + 1e-9 >= minimum ? 'pass' : 'fail', }; }); const components: BookComponent[] = (componentsStates.components ?? []).map((component: any) => { const applicability = component.interaction?.state_applicability ?? {}; const semantics = String(component.semantics ?? component.description ?? ''); return { id: String(component.id ?? 'component'), anatomy: (component.anatomy ?? []).map((a: unknown) => String(a)), semantics: semantics || undefined, presets: uniq(semantics.match(PRESET_RE) ?? []), states: Object.entries(applicability).map(([name, raw]) => { const state = raw as Record; return { name, applicability: String(state.applicability ?? 'unknown'), reason: state.reason ? String(state.reason) : undefined, }; }), }; }); const decisions: BookDecision[] = (governance.decisions ?? []).map((decision: any) => ({ path: String(decision.path ?? ''), sourceClass: String(decision.source_class ?? ''), evidence: (decision.evidence ?? []).map((e: unknown) => String(e)), })); const citedPresets = new Set([ ...(designMd.match(PRESET_RE) ?? []), ...components.flatMap((c) => c.presets), ]); return { source: 'graph', root, name: String(identity.name ?? 'Design system'), kind: identity.kind ? String(identity.kind) : undefined, summary: experience.summary ? String(experience.summary) : undefined, principles: (experience.principles ?? []).map((p: unknown) => String(p)), direction: (experience.design_direction ?? []).map((p: unknown) => String(p)), avoid: (experience.avoid ?? []).map((p: unknown) => String(p)), tokens, contrastPairs, components, decisions, presets: readPresetCatalog(citedPresets), }; } export function parseDesignMd(root: string, text: string): BookSystem { const lines = text.split('\n'); const tokens: BookToken[] = []; const principles: string[] = []; const direction: string[] = []; const components: BookComponent[] = []; let section = ''; let current: BookComponent | undefined; for (const line of lines) { const heading = line.match(/^#{2,4}\s+(.*)$/); if (heading) { const title = heading[1].trim(); const component = title.match(/^Component:\s*(.+)$/i); if (component) { current = { id: component[1].trim(), anatomy: [], states: [], presets: [] }; components.push(current); section = 'component'; continue; } current = undefined; section = /principle/i.test(title) ? 'principles' : /design direction/i.test(title) ? 'direction' : /token/i.test(title) ? 'tokens' : ''; continue; } const tokenLine = line.match(/^-\s+\*\*([\w.-]+)\*\*:\s*`([^`]+)`\s*(?:—|-)?\s*(.*)$/); if (tokenLine) { tokens.push({ name: tokenLine[1], type: /color|#|rgb/i.test(tokenLine[2]) ? 'color' : 'unknown', value: tokenLine[2], description: tokenLine[3] ?? '', decisions: uniq((tokenLine[3] ?? '').match(DECISION_RE) ?? []), }); continue; } const bullet = line.match(/^-\s+(.*)$/); if (bullet && bullet[1].trim()) { if (section === 'principles') principles.push(bullet[1].trim()); else if (section === 'direction') direction.push(bullet[1].trim()); continue; } if (current) { const semantics = line.match(/^\*\*Semantics:\*\*\s*(.+)$/); if (semantics) { current.semantics = semantics[1].trim(); current.presets = uniq(semantics[1].match(PRESET_RE) ?? []); } } } const nameLine = lines.find((l) => /^#\s+/.test(l)); const citedPresets = new Set(text.match(PRESET_RE) ?? []); return { source: 'design-md', root, name: nameLine ? nameLine.replace(/^#\s+/, '').trim() : 'Design system', principles, direction, avoid: [], tokens, contrastPairs: [], components, decisions: [], presets: readPresetCatalog(citedPresets), }; } export function loadSystem(root: string): BookSystem | { error: string } { const designMdPath = join(root, 'DESIGN.md'); const designMd = existsSync(designMdPath) ? readFileSync(designMdPath, 'utf8') : ''; const graphPath = findGraph(root); if (graphPath) return loadFromGraph(root, graphPath, designMd); if (designMd) return parseDesignMd(root, designMd); return { error: `No design system found in ${root}. Expected .omd/system/graph.json or DESIGN.md — run an OmD design workflow first.`, }; } // ── rendering ─────────────────────────────────────────────────────────────── function esc(value: string): string { return value .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function decisionChips(decisions: string[]): string { if (!decisions.length) return ''; return `${decisions.map((d) => `${esc(d)}`).join('')}`; } function swatch(token: BookToken): string { return `
`; } function tokenPreview(token: BookToken): string { const type = token.type.toLowerCase(); if (type === 'color') return swatch(token); if (type === 'shadow') return `
`; if (type === 'fontfamily') return `
가나다 Ag 123
`; if (type === 'fontsize') return `
가나다 Ag
`; if (type === 'spacing' || type === 'dimension') { const numeric = Number.parseFloat(token.value); const px = /rem$/.test(token.value) && Number.isFinite(numeric) ? numeric * 16 : numeric; const width = Number.isFinite(px) ? Math.min(Math.max(px, 2), 240) : 0; return width ? `
` : ''; } if (type === 'duration') return `
`; return ''; } function renderTokens(system: BookSystem): string { if (!system.tokens.length) return '

이 시스템에는 기록된 토큰이 없습니다.

'; const groups = new Map(); for (const token of system.tokens) { const list = groups.get(token.type) ?? []; list.push(token); groups.set(token.type, list); } return [...groups.entries()] .map(([type, tokens]) => `

${esc(type)} ${tokens.length}

${tokens.map((token) => ` `).join('')}
토큰근거
${tokenPreview(token)} ${esc(token.name)} ${esc(token.value)} ${esc(token.description)} ${decisionChips(token.decisions)}
`) .join(''); } function renderContrast(system: BookSystem): string { if (!system.contrastPairs.length) { return '

이 시스템은 대비 쌍을 선언하지 않았습니다. 선언된 쌍이 없으면 측정도 없습니다.

'; } const failures = system.contrastPairs.filter((p) => p.status === 'fail').length; const banner = failures ? `

${failures}개 쌍이 선언한 최소 대비에 미달합니다.

` : '

선언된 모든 쌍이 최소 대비를 만족합니다.

'; return `${banner} ${system.contrastPairs.map((pair) => ` `).join('')}
전경배경요구실측판정
${esc(pair.foreground)} ${esc(pair.background)} ${pair.minimum.toFixed(1)}:1 ${pair.actual ? `${pair.actual.toFixed(2)}:1` : '—'} ${pair.status === 'pass' ? '통과' : pair.status === 'fail' ? '미달' : '측정 불가'}
`; } function renderComponents(system: BookSystem): string { if (!system.components.length) return '

기록된 컴포넌트가 없습니다.

'; return system.components.map((component) => `

${esc(component.id)}${component.presets.length ? `${component.presets.map((p) => `${esc(p)}`).join('')}` : ''}

${component.semantics ? `

${esc(component.semantics)}

` : ''} ${component.anatomy.length ? `

해부

    ${component.anatomy.map((a) => `
  • ${esc(a)}
  • `).join('')}
` : ''} ${component.states.length ? `

상태

${component.states.map((state) => ` `).join('')}
${esc(state.name)} ${esc(state.applicability)} ${state.reason ? esc(state.reason) : ''}
` : ''}
`).join(''); } function renderDecisions(system: BookSystem): string { if (!system.decisions.length) return '

기록된 결정 출처가 없습니다.

'; return ` ${system.decisions.map((decision) => ` `).join('')}
경로출처 등급근거
${esc(decision.path)} ${esc(decision.sourceClass)} ${decision.evidence.map((e) => `${esc(e)}`).join(' ')}
`; } function renderPresets(system: BookSystem): string { if (!system.presets.length) { return '

설치된 프리셋 카탈로그를 찾지 못했습니다.

'; } const cited = system.presets.filter((p) => p.cited); const groups = new Map(); for (const preset of system.presets) { const list = groups.get(preset.group) ?? []; list.push(preset); groups.set(preset.group, list); } return `

이 프로젝트가 인용한 프리셋 ${cited.length}개 / 카탈로그 전체 ${system.presets.length}개.

${[...groups.entries()].map(([group, presets]) => `

${esc(group)} ${presets.length}

    ${presets.map((preset) => `
  • ${esc(preset.id)} ${esc(preset.title)}${preset.cited ? '사용' : ''}
  • `).join('')}
`).join('')}`; } export function renderBook(system: BookSystem): string { const sections: Array<[string, string, string]> = [ ['overview', '개요', ` ${system.summary ? `

${esc(system.summary)}

` : ''} ${system.principles.length ? `

원칙

    ${system.principles.map((p) => `
  • ${esc(p)} ${decisionChips(uniq(p.match(DECISION_RE) ?? []))}
  • `).join('')}
` : ''} ${system.direction.length ? `

방향

    ${system.direction.map((p) => `
  • ${esc(p)} ${decisionChips(uniq(p.match(DECISION_RE) ?? []))}
  • `).join('')}
` : ''} ${system.avoid.length ? `

피하는 것

    ${system.avoid.map((p) => `
  • ${esc(p)}
  • `).join('')}
` : ''}`], ['tokens', `토큰 (${system.tokens.length})`, renderTokens(system)], ['contrast', `대비 (${system.contrastPairs.length})`, renderContrast(system)], ['components', `컴포넌트 (${system.components.length})`, renderComponents(system)], ['decisions', `결정 출처 (${system.decisions.length})`, renderDecisions(system)], ['presets', '프리셋', renderPresets(system)], ]; return ` ${esc(system.name)} — omd book
${sections.map(([id, title, body]) => `

${esc(title)}

${body}
`).join('')}
`; } // ── command ───────────────────────────────────────────────────────────────── export interface BookOptions { dir?: string; port?: string | number; staticOut?: string; open?: boolean; } async function listen(html: () => string, port: number, attempts = 10): Promise { for (let i = 0; i < attempts; i += 1) { const candidate = port + i; const ok = await new Promise((res) => { const server = createServer((req, response) => { if (req.url && req.url.startsWith('/health')) { response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' }); response.end('{"ok":true}'); return; } response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); response.end(html()); }); server.on('error', (error: NodeJS.ErrnoException) => { if (error.code === 'EADDRINUSE') res(false); else { console.error(pc.red(`omd book: ${error.message}`)); res(false); } }); server.listen(candidate, () => res(true)); }); if (ok) return candidate; } return -1; } export async function runBook(options: BookOptions = {}): Promise { const root = resolve(options.dir ?? process.cwd()); const loaded = loadSystem(root); if ('error' in loaded) { console.error(pc.red(loaded.error)); return 1; } if (options.staticOut) { const outDir = resolve(options.staticOut); mkdirSync(outDir, { recursive: true }); const file = join(outDir, 'index.html'); writeFileSync(file, renderBook(loaded), 'utf8'); console.log(pc.green(`omd book → ${file}`)); return 0; } const requested = Number(options.port ?? 6060); const port = await listen(() => { // Re-read on every request so the book tracks edits without a restart. const fresh = loadSystem(root); return 'error' in fresh ? renderBook(loaded) : renderBook(fresh); }, Number.isFinite(requested) ? requested : 6060); if (port < 0) { console.error(pc.red('omd book: no free port found in range.')); return 1; } const url = `http://localhost:${port}`; console.log(`${pc.bold(loaded.name)} — ${loaded.tokens.length} tokens · ${loaded.components.length} components · ${loaded.contrastPairs.length} contrast pairs`); console.log(pc.green(`omd book listening on ${url}`)); console.log(pc.dim('Ctrl+C to stop. The page re-reads the system on every refresh.')); if (options.open) { const { spawn } = await import('node:child_process'); const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'; spawn(opener, [url], { stdio: 'ignore', detached: true }).unref(); } return new Promise(() => { /* keep the process alive until interrupted */ }); }