From 45fd63759ead1f59a2f4b9657d3b29734d5cccdd Mon Sep 17 00:00:00 2001 From: Clayton Kim Date: Wed, 15 Jul 2026 11:42:43 -0700 Subject: [PATCH 1/5] test(evals): add design-system extraction + loader eval suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec-first evals for the external design-system feature, in the existing fixture/expectation idiom: one extraction case per ve:learn modality (code: a synthetic acme-terracotta brand token module -> golden token set, exact match; url: saved HTML+CSS site served via injected fetch, no live network; image: quantized-palette PNG, tolerance match) plus loader cases (resolution order, repo-clone collision dedupe, unknown-preset fallback warning, built-ins never shadowed, malformed systems fail loudly). Reports required --ve-* token coverage per case. All fixtures are invented brands — real design systems are private user artifacts and never ship in this repo. The synthetic code fixture still exercises every mapping path: name hints, muted/faint contrast ranking, hue-classified danger, grid/weight/easing extraction, and defaults. Mutation-verified: flipping the muted-contrast ranking or the accent-fill precompositing makes the suite fail. Co-Authored-By: Claude Fable 5 --- evals/design-systems/run.mjs | 330 ++++++++++++++++++ .../code/acme-terracotta-tokens.ts | 101 ++++++ .../expected/acme-terracotta-tokens.json | 59 ++++ .../expected/acme-url-tokens.json | 18 + .../expected/brand-palette-image.json | 14 + .../design-systems/image/brand-palette.png | Bin 0 -> 374 bytes evals/fixtures/design-systems/url/index.html | 23 ++ evals/fixtures/design-systems/url/styles.css | 46 +++ 8 files changed, 591 insertions(+) create mode 100644 evals/design-systems/run.mjs create mode 100644 evals/fixtures/design-systems/code/acme-terracotta-tokens.ts create mode 100644 evals/fixtures/design-systems/expected/acme-terracotta-tokens.json create mode 100644 evals/fixtures/design-systems/expected/acme-url-tokens.json create mode 100644 evals/fixtures/design-systems/expected/brand-palette-image.json create mode 100644 evals/fixtures/design-systems/image/brand-palette.png create mode 100644 evals/fixtures/design-systems/url/index.html create mode 100644 evals/fixtures/design-systems/url/styles.css diff --git a/evals/design-systems/run.mjs b/evals/design-systems/run.mjs new file mode 100644 index 0000000..626dc4b --- /dev/null +++ b/evals/design-systems/run.mjs @@ -0,0 +1,330 @@ +#!/usr/bin/env node +// Design-system eval suite: extraction + loader. +// +// These evals are the SPEC for `ve:learn`'s deterministic extraction +// heuristics and the registry loader — fixture in, golden out. Heuristic +// changes must hill-climb this suite, in the same fixture/expectation idiom +// as the verifier evals (evals/run.mjs). Runs as the second leg of +// `npm run ve:eval`. +// +// EXTRACTION — one case per modality: +// code evals/fixtures/design-systems/code/ (synthetic brand token +// module → golden acme-terracotta tokens; exact match + +// required-key coverage report) +// url evals/fixtures/design-systems/url/ (saved HTML+CSS site served +// through an injected fetch — no live network; :root custom props, +// @font-face, linked-stylesheet following) +// image evals/fixtures/design-systems/image/ (palette quantization via +// Playwright canvas; tolerance-based match) +// +// LOADER — resolution order ($ARTIFACTURE_DESIGN_DIR beats +// ~/.artifacture/design-systems beats /design-systems), the +// ~/.artifacture-is-a-repo-clone dedupe, unknown-preset fallback warning, +// and malformed systems failing loudly. +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + extractFromCode, + mapExtractionToCore, + deriveTokens, + hexToRgb, +} from '../../scripts/ve-mdx/learn-extractors.mjs'; +import { extractFromUrlSource, extractFromImageSource } from '../../scripts/ve-mdx/learn-sources.mjs'; +import { + DesignSystemError, + REQUIRED_VE_TOKENS, + designSystemSearchPaths, + resolveDesignSystem, + resolvePresetCssForExport, +} from '../../scripts/ve-mdx/design-systems.mjs'; + +const EVAL_ROOT = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(EVAL_ROOT, '../..'); +const FIXTURES = join(REPO_ROOT, 'evals/fixtures/design-systems'); +const GLOBAL_CSS = join(REPO_ROOT, 'visual-explainer-mdx/global.css'); + +const results = []; +function record(name, failures, detail = {}) { + results.push({ name, status: failures.length === 0 ? 'pass' : 'fail', failures, detail }); +} + +function readJson(path) { + return JSON.parse(readFileSync(path, 'utf8')); +} + +function compareTokens(actual, expectedTokens) { + const failures = []; + for (const [key, expected] of Object.entries(expectedTokens)) { + if (actual[key] !== expected) { + failures.push(`${key}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual[key])}`); + } + } + return failures; +} + +function requiredCoverage(actual) { + const produced = new Set(Object.keys(actual)); + const missing = REQUIRED_VE_TOKENS.filter((token) => !produced.has(token)); + return { producedRequired: REQUIRED_VE_TOKENS.length - missing.length, missing }; +} + +// --------------------------------------------------------------------------- +// Extraction evals +// --------------------------------------------------------------------------- + +function evalCodeExtraction() { + const source = readFileSync(join(FIXTURES, 'code/acme-terracotta-tokens.ts'), 'utf8'); + const golden = readJson(join(FIXTURES, 'expected/acme-terracotta-tokens.json')); + const tokens = deriveTokens(mapExtractionToCore(extractFromCode(source)).core); + const failures = compareTokens(tokens, golden.tokens); + const coverage = requiredCoverage(tokens); + if (coverage.missing.length > 0) failures.push(`missing required tokens: ${coverage.missing.join(', ')}`); + record('extraction-code-acme-terracotta', failures, { coverage }); +} + +async function evalUrlExtraction() { + // Serve the saved fixture site through an injected fetch — the real url + // code path (linked-stylesheet following included) with no live network. + const siteRoot = join(FIXTURES, 'url'); + const fetchImpl = async (url) => { + const pathname = new URL(url).pathname; + const file = join(siteRoot, pathname === '/' ? 'index.html' : pathname.replace(/^\//, '')); + try { + const text = readFileSync(file, 'utf8'); + return { ok: true, status: 200, text: async () => text }; + } catch { + return { ok: false, status: 404, text: async () => '' }; + } + }; + const golden = readJson(join(FIXTURES, 'expected/acme-url-tokens.json')); + const extraction = await extractFromUrlSource('https://acme.example/', { fetchImpl }); + const tokens = deriveTokens(mapExtractionToCore(extraction).core); + const failures = compareTokens(tokens, golden.tokens); + for (const face of golden.fontFaces) { + if (!extraction.fontFaces.includes(face)) failures.push(`@font-face not discovered: ${face}`); + } + const coverage = requiredCoverage(tokens); + if (coverage.missing.length > 0) failures.push(`missing required tokens: ${coverage.missing.join(', ')}`); + record('extraction-url-acme-site', failures, { coverage }); +} + +async function evalImageExtraction() { + const golden = readJson(join(FIXTURES, 'expected/brand-palette-image.json')); + const extraction = await extractFromImageSource(join(FIXTURES, 'image/brand-palette.png')); + const failures = []; + + const withinTolerance = (a, b) => { + const [ar, ag, ab] = hexToRgb(a); + const [br, bg, bb] = hexToRgb(b); + return ( + Math.abs(ar - br) <= golden.channelTolerance && + Math.abs(ag - bg) <= golden.channelTolerance && + Math.abs(ab - bb) <= golden.channelTolerance + ); + }; + for (const expected of golden.palette) { + const hit = extraction.dominant.find((color) => withinTolerance(color.value, expected.hex)); + if (!hit) failures.push(`palette color ${expected.hex} not found within tolerance`); + else if (hit.count < expected.minShare) { + failures.push(`palette color ${expected.hex} share ${hit.count.toFixed(3)} < ${expected.minShare}`); + } + } + const core = mapExtractionToCore(extraction).core; + for (const [slot, expected] of Object.entries(golden.core)) { + if (!core[slot] || !withinTolerance(core[slot], expected)) { + failures.push(`core.${slot}: expected ~${expected}, got ${core[slot]}`); + } + } + const coverage = requiredCoverage(deriveTokens(core)); + if (coverage.missing.length > 0) failures.push(`missing required tokens: ${coverage.missing.join(', ')}`); + record('extraction-image-brand-palette', failures, { coverage }); +} + +// --------------------------------------------------------------------------- +// Loader evals +// --------------------------------------------------------------------------- + +function writeSystem(registryDir, name, accent, extra = {}) { + const dir = join(registryDir, name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'tokens.css'), `:root {\n --ve-accent: ${accent};\n}\n`); + writeFileSync( + join(dir, 'manifest.json'), + `${JSON.stringify({ name, description: 'loader eval fixture', ...extra }, null, 2)}\n`, + ); + return dir; +} + +function withTempRegistries(run) { + const root = mkdtempSync(join(tmpdir(), 've-ds-eval-')); + try { + const envDir = join(root, 'env-registry'); + const home = join(root, 'home'); + const repoRoot = join(root, 'repo'); + mkdirSync(envDir, { recursive: true }); + mkdirSync(join(home, '.artifacture/design-systems'), { recursive: true }); + mkdirSync(join(repoRoot, 'design-systems'), { recursive: true }); + return run({ + root, + envDir, + home, + homeRegistry: join(home, '.artifacture/design-systems'), + repoRoot, + repoRegistry: join(repoRoot, 'design-systems'), + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +function evalResolutionOrder() { + withTempRegistries(({ envDir, home, homeRegistry, repoRoot, repoRegistry }) => { + writeSystem(envDir, 'acme', '#111111'); + writeSystem(homeRegistry, 'acme', '#222222'); + writeSystem(repoRegistry, 'acme', '#333333'); + + const failures = []; + const accentOf = (opts) => { + const system = resolveDesignSystem('acme', opts); + return system?.tokensCss.match(/--ve-accent:\s*(#\w+)/)?.[1] ?? null; + }; + + const envWins = accentOf({ env: { ARTIFACTURE_DESIGN_DIR: envDir }, home, repoRoot }); + if (envWins !== '#111111') failures.push(`$ARTIFACTURE_DESIGN_DIR should win: got ${envWins}`); + const homeWins = accentOf({ env: {}, home, repoRoot }); + if (homeWins !== '#222222') failures.push(`~/.artifacture should beat repo-local: got ${homeWins}`); + const repoFallback = accentOf({ env: {}, home: join(home, 'empty-home'), repoRoot }); + if (repoFallback !== '#333333') failures.push(`repo-local fallback should resolve: got ${repoFallback}`); + const missEverywhere = resolveDesignSystem('nope', { env: {}, home, repoRoot }); + if (missEverywhere !== null) failures.push('unknown system should resolve to null'); + record('loader-resolution-order', failures); + }); +} + +function evalCollisionDedupe() { + // ~/.artifacture may BE a clone of this repo: then the user-global registry + // and the repo-local registry are the same directory, which must be + // searched exactly once (at the higher-priority slot). + withTempRegistries(({ home }) => { + const failures = []; + const cloneRoot = join(home, '.artifacture'); + const paths = designSystemSearchPaths({ env: {}, home, repoRoot: cloneRoot }); + const expected = join(cloneRoot, 'design-systems'); + if (paths.length !== 1 || paths[0] !== expected) { + failures.push(`expected deduped single path ${expected}, got ${JSON.stringify(paths)}`); + } + record('loader-collision-dedupe', failures); + }); +} + +function evalUnknownPresetFallback() { + withTempRegistries(({ home, repoRoot }) => { + const failures = []; + const { css, warnings } = resolvePresetCssForExport('', { + env: {}, + home, + repoRoot, + globalCssPath: GLOBAL_CSS, + }); + if (warnings.length !== 1 || !/unknown preset "not-a-real-system"/.test(warnings[0] ?? '')) { + failures.push(`expected a clear unknown-preset warning, got ${JSON.stringify(warnings)}`); + } + if (!css || !css.includes('[data-ve-preset="not-a-real-system"]')) { + failures.push('fallback CSS should scope built-in tokens to the unknown name'); + } + if (!css || !css.includes('--ve-bg: #09090b')) { + failures.push('fallback CSS should carry the default built-in (mono-industrial) tokens'); + } + record('loader-unknown-preset-fallback', failures); + }); +} + +function evalBuiltinsNotShadowed() { + // Built-in preset names must never hit the registry (a user system named + // "terminal" must not silently restyle built-in artifacts). + withTempRegistries(({ home, homeRegistry, repoRoot }) => { + const failures = []; + writeSystem(homeRegistry, 'terminal', '#bad000'); + const { css, warnings } = resolvePresetCssForExport('', { + env: {}, + home, + repoRoot, + globalCssPath: GLOBAL_CSS, + }); + if (css !== null) failures.push('built-in preset must not be shadowed by a registry system'); + if (warnings.length !== 0) failures.push(`expected no warnings for a built-in preset, got ${JSON.stringify(warnings)}`); + record('loader-builtin-not-shadowed', failures); + }); +} + +function evalMalformedSystemsFailLoudly() { + withTempRegistries(({ home, homeRegistry, repoRoot }) => { + const failures = []; + const opts = { env: {}, home, repoRoot }; + + // Malformed manifest.json → loud DesignSystemError, not a silent skip. + const brokenDir = join(homeRegistry, 'broken'); + mkdirSync(brokenDir, { recursive: true }); + writeFileSync(join(brokenDir, 'tokens.css'), ':root { --ve-accent: #123456; }\n'); + writeFileSync(join(brokenDir, 'manifest.json'), '{ not json'); + try { + resolveDesignSystem('broken', opts); + failures.push('malformed manifest.json should throw'); + } catch (error) { + if (!(error instanceof DesignSystemError) || !/malformed manifest\.json/.test(error.message)) { + failures.push(`malformed manifest threw the wrong error: ${error.message}`); + } + } + + // Missing tokens.css → loud error too (a half-formed system must not + // silently shadow a lower-priority one). + const bareDir = join(homeRegistry, 'bare'); + mkdirSync(bareDir, { recursive: true }); + writeFileSync(join(bareDir, 'manifest.json'), '{"name":"bare"}\n'); + try { + resolveDesignSystem('bare', opts); + failures.push('missing tokens.css should throw'); + } catch (error) { + if (!(error instanceof DesignSystemError) || !/missing tokens\.css/.test(error.message)) { + failures.push(`missing tokens.css threw the wrong error: ${error.message}`); + } + } + record('loader-malformed-fails-loudly', failures); + }); +} + +// --------------------------------------------------------------------------- +// Run +// --------------------------------------------------------------------------- + +evalCodeExtraction(); +await evalUrlExtraction(); +await evalImageExtraction(); +evalResolutionOrder(); +evalCollisionDedupe(); +evalUnknownPresetFallback(); +evalBuiltinsNotShadowed(); +evalMalformedSystemsFailLoudly(); + +console.log('case,status'); +for (const row of results) console.log(`${row.name},${row.status}`); +for (const row of results) { + if (row.detail?.coverage) { + const { producedRequired, missing } = row.detail.coverage; + console.log( + `coverage ${row.name}: ${producedRequired}/${REQUIRED_VE_TOKENS.length} required tokens produced${missing.length ? ` (missing: ${missing.join(', ')})` : ''}`, + ); + } +} + +const failures = results.filter((row) => row.status !== 'pass'); +if (failures.length > 0) { + console.error('\nFailures:'); + for (const failure of failures) { + for (const message of failure.failures) console.error(`- ${failure.name}: ${message}`); + } + process.exit(1); +} +console.log(`\nAll ${results.length} design-system eval cases passed.`); diff --git a/evals/fixtures/design-systems/code/acme-terracotta-tokens.ts b/evals/fixtures/design-systems/code/acme-terracotta-tokens.ts new file mode 100644 index 0000000..cd6165c --- /dev/null +++ b/evals/fixtures/design-systems/code/acme-terracotta-tokens.ts @@ -0,0 +1,101 @@ +// Eval fixture: a SYNTHETIC brand token module for the invented +// "Acme Terracotta" design system. All values are made up; the file mirrors +// the structural shapes ve:learn's code extractor must handle in the wild — +// a named colors object, alpha-suffix tint idioms, font stacks, a signature +// easing constant, a numeric type ramp, tone records, and a grid-paper +// helper. Golden expectations live in ../expected/acme-terracotta-tokens.json. +// +// The palette is chosen so the mapper exercises every decision path: +// name hints paper->bg, ink->text, surface->panel, border->rule, +// primary->accent, night->bg-alt +// contrast ranking gray vs muted (darker wins --ve-muted, other +// becomes --ve-faint) +// status name hints success/info/warning +// hue classification ember (reddish, no hinted name) -> danger +// defaults heading<-text, accent-contrast, radius 0px +// numeric extraction weight mode 350, 32px grid at .45 line alpha, ease +import type { CSSProperties } from "react"; + +export const colors = { + paper: "#F7F3EA", + surface: "#EDE7D8", + border: "#C9C2B4", + ink: "#26282D", + gray: "#57595B", + muted: "#7B7D80", + primary: "#C05F33", + ember: "#96412F", + success: "#5FA97C", + info: "#4A86C8", + warning: "#CC9F45", + night: "#33363E", +}; + +/** Alpha-suffix tinting idiom: tint(colors.primary, "10") → "#C05F3310" */ +export const tint = (color: string, alphaHex: string) => `${color}${alphaHex}`; + +/** + * Opaque equivalent of tint(): composites fg at the given hex alpha over an + * opaque bg and returns the resulting SOLID hex (fills over grid backdrops + * must be fully opaque). + */ +export const solidTint = (fg: string, bg: string, alphaHex: string) => { + const a = parseInt(alphaHex, 16) / 255; + const ch = (hex: string, i: number) => parseInt(hex.slice(1 + 2 * i, 3 + 2 * i), 16); + return `#${[0, 1, 2] + .map((i) => + Math.round(a * ch(fg, i) + (1 - a) * ch(bg, i)) + .toString(16) + .padStart(2, "0"), + ) + .join("")}`.toUpperCase(); +}; + +export const fonts = { + display: "'Tiempos Headline','Lora',Georgia,serif", + body: "'Karla', ui-sans-serif, system-ui, sans-serif", + mono: "'Recursive Mono','Space Mono',ui-monospace,Menlo,monospace", +}; + +export const EASE = "cubic-bezier(0.33,1,0.68,1)"; + +/** Type ramp for the fixed presentation canvas. */ +export const typeRamp = { + title: { fontFamily: fonts.display, fontWeight: 350, fontSize: 72, lineHeight: 0.95, letterSpacing: 0 }, + titleWide: { fontFamily: fonts.display, fontWeight: 350, fontSize: 66, lineHeight: 0.97, letterSpacing: 0 }, + claim: { fontSize: 32, lineHeight: 1.25 }, + body: { fontSize: 24, lineHeight: 1.35 }, + bodySmall: { fontSize: 17, lineHeight: 1.4 }, + metric: { fontFamily: fonts.display, fontWeight: 350, fontSize: 44, lineHeight: 1 }, + label: { fontSize: 12, fontWeight: 650, textTransform: "uppercase" as const }, + monoLabel: { fontFamily: fonts.mono, fontSize: 12, textTransform: "uppercase" as const }, +} satisfies Record; + +export type Tone = "paper" | "night" | "primary"; + +export interface ToneVals { + bg: string; + fg: string; + hair: string; + mut: string; +} + +export const TONES: Record = { + paper: { bg: colors.paper, fg: colors.ink, hair: colors.border, mut: colors.muted }, + night: { bg: colors.night, fg: colors.paper, hair: "rgba(247,243,234,.24)", mut: "rgba(247,243,234,.6)" }, + primary: { bg: colors.primary, fg: colors.paper, hair: "rgba(247,243,234,.36)", mut: "rgba(247,243,234,.7)" }, +}; + +/** Grid-paper backdrop for diagram areas (32px grid). */ +export const gridPaper = (line = "rgba(201,194,180,.45)"): CSSProperties => ({ + backgroundImage: `linear-gradient(${line} 1px, transparent 1px), linear-gradient(90deg, ${line} 1px, transparent 1px)`, + backgroundSize: "32px 32px", +}); + +/** Injected once by the deck root (extractor must ignore template literals). */ +export const GLOBAL_CSS = ` +html, body, #root { margin: 0; padding: 0; height: 100%; background: #1d1c1a; } +* { box-sizing: border-box; } +button:focus-visible { outline: 2px solid ${colors.primary}; outline-offset: 3px; } +@keyframes acmeSlideIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } } +`; diff --git a/evals/fixtures/design-systems/expected/acme-terracotta-tokens.json b/evals/fixtures/design-systems/expected/acme-terracotta-tokens.json new file mode 100644 index 0000000..689099e --- /dev/null +++ b/evals/fixtures/design-systems/expected/acme-terracotta-tokens.json @@ -0,0 +1,59 @@ +{ + "description": "Golden --ve-* tokens ve:learn must produce from the synthetic acme-terracotta fixture. Hand-verified against the fixture source: bg=paper, text=ink, panel=surface, rule=border, accent=primary, bg-alt=night, muted/faint=gray/muted contrast-ranked, ok/info/warn=success/info/warning name hints, danger=ember via hue classification, fonts=display/body/mono stacks, weight mode 350, 32px grid at 0.45 line alpha, ease constant.", + "tokens": { + "--ve-font-display": "'Tiempos Headline','Lora',Georgia,serif", + "--ve-font-body": "'Karla', ui-sans-serif, system-ui, sans-serif", + "--ve-font-mono": "'Recursive Mono','Space Mono',ui-monospace,Menlo,monospace", + "--ve-display-weight": "350", + "--ve-heading-weight": "350", + "--ve-radius": "0px", + "--ve-poster-radius": "0px", + "--ve-bg": "#F7F3EA", + "--ve-bg-alt": "#33363E", + "--ve-panel": "#EDE7D8", + "--ve-panel-strong": "#DEDBD3", + "--ve-row": "#E8E5DD", + "--ve-nav-bg": "rgb(247 243 234 / 0.92)", + "--ve-review-bg": "rgb(247 243 234 / 0.96)", + "--ve-rule": "#C9C2B4", + "--ve-heading": "#26282D", + "--ve-text": "#26282D", + "--ve-muted": "#57595B", + "--ve-faint": "#7B7D80", + "--ve-accent": "#C05F33", + "--ve-accent-soft": "#F0E1D4", + "--ve-accent-contrast": "#F7F3EA", + "--ve-info": "#4A86C8", + "--ve-warn": "#CC9F45", + "--ve-danger": "#96412F", + "--ve-ok": "#5FA97C", + "--ve-err": "#96412F", + "--ve-diagram-bg": "#F7F3EA", + "--ve-grid-line": "#C9C2B4", + "--ve-node-bg": "#F7F3EA", + "--ve-node-stroke": "#C9C2B4", + "--ve-diagram-ink": "#26282D", + "--ve-diagram-muted": "#57595B", + "--ve-diagram-frame": "#C9C2B4", + "--ve-diagram-accent-fill": "#F4EADF", + "--ve-diagram-grid-size": "32", + "--ve-grid-dot-r": "0", + "--ve-grid-dot-opacity": "0", + "--ve-grid-line-opacity": "0.45", + "--ve-grid-line-width": "1", + "--ve-grid-scanline-opacity": "0", + "--ve-node-radius": "0", + "--ve-chip-radius": "0", + "--ve-code-bg": "#26282D", + "--ve-code-text": "rgb(247 243 234 / 0.88)", + "--ve-code-muted": "rgb(247 243 234 / 0.5)", + "--ve-code-rule": "rgb(247 243 234 / 0.18)", + "--ve-code-accent": "#CC9F45", + "--ve-poster-bg": "#F7F3EA", + "--ve-poster-text": "#26282D", + "--ve-poster-muted": "rgb(38 40 45 / 0.62)", + "--ve-poster-rule": "rgb(38 40 45 / 0.2)", + "--ve-poster-grid": "rgb(38 40 45 / 0.08)", + "--ve-ease": "cubic-bezier(0.33,1,0.68,1)" + } +} diff --git a/evals/fixtures/design-systems/expected/acme-url-tokens.json b/evals/fixtures/design-systems/expected/acme-url-tokens.json new file mode 100644 index 0000000..027a2e8 --- /dev/null +++ b/evals/fixtures/design-systems/expected/acme-url-tokens.json @@ -0,0 +1,18 @@ +{ + "description": "Golden core tokens for the saved Acme brand page fixture (url modality). Hand-verified against the fixture CSS: :root custom props supply bg/text/muted/rule/accent, hue classification supplies warn, @font-face + font-family declarations supply the three stacks.", + "tokens": { + "--ve-bg": "#0E1116", + "--ve-text": "#E6EDF3", + "--ve-heading": "#E6EDF3", + "--ve-muted": "#9DA7B1", + "--ve-faint": "#9DA7B1", + "--ve-rule": "#2D333B", + "--ve-accent": "#3FB6A8", + "--ve-accent-contrast": "#0E1116", + "--ve-warn": "#D4A72C", + "--ve-font-display": "\"Acme Serif\", Georgia, serif", + "--ve-font-body": "\"Acme Grotesk\", ui-sans-serif, system-ui, sans-serif", + "--ve-font-mono": "\"Acme Mono\", ui-monospace, SFMono-Regular, Menlo, monospace" + }, + "fontFaces": ["Acme Grotesk", "Acme Mono"] +} diff --git a/evals/fixtures/design-systems/expected/brand-palette-image.json b/evals/fixtures/design-systems/expected/brand-palette-image.json new file mode 100644 index 0000000..c7b7f6d --- /dev/null +++ b/evals/fixtures/design-systems/expected/brand-palette-image.json @@ -0,0 +1,14 @@ +{ + "description": "Expected quantized palette + core mapping for brand-palette.png (image modality). The fixture is three solid blocks of the synthetic acme-terracotta palette: paper #F7F3EA (~52%), ink #26282D (~29%), terracotta #C05F33 (~17%). Tolerance-based: quantization averages within-bucket antialiasing, so channels may drift a little.", + "channelTolerance": 12, + "palette": [ + { "hex": "#F7F3EA", "minShare": 0.4 }, + { "hex": "#26282D", "minShare": 0.2 }, + { "hex": "#C05F33", "minShare": 0.1 } + ], + "core": { + "bg": "#F7F3EA", + "text": "#26282D", + "accent": "#C05F33" + } +} diff --git a/evals/fixtures/design-systems/image/brand-palette.png b/evals/fixtures/design-systems/image/brand-palette.png new file mode 100644 index 0000000000000000000000000000000000000000..028bac5d96c4ae9d0fc08d0af1e2590265a10a07 GIT binary patch literal 374 zcmeAS@N?(olHy`uVBq!ia0vp^CxAGGg9%9bJb4krz`&^O>EaktG3U({L*C{928N6G zmqkg5wLD;59FdrsaFk`b!>4&|r~jTymfv%8o8IHfeauXUloWM@h6FMHzX|`2*~t?H u#5fZvCw2e>?f?I~rM6dr3^q`(F*BSMV4ZGT&yx-mVeoYIb6Mw<&;$VB{%A%3 literal 0 HcmV?d00001 diff --git a/evals/fixtures/design-systems/url/index.html b/evals/fixtures/design-systems/url/index.html new file mode 100644 index 0000000..7c8592c --- /dev/null +++ b/evals/fixtures/design-systems/url/index.html @@ -0,0 +1,23 @@ + + + + + + Acme Analytics — Brand + + + + +
+

Acme Analytics

+

Design foundations for the Acme dashboard suite.

+
+ + diff --git a/evals/fixtures/design-systems/url/styles.css b/evals/fixtures/design-systems/url/styles.css new file mode 100644 index 0000000..141a86d --- /dev/null +++ b/evals/fixtures/design-systems/url/styles.css @@ -0,0 +1,46 @@ +/* Eval fixture stylesheet for the saved Acme brand page. */ +:root { + --color-background: #0e1116; + --color-text: #e6edf3; + --color-muted: #9da7b1; + --color-border: #2d333b; + --color-accent: #3fb6a8; + --color-warning: #d4a72c; +} + +@font-face { + font-family: "Acme Grotesk"; + src: url("/fonts/acme-grotesk.woff2") format("woff2"); + font-weight: 400 700; +} + +@font-face { + font-family: "Acme Mono"; + src: url("/fonts/acme-mono.woff2") format("woff2"); +} + +body { + background: var(--color-background); + color: var(--color-text); + font-family: "Acme Grotesk", ui-sans-serif, system-ui, sans-serif; +} + +h1, +h2 { + font-family: "Acme Serif", Georgia, serif; + color: var(--color-text); +} + +code, +pre { + font-family: "Acme Mono", ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.panel { + background: #161b22; + border: 1px solid var(--color-border); +} + +a { + color: var(--color-accent); +} From 797158db631a4ae00ec1b92d7acf2cb406a6ae89 Mon Sep 17 00:00:00 2001 From: Clayton Kim Date: Wed, 15 Jul 2026 11:42:43 -0700 Subject: [PATCH 2/5] feat(ve-mdx): external design-system registry resolved at export Design systems are user-owned directories (tokens.css with --ve-* custom properties in a :root block + manifest.json with provenance/fonts/notes) resolved in order: $ARTIFACTURE_DESIGN_DIR -> ~/.artifacture/ design-systems -> /design-systems (first hit wins; deduped when ~/.artifacture is itself a repo clone). ve:export scans the source for preset references, re-scopes the winning tokens.css to [data-ve-preset=""] with derived diagram fallbacks and manifest font imports, and inlines it into the standalone HTML. Unknown names warn and fall back to built-in mono-industrial tokens; malformed systems throw instead of silently falling through; built-in names never consult the registry. The repo ships NO systems: repo-local design-systems/ is gitignored (only its README is tracked) because brand tokens are usually private and ~/.artifacture may BE a repo clone. Co-Authored-By: Claude Fable 5 --- .gitignore | 9 + design-systems/README.md | 31 +++ scripts/ve-mdx/design-systems.mjs | 321 +++++++++++++++++++++++++ scripts/ve-mdx/design-systems.test.mjs | 102 ++++++++ scripts/ve-mdx/export.mjs | 21 +- visual-explainer-mdx/components.tsx | 13 +- 6 files changed, 495 insertions(+), 2 deletions(-) create mode 100644 design-systems/README.md create mode 100644 scripts/ve-mdx/design-systems.mjs create mode 100644 scripts/ve-mdx/design-systems.test.mjs diff --git a/.gitignore b/.gitignore index 2d66e5b..6814a4a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,15 @@ skills-lock.json /mono-industrial-*-geist-pixel.png /slides-*.png +# Design-system registry (repo-local fallback). Design systems are USER-OWNED, +# usually PRIVATE artifacts; the loader also resolves +# ~/.artifacture/design-systems, and ~/.artifacture may itself BE a clone of +# this repo — in that case this repo-local directory IS the user's global +# registry, so learned/private systems landing here must never be committed by +# accident. Only the README ships. +/design-systems/* +!/design-systems/README.md + # Publish hygiene (Artifacture) .codex-briefs/ evals/model-matrix/out/ diff --git a/design-systems/README.md b/design-systems/README.md new file mode 100644 index 0000000..eb3af3e --- /dev/null +++ b/design-systems/README.md @@ -0,0 +1,31 @@ +# Design-system registry (repo-local fallback) + +This directory is the LOWEST-priority location the design-system loader +searches. Design systems are user-owned — and usually private — artifacts +that live outside the repo: + +1. `$ARTIFACTURE_DESIGN_DIR` (explicit override) +2. `~/.artifacture/design-systems/` (user-global registry — the recommended + home for your systems, especially brand tokens that must not be published) +3. `/design-systems/` (this directory — repo-local fallback) + +First hit wins. If `~/.artifacture` is itself a clone of this repo, locations +2 and 3 are the same directory; the loader dedupes the search list, and this +directory's contents are gitignored (see the repo `.gitignore`) so +learned/private systems can never be committed by accident. This repo +intentionally ships NO systems here — the repo ships the mechanism, your +registry holds the brand. + +Each system is a directory named by its slug: + +``` +design-systems// + tokens.css --ve-* custom properties in a :root { ... } block + manifest.json name, description, source provenance, fonts, notes +``` + +For example, a private `acme-brand/tokens.css` would set `--ve-bg`, +`--ve-text`, `--ve-accent`, the three `--ve-font-*` stacks, and friends; its +`manifest.json` records where the tokens were learned from and the brand's +hard rules. Draft one from your own sources with `npm run ve:learn` and refine +it per `docs/design-systems.md`. diff --git a/scripts/ve-mdx/design-systems.mjs b/scripts/ve-mdx/design-systems.mjs new file mode 100644 index 0000000..ffc4ef0 --- /dev/null +++ b/scripts/ve-mdx/design-systems.mjs @@ -0,0 +1,321 @@ +// Design-system registry loader. +// +// A design system is a user-owned directory (one per system) containing: +// tokens.css — `--ve-*` custom properties, inside a `:root { ... }` block +// (or bare declarations); the loader re-scopes them to +// `[data-ve-preset=""]` when inlining. +// manifest.json — name, description, source provenance, font loads and +// fallbacks, plus free-form notes (e.g. hard rules). +// +// Resolution order (first hit wins): +// 1. $ARTIFACTURE_DESIGN_DIR +// 2. ~/.artifacture/design-systems/ +// 3. /design-systems/ (repo-local fallback, gitignored except +// shipped examples) +// +// Collision note: ~/.artifacture may itself BE a clone of this repo. In that +// case path 2 and path 3 are the same directory; the search list is deduped +// so the directory is consulted exactly once, at the higher-priority slot. +// See docs/design-systems.md for the full contract. +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +export const BUILTIN_PRESETS = new Set([ + 'mono-industrial', + 'nothing', + 'blueprint', + 'editorial', + 'paper-ink', + 'terminal', + 'custom', +]); + +export const DEFAULT_FALLBACK_PRESET = 'mono-industrial'; + +// Token keys a complete design system is expected to provide (directly or via +// the derived fallbacks the exporter injects). Used for coverage reporting by +// the loader, `ve:learn`, and the design-system evals. +export const REQUIRED_VE_TOKENS = [ + '--ve-font-display', + '--ve-font-body', + '--ve-font-mono', + '--ve-display-weight', + '--ve-heading-weight', + '--ve-radius', + '--ve-bg', + '--ve-bg-alt', + '--ve-panel', + '--ve-panel-strong', + '--ve-row', + '--ve-nav-bg', + '--ve-review-bg', + '--ve-rule', + '--ve-heading', + '--ve-text', + '--ve-muted', + '--ve-faint', + '--ve-accent', + '--ve-accent-soft', + '--ve-accent-contrast', + '--ve-info', + '--ve-warn', + '--ve-danger', + '--ve-ok', + '--ve-err', + '--ve-diagram-bg', + '--ve-grid-line', + '--ve-node-bg', + '--ve-node-stroke', + '--ve-diagram-grid-size', + '--ve-grid-dot-r', + '--ve-grid-dot-opacity', + '--ve-grid-line-opacity', + '--ve-grid-scanline-opacity', + '--ve-node-radius', + '--ve-chip-radius', + '--ve-code-bg', + '--ve-code-text', + '--ve-code-muted', + '--ve-code-rule', + '--ve-code-accent', + '--ve-poster-bg', + '--ve-poster-text', + '--ve-poster-muted', + '--ve-poster-rule', + '--ve-poster-grid', +]; + +export class DesignSystemError extends Error { + constructor(message, { name, dir } = {}) { + super(message); + this.name = 'DesignSystemError'; + this.systemName = name; + this.dir = dir; + } +} + +const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/; + +export function assertValidSlug(name) { + if (!SLUG_PATTERN.test(name)) { + throw new DesignSystemError( + `Invalid design-system name "${name}" — use a lowercase slug (letters, digits, hyphens).`, + { name }, + ); + } +} + +/** + * Ordered, deduped list of registry directories to search. + * Injectable env/home/repoRoot keep this testable without touching the + * process environment. + */ +export function designSystemSearchPaths({ + env = process.env, + home = os.homedir(), + repoRoot = process.cwd(), +} = {}) { + const paths = []; + if (env.ARTIFACTURE_DESIGN_DIR) paths.push(path.resolve(env.ARTIFACTURE_DESIGN_DIR)); + paths.push(path.join(home, '.artifacture', 'design-systems')); + paths.push(path.join(repoRoot, 'design-systems')); + return [...new Set(paths)]; +} + +/** + * Resolve `name` against the registry. Returns + * `{ name, dir, tokensCss, manifest, searchPath }` or null when no registry + * directory contains the system. A directory that EXISTS but is malformed + * (missing tokens.css or manifest.json, or unparseable manifest) throws a + * DesignSystemError — a half-formed system that would silently shadow a + * lower-priority one is a footgun, so it fails loudly instead of skipping. + */ +export function resolveDesignSystem(name, opts = {}) { + assertValidSlug(name); + for (const searchPath of designSystemSearchPaths(opts)) { + const dir = path.join(searchPath, name); + if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) continue; + + const tokensPath = path.join(dir, 'tokens.css'); + const manifestPath = path.join(dir, 'manifest.json'); + if (!fs.existsSync(tokensPath)) { + throw new DesignSystemError( + `Design system "${name}" at ${dir} is missing tokens.css.`, + { name, dir }, + ); + } + if (!fs.existsSync(manifestPath)) { + throw new DesignSystemError( + `Design system "${name}" at ${dir} is missing manifest.json.`, + { name, dir }, + ); + } + let manifest; + try { + manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + } catch (error) { + throw new DesignSystemError( + `Design system "${name}" at ${dir} has a malformed manifest.json: ${error.message}`, + { name, dir }, + ); + } + return { + name, + dir, + searchPath, + tokensCss: fs.readFileSync(tokensPath, 'utf8'), + manifest, + }; + } + return null; +} + +/** All resolvable system names across the search paths (for diagnostics). */ +export function listDesignSystems(opts = {}) { + const names = new Set(); + for (const searchPath of designSystemSearchPaths(opts)) { + if (!fs.existsSync(searchPath)) continue; + for (const entry of fs.readdirSync(searchPath, { withFileTypes: true })) { + if (entry.isDirectory() && SLUG_PATTERN.test(entry.name)) names.add(entry.name); + } + } + return [...names].sort(); +} + +const DECLARATION_PATTERN = /(--[\w-]+)\s*:\s*([^;}]+)/g; + +/** Parse every custom-property declaration in a tokens.css text. */ +export function parseTokenDeclarations(css) { + const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ''); + const declarations = []; + for (const match of withoutComments.matchAll(DECLARATION_PATTERN)) { + declarations.push([match[1], match[2].trim()]); + } + return declarations; +} + +/** + * Re-scope a tokens.css file to `[data-ve-preset=""]`. tokens.css is + * declaration-only by contract (a `:root { ... }` block is the recommended + * authoring form); any non-custom-property rules are ignored. + */ +export function scopeTokensCss(css, name) { + const declarations = parseTokenDeclarations(css); + if (declarations.length === 0) { + throw new DesignSystemError( + `Design system "${name}" tokens.css contains no custom-property declarations.`, + { name }, + ); + } + const body = declarations.map(([prop, value]) => ` ${prop}: ${value};`).join('\n'); + return `[data-ve-preset="${name}"] {\n${body}\n}`; +} + +// Derived diagram fallbacks mirroring global.css's built-in +// [data-ve-preset="custom"] block, emitted BEFORE the user tokens so an +// explicit token in tokens.css always wins. +const DERIVED_FALLBACKS = [ + '--ve-diagram-ink: var(--ve-heading)', + '--ve-diagram-muted: var(--ve-muted)', + '--ve-diagram-frame: var(--ve-rule)', + '--ve-diagram-accent-fill: color-mix(in srgb, var(--ve-accent) 14%, transparent)', +]; + +/** + * Full CSS text to inline for a resolved system: optional font @imports from + * the manifest, then derived fallbacks + tokens scoped to the preset name. + */ +export function designSystemStyleCss(system) { + const imports = (system.manifest?.fonts?.imports ?? []) + .map((url) => `@import url('${url}');`) + .join('\n'); + const fallbacks = DERIVED_FALLBACKS.map((line) => ` ${line};`).join('\n'); + const scoped = scopeTokensCss(system.tokensCss, system.name).replace( + '{\n', + `{\n${fallbacks}\n`, + ); + // Name only — no local filesystem paths in a shareable artifact. + const header = `/* artifacture design system: ${system.name} */`; + return [header, imports, scoped].filter(Boolean).join('\n'); +} + +/** Which REQUIRED_VE_TOKENS a tokens.css leaves unset (derived ones excluded). */ +export function missingRequiredTokens(css) { + const provided = new Set(parseTokenDeclarations(css).map(([prop]) => prop)); + return REQUIRED_VE_TOKENS.filter((token) => !provided.has(token)); +} + +// Preset names referenced by an MDX/TSX source: data-ve-preset attributes and +// `preset` props/keys with a string-literal value (preset="x", preset={'x'}, +// preset: 'x'). +const PRESET_REFERENCE_PATTERNS = [ + /data-ve-preset\s*=\s*[{]?\s*["']([\w-]+)["']/g, + /\bpreset\s*[:=]\s*[{]?\s*["']([\w-]+)["']/g, +]; + +export function presetNamesInSource(code) { + const names = new Set(); + for (const pattern of PRESET_REFERENCE_PATTERNS) { + for (const match of code.matchAll(pattern)) names.add(match[1]); + } + return [...names]; +} + +/** + * Extract a built-in preset's token block from global.css and re-scope it to + * `name` — the deterministic fallback when a preset is neither built in nor + * in the registry (the artifact still renders with the default look instead + * of unset tokens). + */ +export function builtinFallbackCss(name, globalCss, fallbackPreset = DEFAULT_FALLBACK_PRESET) { + const pattern = new RegExp(`\\[data-ve-preset="${fallbackPreset}"\\]\\s*\\{([^}]*)\\}`); + const match = globalCss.match(pattern); + if (!match) { + throw new DesignSystemError( + `Could not find built-in preset "${fallbackPreset}" in global.css to use as a fallback.`, + { name }, + ); + } + return `/* unknown preset "${name}": falling back to built-in "${fallbackPreset}" tokens */\n[data-ve-preset="${name}"] {${match[1]}}`; +} + +/** + * Export-pipeline entry point. Scans a source for preset references, resolves + * non-built-in names against the registry, and returns the CSS to inline plus + * human-readable warnings. Unknown names fall back to the default built-in + * tokens (with a warning); malformed systems keep throwing loudly. + */ +export function resolvePresetCssForExport(sourceCode, opts = {}) { + const { globalCssPath } = opts; + const styles = []; + const warnings = []; + for (const name of presetNamesInSource(sourceCode)) { + if (BUILTIN_PRESETS.has(name)) continue; + if (!SLUG_PATTERN.test(name)) continue; + const system = resolveDesignSystem(name, opts); + if (system) { + const missing = missingRequiredTokens(system.tokensCss); + // Derived fallbacks cover the diagram-ink family; anything else unset + // is worth surfacing but not fatal. + const uncovered = missing.filter((token) => !DERIVED_FALLBACKS.some((line) => line.startsWith(`${token}:`))); + if (uncovered.length > 0) { + warnings.push( + `design system "${name}" leaves ${uncovered.length} recommended token(s) unset: ${uncovered.join(', ')}`, + ); + } + styles.push(designSystemStyleCss(system)); + continue; + } + const searched = designSystemSearchPaths(opts).join(', '); + const available = listDesignSystems(opts); + warnings.push( + `unknown preset "${name}": not a built-in preset and not found in the design-system registry ` + + `(searched: ${searched}${available.length ? `; available: ${available.join(', ')}` : ''}). ` + + `Falling back to built-in "${DEFAULT_FALLBACK_PRESET}" tokens.`, + ); + const globalCss = fs.readFileSync(globalCssPath, 'utf8'); + styles.push(builtinFallbackCss(name, globalCss)); + } + return { css: styles.length ? styles.join('\n') : null, warnings }; +} diff --git a/scripts/ve-mdx/design-systems.test.mjs b/scripts/ve-mdx/design-systems.test.mjs new file mode 100644 index 0000000..da7deef --- /dev/null +++ b/scripts/ve-mdx/design-systems.test.mjs @@ -0,0 +1,102 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + BUILTIN_PRESETS, + DesignSystemError, + designSystemSearchPaths, + presetNamesInSource, + resolveDesignSystem, + scopeTokensCss, +} from './design-systems.mjs'; + +function tempRegistries() { + const root = mkdtempSync(join(tmpdir(), 've-ds-test-')); + const envDir = join(root, 'env-registry'); + const home = join(root, 'home'); + const repoRoot = join(root, 'repo'); + mkdirSync(envDir, { recursive: true }); + mkdirSync(join(home, '.artifacture/design-systems'), { recursive: true }); + mkdirSync(join(repoRoot, 'design-systems'), { recursive: true }); + return { root, envDir, home, homeRegistry: join(home, '.artifacture/design-systems'), repoRoot }; +} + +function writeSystem(registryDir, name, accent) { + const dir = join(registryDir, name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'tokens.css'), `:root {\n --ve-accent: ${accent};\n}\n`); + writeFileSync(join(dir, 'manifest.json'), `${JSON.stringify({ name })}\n`); +} + +test('search paths follow env > home > repo order and dedupe the repo-clone collision', () => { + const paths = designSystemSearchPaths({ + env: { ARTIFACTURE_DESIGN_DIR: '/custom/dir' }, + home: '/home/u', + repoRoot: '/repo', + }); + assert.deepEqual(paths, ['/custom/dir', '/home/u/.artifacture/design-systems', '/repo/design-systems']); + + // ~/.artifacture IS the repo clone → user-global and repo-local registries + // are the same directory, consulted once. + const collided = designSystemSearchPaths({ env: {}, home: '/home/u', repoRoot: '/home/u/.artifacture' }); + assert.deepEqual(collided, ['/home/u/.artifacture/design-systems']); +}); + +test('resolution order: env registry beats home registry beats repo-local', () => { + const { root, envDir, home, homeRegistry, repoRoot } = tempRegistries(); + try { + writeSystem(envDir, 'acme', '#111111'); + writeSystem(homeRegistry, 'acme', '#222222'); + writeSystem(join(repoRoot, 'design-systems'), 'acme', '#333333'); + + const withEnv = resolveDesignSystem('acme', { env: { ARTIFACTURE_DESIGN_DIR: envDir }, home, repoRoot }); + assert.match(withEnv.tokensCss, /#111111/); + + const withoutEnv = resolveDesignSystem('acme', { env: {}, home, repoRoot }); + assert.match(withoutEnv.tokensCss, /#222222/); + + const repoOnly = resolveDesignSystem('acme', { env: {}, home: join(root, 'nohome'), repoRoot }); + assert.match(repoOnly.tokensCss, /#333333/); + + assert.equal(resolveDesignSystem('missing', { env: {}, home, repoRoot }), null); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('malformed systems fail loudly instead of falling through', () => { + const { root, home, homeRegistry, repoRoot } = tempRegistries(); + try { + const dir = join(homeRegistry, 'broken'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'tokens.css'), ':root { --ve-accent: #123456; }\n'); + writeFileSync(join(dir, 'manifest.json'), '{ nope'); + assert.throws( + () => resolveDesignSystem('broken', { env: {}, home, repoRoot }), + (error) => error instanceof DesignSystemError && /malformed manifest/.test(error.message), + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('scopeTokensCss re-scopes :root declarations to the preset attribute', () => { + const scoped = scopeTokensCss(':root {\n --ve-bg: #fff;\n --ve-text: #111;\n}\n', 'acme'); + assert.match(scoped, /^\[data-ve-preset="acme"\] \{/); + assert.match(scoped, /--ve-bg: #fff;/); + assert.match(scoped, /--ve-text: #111;/); + assert.throws(() => scopeTokensCss('body { color: red; }', 'acme'), /no custom-property declarations/); +}); + +test('presetNamesInSource finds preset props and data attributes, builtin set intact', () => { + const code = ` + +
+ + `; + assert.deepEqual(presetNamesInSource(code).sort(), ['acme-dark', 'acme-terracotta', 'terminal']); + assert.equal(BUILTIN_PRESETS.has('terminal'), true); + assert.equal(BUILTIN_PRESETS.has('acme-terracotta'), false); +}); diff --git a/scripts/ve-mdx/export.mjs b/scripts/ve-mdx/export.mjs index 5d85606..c0162e8 100644 --- a/scripts/ve-mdx/export.mjs +++ b/scripts/ve-mdx/export.mjs @@ -8,6 +8,7 @@ import react from '@vitejs/plugin-react'; import mdx from '@mdx-js/rollup'; import tailwindcss from '@tailwindcss/vite'; import { preflightSource } from './integrity.mjs'; +import { resolvePresetCssForExport } from './design-systems.mjs'; const repoRoot = process.cwd(); @@ -115,7 +116,25 @@ async function main() { }); const html = await fs.readFile(path.join(dist, 'index.html'), 'utf8'); - const generated = await inlineAssets(html, dist); + let generated = await inlineAssets(html, dist); + + // Resolve data-ve-preset names against the external design-system + // registry ($ARTIFACTURE_DESIGN_DIR → ~/.artifacture/design-systems → + // /design-systems) and inline the matching tokens.css. Unknown + // names fall back to built-in tokens with a warning. + const sourceCode = await fs.readFile(source, 'utf8'); + const { css: designSystemCss, warnings } = resolvePresetCssForExport(sourceCode, { + repoRoot, + globalCssPath: path.join(repoRoot, 'visual-explainer-mdx/global.css'), + }); + for (const warning of warnings) console.warn(`WARN: ${warning}`); + if (designSystemCss) { + generated = generated.replace( + '', + `\n`, + ); + } + await fs.mkdir(path.dirname(out), { recursive: true }); await fs.writeFile(out, generated); console.log(`Generated ${out}`); diff --git a/visual-explainer-mdx/components.tsx b/visual-explainer-mdx/components.tsx index 5375dad..b88a4fa 100644 --- a/visual-explainer-mdx/components.tsx +++ b/visual-explainer-mdx/components.tsx @@ -1,7 +1,18 @@ import React, { useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react'; import { edgePath, labelLeaderEndpoint, layoutDiagram, mobileConnectorEdges, splitSvgText, wrapWords, type LaidOutNode } from './diagram-layout'; -export type VisualPreset = 'mono-industrial' | 'nothing' | 'blueprint' | 'editorial' | 'paper-ink' | 'terminal' | 'custom'; +// Built-in preset names, plus any slug resolvable from the external +// design-system registry (see docs/design-systems.md). `(string & {})` keeps +// literal autocompletion for the built-ins while admitting registry names. +export type VisualPreset = + | 'mono-industrial' + | 'nothing' + | 'blueprint' + | 'editorial' + | 'paper-ink' + | 'terminal' + | 'custom' + | (string & {}); type ShellProps = { title: string; From d8a0f3672386a7a5cfd5d806760afbf3b38b063c Mon Sep 17 00:00:00 2001 From: Clayton Kim Date: Wed, 15 Jul 2026 11:43:02 -0700 Subject: [PATCH 3/5] =?UTF-8?q?feat(ve-mdx):=20ve:learn=20=E2=80=94=20lear?= =?UTF-8?q?n=20design-system=20tokens=20from=20code,=20URL,=20or=20image?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm run ve:learn -- --name [--out ] drafts a design system into the registry (default: $ARTIFACTURE_DESIGN_DIR, else ~/.artifacture/design-systems — the private, user-global home for brand tokens). Modalities: code files (named hex tokens, font stacks, weight/size ramps, grid geometry, easing), URLs (:root custom props, @font-face, dominant colors across fetched linked CSS), and images (palette quantization via a canvas in Playwright's bundled Chromium). A shared deterministic mapper turns any extraction into a full --ve-* token set (name hints first, then contrast/saturation/coverage statistics; panel/accent tints precomposited to solid hex so light systems survive naive contrast tooling). The draft manifest records provenance plus an extraction report: per-token decisions, size ramp, and required-token coverage. Pure heuristics live in learn-extractors.mjs; fetch/browser I/O in learn-sources.mjs (injectable fetch, no network in tests). The evals/design-systems suite is the spec these are fit to. Co-Authored-By: Claude Fable 5 --- scripts/ve-mdx/learn-extractors.mjs | 541 +++++++++++++++++++++++ scripts/ve-mdx/learn-extractors.test.mjs | 117 +++++ scripts/ve-mdx/learn-sources.mjs | 77 ++++ scripts/ve-mdx/learn.mjs | 143 ++++++ 4 files changed, 878 insertions(+) create mode 100644 scripts/ve-mdx/learn-extractors.mjs create mode 100644 scripts/ve-mdx/learn-extractors.test.mjs create mode 100644 scripts/ve-mdx/learn-sources.mjs create mode 100644 scripts/ve-mdx/learn.mjs diff --git a/scripts/ve-mdx/learn-extractors.mjs b/scripts/ve-mdx/learn-extractors.mjs new file mode 100644 index 0000000..6b8a928 --- /dev/null +++ b/scripts/ve-mdx/learn-extractors.mjs @@ -0,0 +1,541 @@ +// Pure extraction + token-mapping logic behind `npm run ve:learn`. +// +// Three modalities feed one shared mapper: +// code — TS/JS/CSS token objects (hex colors, font stacks, size ramps) +// url — fetched HTML + linked CSS (:root custom props, @font-face, +// dominant colors); network I/O lives in learn.mjs, this module +// only sees the already-fetched texts +// image — RGBA pixels (palette quantization); pixel capture lives in +// learn.mjs, quantization + mapping here +// +// Everything here is deterministic — the design-system evals +// (evals/design-systems/) treat these functions as the spec: fixture in, +// golden tokens out. Heuristic changes must hill-climb those evals. + +// --------------------------------------------------------------------------- +// Color math +// --------------------------------------------------------------------------- + +export function normalizeHex(value) { + let hex = value.trim().toLowerCase(); + if (!hex.startsWith('#')) return null; + hex = hex.slice(1); + if (hex.length === 3 || hex.length === 4) hex = [...hex].map((ch) => ch + ch).join(''); + if (hex.length === 8) hex = hex.slice(0, 6); // strip alpha for classification + if (hex.length !== 6 || /[^0-9a-f]/.test(hex)) return null; + return `#${hex}`; +} + +export function hexToRgb(hex) { + const normalized = normalizeHex(hex); + if (!normalized) return null; + return [1, 3, 5].map((i) => parseInt(normalized.slice(i, i + 2), 16)); +} + +export function rgbToHex([r, g, b]) { + return `#${[r, g, b].map((ch) => Math.round(Math.max(0, Math.min(255, ch))).toString(16).padStart(2, '0')).join('')}`.toUpperCase(); +} + +export function luminance(hex) { + const rgb = hexToRgb(hex); + if (!rgb) return 0; + const [r, g, b] = rgb.map((ch) => { + const s = ch / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +export function contrastRatio(a, b) { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +export function saturation(hex) { + const rgb = hexToRgb(hex); + if (!rgb) return 0; + const [r, g, b] = rgb.map((ch) => ch / 255); + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + if (max === min) return 0; + const l = (max + min) / 2; + const d = max - min; + return l > 0.5 ? d / (2 - max - min) : d / (max + min); +} + +export function hue(hex) { + const rgb = hexToRgb(hex); + if (!rgb) return 0; + const [r, g, b] = rgb.map((ch) => ch / 255); + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + if (max === min) return 0; + const d = max - min; + let h; + if (max === r) h = ((g - b) / d) % 6; + else if (max === g) h = (b - r) / d + 2; + else h = (r - g) / d + 4; + return ((h * 60) + 360) % 360; +} + +/** Solid composite of fg over bg at `alpha` (the deck's solidTint idiom). */ +export function mixHex(fg, bg, alpha) { + const fgRgb = hexToRgb(fg); + const bgRgb = hexToRgb(bg); + return rgbToHex(fgRgb.map((ch, i) => alpha * ch + (1 - alpha) * bgRgb[i])); +} + +/** `rgb(r g b / a)` string in the global.css idiom. */ +export function rgbA(hex, alpha) { + const [r, g, b] = hexToRgb(hex); + return `rgb(${r} ${g} ${b} / ${alpha})`; +} + +// --------------------------------------------------------------------------- +// Modality detection +// --------------------------------------------------------------------------- + +const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', '.bmp']); + +export function detectModality(source) { + if (/^https?:\/\//i.test(source)) return 'url'; + const ext = source.slice(source.lastIndexOf('.')).toLowerCase(); + if (IMAGE_EXTENSIONS.has(ext)) return 'image'; + return 'code'; +} + +// --------------------------------------------------------------------------- +// Code extractor +// --------------------------------------------------------------------------- + +const FONT_GENERICS = /\b(serif|sans-serif|monospace|system-ui|ui-monospace|ui-sans-serif|ui-serif)\b/; + +function looksLikeFontStack(value) { + return value.includes(',') && FONT_GENERICS.test(value); +} + +/** + * Extract named hex colors, font stacks, font weights, size ramps, grid + * geometry, and easing from a TS/JS/CSS token source. + */ +export function extractFromCode(text) { + const notes = []; + const colors = []; + const seen = new Set(); + + // `name: "#hex"` / `name = '#hex'` / `--name: #hex` pairs. + const namedHex = /(?:["']?([\w-]+)["']?\s*[:=]\s*["']?)(#[0-9a-fA-F]{3,8})\b/g; + for (const match of text.matchAll(namedHex)) { + const hex = normalizeHex(match[2]); + if (!hex) continue; + const name = (match[1] ?? '').replace(/^--/, ''); + const key = `${name}:${hex}`; + if (seen.has(key)) continue; + seen.add(key); + colors.push({ name, value: hex }); + } + + // Font stacks: string values containing a comma-separated list ending in a + // generic family, keyed by their property name. + const fonts = {}; + const stringValue = /["']?([\w-]+)["']?\s*[:=]\s*(["'])((?:\\.|(?!\2).)*)\2/g; + for (const match of text.matchAll(stringValue)) { + const [, name, , raw] = match; + if (!looksLikeFontStack(raw)) continue; + const slot = classifyFontSlot(name, raw); + if (slot && !fonts[slot]) fonts[slot] = raw; + } + + // Font weights (numeric); the mode is used as the display/heading weight. + const weights = [...text.matchAll(/fontWeight:\s*(\d{3})|font-weight:\s*(\d{3})/g)] + .map((match) => Number(match[1] ?? match[2])); + + // Size ramp: fontSize values (px numbers). + const sizes = [...new Set( + [...text.matchAll(/fontSize:\s*(\d+(?:\.\d+)?)|font-size:\s*(\d+(?:\.\d+)?)px/g)] + .map((match) => Number(match[1] ?? match[2])), + )].sort((a, b) => a - b); + + // Grid geometry: backgroundSize "40px 40px" + the grid line's rgba alpha + // (an rgba(...) appearing shortly before the backgroundSize declaration). + let grid = null; + const gridSize = text.match(/backgroundSize:\s*["'](\d+)px\s+\d+px["']/); + if (gridSize) { + grid = { size: Number(gridSize[1]) }; + // The grid line color is the nearest rgba(...) declared before the + // backgroundSize declaration (i.e. inside the same grid-paper helper). + const before = text.slice(Math.max(0, gridSize.index - 400), gridSize.index); + const alphas = [...before.matchAll(/rgba?\([\d\s,]+(0?\.\d+)\s*\)/g)]; + if (alphas.length > 0) grid.lineOpacity = Number(alphas.at(-1)[1]); + } + + // Signature easing. + const ease = text.match(/cubic-bezier\(\s*[\d., ]+\)/)?.[0] ?? null; + + // Border radii (numeric tokens; 999 pills excluded from the surface radius). + const radii = [...text.matchAll(/borderRadius:\s*(\d+)/g)] + .map((match) => Number(match[1])) + .filter((value) => value < 100); + + if (colors.length === 0) notes.push('no hex colors found in source'); + return { modality: 'code', colors, fonts, weights, sizes, grid, ease, radii, notes }; +} + +function classifyFontSlot(name, stack) { + const lower = (name ?? '').toLowerCase(); + if (/display|heading|title|serif/.test(lower)) return 'display'; + if (/mono|code/.test(lower)) return 'mono'; + if (/body|sans|text|base|font/.test(lower)) return 'body'; + if (/monospace|ui-monospace/.test(stack)) return 'mono'; + if (/\bserif\b/.test(stack) && !/sans-serif/.test(stack)) return 'display'; + return 'body'; +} + +// --------------------------------------------------------------------------- +// URL (HTML + CSS) extractor — network-free: caller supplies fetched texts +// --------------------------------------------------------------------------- + +/** + * Extract tokens from a fetched page: `html` plus the text of every + * stylesheet (`cssTexts`, inline '; + const css = ` + :root { --brand-bg: #101418; --brand-text: #f0f4f8; } + @font-face { font-family: "Acme Grotesk"; src: url(x.woff2); } + body { font-family: "Acme Grotesk", ui-sans-serif, system-ui, sans-serif; } + code { font-family: ui-monospace, monospace; } + `; + const extraction = extractFromHtml({ html, cssTexts: [css] }); + assert.deepEqual( + extraction.colors.map((color) => [color.name, color.value]), + [ + ['brand-bg', '#101418'], + ['brand-text', '#f0f4f8'], + ], + ); + assert.deepEqual(extraction.fontFaces, ['Acme Grotesk']); + assert.equal(extraction.fonts.body, '"Acme Grotesk", ui-sans-serif, system-ui, sans-serif'); + // @font-face's own font-family must not claim a slot by itself. + assert.notEqual(extraction.fonts.display, '"Acme Grotesk"'); + assert.equal(extraction.title, 'Acme'); +}); + +test('image quantization: solid blocks come back as a coverage-ranked palette', () => { + // 8x4 synthetic RGBA buffer: 50% white, 25% near-black, 25% red. + const pixels = []; + const push = (rgb, count) => { + for (let i = 0; i < count; i += 1) pixels.push(...rgb, 255); + }; + push([255, 255, 255], 16); + push([16, 16, 20], 8); + push([200, 30, 40], 8); + const palette = quantizePalette(pixels); + assert.equal(palette[0].value, '#FFFFFF'); + assert.equal(palette[0].share, 0.5); + assert.deepEqual(palette.map((entry) => entry.value).sort(), ['#101014', '#C81E28', '#FFFFFF']); + + const { core } = mapExtractionToCore({ + modality: 'image', + colors: [], + dominant: palette.map(({ value, share }) => ({ name: '', value, count: share })), + fonts: {}, + notes: [], + }); + assert.equal(core.bg, '#FFFFFF'); + assert.equal(core.text, '#101014'); + assert.equal(core.accent, '#C81E28'); +}); diff --git a/scripts/ve-mdx/learn-sources.mjs b/scripts/ve-mdx/learn-sources.mjs new file mode 100644 index 0000000..883bb7b --- /dev/null +++ b/scripts/ve-mdx/learn-sources.mjs @@ -0,0 +1,77 @@ +// I/O shells for ve:learn's url and image modalities. Kept separate from the +// learn.mjs CLI so the eval harness (evals/design-systems/) and tests can +// import them without triggering the CLI, and separate from +// learn-extractors.mjs so that module stays pure/deterministic. +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { extractFromHtml, extractionFromPalette, quantizePalette } from './learn-extractors.mjs'; + +/** + * Fetch a page plus its linked stylesheets and run the HTML/CSS extractor. + * `fetchImpl` is injectable for tests/evals (no live network in CI). + */ +export async function extractFromUrlSource(url, { fetchImpl = fetch, maxStylesheets = 10 } = {}) { + const response = await fetchImpl(url, { redirect: 'follow' }); + if (!response.ok) throw new Error(`Failed to fetch ${url}: HTTP ${response.status}`); + const html = await response.text(); + const cssTexts = [...html.matchAll(/]*>([\s\S]*?)<\/style>/gi)].map((match) => match[1]); + const hrefs = [...html.matchAll(/]+rel=["']stylesheet["'][^>]*>/gi)] + .map((match) => match[0].match(/href=["']([^"']+)["']/)?.[1]) + .filter(Boolean) + .slice(0, maxStylesheets); + for (const href of hrefs) { + try { + const cssUrl = new URL(href, url).toString(); + const cssResponse = await fetchImpl(cssUrl, { redirect: 'follow' }); + if (cssResponse.ok) cssTexts.push(await cssResponse.text()); + } catch (error) { + console.warn(`WARN: could not fetch stylesheet ${href}: ${error.message}`); + } + } + return extractFromHtml({ html, cssTexts }); +} + +/** + * Decode an image and quantize its palette using a canvas in Playwright's + * bundled Chromium (already a repo dependency). + */ +export async function extractFromImageSource(imagePath) { + const { chromium } = await import('playwright'); + const browser = await chromium.launch(); + try { + const page = await browser.newPage(); + // data: URL rather than file:// — Chromium refuses file:// subresources + // from a non-file page. + const MIME_TYPES = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp', + '.gif': 'image/gif', + '.avif': 'image/avif', + '.bmp': 'image/bmp', + }; + const mime = MIME_TYPES[path.extname(imagePath).toLowerCase()] ?? 'image/png'; + const bytes = await fs.readFile(path.resolve(imagePath)); + const imageUrl = `data:${mime};base64,${bytes.toString('base64')}`; + await page.goto('about:blank'); + const data = await page.evaluate(async (src) => { + const image = new Image(); + image.src = src; + await image.decode(); + const SAMPLE = 96; // downsample: palette needs coverage, not detail + const scale = Math.min(1, SAMPLE / Math.max(image.width, image.height)); + const width = Math.max(1, Math.round(image.width * scale)); + const height = Math.max(1, Math.round(image.height * scale)); + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(image, 0, 0, width, height); + return [...ctx.getImageData(0, 0, width, height).data]; + }, imageUrl); + return extractionFromPalette(quantizePalette(data)); + } finally { + await browser.close(); + } +} diff --git a/scripts/ve-mdx/learn.mjs b/scripts/ve-mdx/learn.mjs new file mode 100644 index 0000000..c1112ec --- /dev/null +++ b/scripts/ve-mdx/learn.mjs @@ -0,0 +1,143 @@ +#!/usr/bin/env node +// ve:learn — learn a draft design system from a source and write it into the +// design-system registry for human/agent refinement. +// +// npm run ve:learn -- --name [--out ] [--force] +// +// is one of: +// code — a TS/JS/CSS file with token objects (hex colors, font stacks, +// size ramps) +// url — an http(s) page; fetches the HTML plus linked CSS and extracts +// :root custom properties, @font-face, and dominant colors +// image — a PNG/JPEG/WebP; palette quantization via a canvas rendered in +// Playwright's bundled Chromium +// +// Output: //{tokens.css,manifest.json} with provenance and an +// extraction report. Default registry dir: $ARTIFACTURE_DESIGN_DIR, else +// ~/.artifacture/design-systems. Heuristics are deterministic; the +// design-system evals (evals/design-systems/) are their spec. +import fs from 'node:fs/promises'; +import { readFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { assertValidSlug, REQUIRED_VE_TOKENS } from './design-systems.mjs'; +import { + detectModality, + extractFromCode, + mapExtractionToCore, + deriveTokens, + tokensCssText, +} from './learn-extractors.mjs'; +import { extractFromUrlSource, extractFromImageSource } from './learn-sources.mjs'; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(SCRIPT_DIR, '../..'); + +function parseArgs(argv) { + const args = [...argv]; + const source = args.shift(); + let name = null; + let out = null; + let force = false; + for (let i = 0; i < args.length; i += 1) { + if (args[i] === '--name') name = args[++i]; + else if (args[i] === '--out') out = args[++i]; + else if (args[i] === '--force') force = true; + else throw new Error(`Unknown argument: ${args[i]}`); + } + if (!source || !name) { + throw new Error('Usage: npm run ve:learn -- --name [--out ] [--force]'); + } + assertValidSlug(name); + return { source, name, out, force }; +} + +function defaultRegistryDir(env = process.env) { + if (env.ARTIFACTURE_DESIGN_DIR) return path.resolve(env.ARTIFACTURE_DESIGN_DIR); + return path.join(os.homedir(), '.artifacture', 'design-systems'); +} + +function buildManifest({ name, source, modality, mapped, tokens }) { + const produced = Object.keys(tokens); + const missing = REQUIRED_VE_TOKENS.filter((token) => !produced.includes(token)); + const pkg = JSON.parse(readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')); + return { + name, + status: 'draft', + description: `Draft design system learned from ${modality} source — review and refine before relying on it.`, + source: { + kind: modality, + location: source, + learnedAt: new Date().toISOString(), + tool: `artifacture ve:learn ${pkg.version}`, + }, + fonts: { + imports: [], + stacks: { + display: mapped.core.fontDisplay, + body: mapped.core.fontBody, + mono: mapped.core.fontMono, + }, + note: 'Add remote font imports here if the brand requires webfonts; stacks must end in a system fallback so artifacts degrade gracefully offline.', + }, + notes: [ + 'DRAFT: heuristically extracted. Refine per docs/design-systems.md (agent-assisted refinement flow), then remove the draft status.', + ], + extraction: { + decisions: mapped.decisions, + notes: mapped.notes, + sizes: mapped.core.sizes, + ease: mapped.core.ease, + coverage: { + produced: produced.length, + required: REQUIRED_VE_TOKENS.length, + missingRequired: missing, + }, + }, + }; +} + +async function main() { + const { source, name, out, force } = parseArgs(process.argv.slice(2)); + const modality = detectModality(source); + + let extraction; + if (modality === 'url') { + extraction = await extractFromUrlSource(source); + } else if (modality === 'image') { + extraction = await extractFromImageSource(source); + } else { + extraction = extractFromCode(await fs.readFile(path.resolve(source), 'utf8')); + } + + const mapped = mapExtractionToCore(extraction); + const tokens = deriveTokens(mapped.core); + const manifest = buildManifest({ name, source, modality, mapped, tokens }); + + const registryDir = out ? path.resolve(out) : defaultRegistryDir(); + const systemDir = path.join(registryDir, name); + try { + await fs.access(systemDir); + if (!force) { + throw new Error(`Refusing to overwrite existing design system at ${systemDir} (pass --force to replace).`); + } + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + await fs.mkdir(systemDir, { recursive: true }); + await fs.writeFile(path.join(systemDir, 'tokens.css'), tokensCssText(tokens, { name })); + await fs.writeFile(path.join(systemDir, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); + + console.log(`Learned design system "${name}" (${modality}) → ${systemDir}`); + console.log(` tokens: ${Object.keys(tokens).length} produced, ${manifest.extraction.coverage.missingRequired.length} required missing`); + for (const [slot, why] of Object.entries(mapped.decisions)) console.log(` ${slot}: ${why}`); + for (const note of mapped.notes) console.log(` note: ${note}`); + console.log('Draft written — refine tokens.css/manifest.json before shipping (docs/design-systems.md).'); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); From 476c41cd3414e06f16177f3471e8d1370420310d Mon Sep 17 00:00:00 2001 From: Clayton Kim Date: Wed, 15 Jul 2026 11:43:02 -0700 Subject: [PATCH 4/5] chore(release): wire design-system checks into npm/CI, document, bump to 0.8.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - npm test (node:test: loader, extractors, diagram layout) added and run in the CI static job - npm run ve:eval now runs the design-system eval suite as its second leg (image case uses the chromium already installed in the evals job) - docs/design-systems.md: registry format, resolution order and the ~/.artifacture-repo-clone collision, ve:learn modalities, and the agent-assisted refinement flow. Design systems are intentionally external, user-owned artifacts — the repo ships the mechanism and synthetic eval fixtures only; private brand registries live outside the repo - README, CONTRIBUTING, SKILL.md updated; CHANGELOG 0.8.0; version sources bumped in lockstep (check:manifests green) Co-Authored-By: Claude Fable 5 --- .claude-plugin/marketplace.json | 2 +- .github/workflows/ci.yml | 1 + CHANGELOG.md | 16 ++ CONTRIBUTING.md | 21 ++- README.md | 2 + docs/design-systems.md | 154 ++++++++++++++++++ package.json | 8 +- .../.claude-plugin/plugin.json | 2 +- plugins/visual-explainer/SKILL.md | 4 +- 9 files changed, 202 insertions(+), 8 deletions(-) create mode 100644 docs/design-systems.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index c5f264e..1658b9d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -13,7 +13,7 @@ "name": "visual-explainer", "source": "./plugins/visual-explainer", "description": "Generate beautiful HTML pages for diagrams, diff reviews, plan reviews, slides, and data tables", - "version": "0.7.0", + "version": "0.8.0", "author": { "name": "Clayton Kim" }, diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31e7a58..f84d0d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,7 @@ jobs: node-version-file: '.nvmrc' cache: npm - run: npm ci + - run: npm test - run: npm run ve:check-integrity # Plan 004 (release hygiene) landed before this workflow was written, # so all manifest version sources already agree — this job is diff --git a/CHANGELOG.md b/CHANGELOG.md index b8deaa3..42b2634 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [0.8.0] - 2026-07-15 + +### External design-system registry +- Design systems are now user-owned artifacts resolved from a registry OUTSIDE the skill/repo: `$ARTIFACTURE_DESIGN_DIR` → `~/.artifacture/design-systems/` → `/design-systems/` (first hit wins; deduped when `~/.artifacture` is itself a repo clone). +- Format: one directory per system with `tokens.css` (`--ve-*` custom properties in a `:root` block, re-scoped to `[data-ve-preset=""]` at export) and `manifest.json` (description, source provenance, font loads/fallbacks, hard-rule notes). +- `ve:export` resolves non-built-in `data-ve-preset` names against the registry and inlines the tokens into the standalone HTML; unknown names warn and fall back to built-in `mono-industrial` tokens; malformed systems fail loudly. Built-ins can never be shadowed. +- Repo-local `design-systems/` is gitignored (only its README is tracked) so learned/private systems can't be committed by accident — the repo ships the mechanism, user registries hold the (typically private) brand tokens. + +### ve:learn token learning +- New `npm run ve:learn -- --name [--out ]` drafts a design system from a code file (hex tokens, font stacks, size ramps, grid geometry, easing), a URL (`:root` custom props, `@font-face`, dominant colors from linked CSS), or an image (palette quantization via a Playwright canvas). +- Drafts land in the registry with a manifest `extraction` report: per-token mapping decisions, size ramp, and required-token coverage. Agent-assisted refinement flow documented in `docs/design-systems.md`. +- Deterministic heuristics are eval-driven: `evals/design-systems/` (second leg of `npm run ve:eval`) pins fixture sources to golden token sets per modality plus loader resolution-order/fallback/malformed-manifest cases. + +### Tooling +- New `npm test` (node:test) covering the loader, extractors, and diagram layout; wired into CI alongside the expanded `ve:eval`. + ## [0.7.0] - 2026-07-04 ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 67a96ea..1b42f2b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,10 +40,29 @@ selected via `data-ve-preset=""` on the root. Add a new tokens the existing presets set (mono-industrial, nothing, blueprint, editorial, paper-ink, terminal, custom) — don't introduce new token names. +## Adding or changing a design system + +Design systems are user-owned — usually private brand material — and live +OUTSIDE the repo (see `docs/design-systems.md`): `$ARTIFACTURE_DESIGN_DIR` → +`~/.artifacture/design-systems/` → `/design-systems/`. The repo ships +NO systems; the repo-local directory is gitignored (only its README is +tracked). Never commit a real brand's tokens, and keep eval fixtures +synthetic. + +If you change the `ve:learn` extraction heuristics or the registry loader +(`scripts/ve-mdx/design-systems.mjs`, `learn-extractors.mjs`, +`learn-sources.mjs`), the eval suite in `evals/design-systems/` is the spec: +fixture sources with golden expected tokens plus loader resolution-order +cases, run as the second leg of `npm run ve:eval`. Update or extend the +fixtures/goldens under `evals/fixtures/design-systems/` with the change — +never weaken a golden to make a heuristic pass without hand-verifying the new +values against the fixture source. + ## Before you open a PR +- `npm test` must pass. - `npm run ve:check` must pass. -- `npm run ve:eval` must pass. +- `npm run ve:eval` must pass (verifier evals + design-system evals). - If you touched or generated an HTML artifact, run the verifier on it (`node plugins/visual-explainer/scripts/verify/ve-verify.mjs `) and fix any error-severity failures. diff --git a/README.md b/README.md index 7c8864c..a28b12e 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ Video formats (9:16 reel, 16:9 long-form) render to MP4 through Hyperframes; sam - **`/explain-diff`**: a literate diff mode (background → intuition → walkthrough → quiz), adapted from Geoffrey Litt's prompt pattern. - **Model-matrix eval harness** (`evals/model-matrix/`): the same briefs across Kimi, GLM, DeepSeek, Claude, and Codex, scored on deterministic compliance plus a blind screenshot judge. Point it at your own model in about ten minutes. - **One-command team sharing**: `share.sh` deploys to Vercel (zero setup, public) or sharehtml on Cloudflare (stable update-in-place URLs, team SSO via Cloudflare Access, comments). See `docs/TEAM-SHARING.md`. +- **External design systems + `ve:learn`**: brand token sets are user-owned artifacts resolved from a registry outside the skill (`$ARTIFACTURE_DESIGN_DIR` → `~/.artifacture/design-systems/` → repo `design-systems/`) and inlined into exports by preset name. `npm run ve:learn -- --name ` drafts a system from a token source, a live page, or an image palette; deterministic heuristics are pinned by their own eval suite (`evals/design-systems/`). The repo ships the mechanism only — systems (typically private brand tokens) live in your own registry. See `docs/design-systems.md`. ## Install @@ -110,6 +111,7 @@ For private team sharing setup, see [`docs/TEAM-SHARING.md`](docs/TEAM-SHARING.m ## Docs - [Features](docs/features.md): the full capability reference, including all output modes, aesthetics, and how generation works. +- [Design systems](docs/design-systems.md): the external design-system registry format, resolution order, `ve:learn` token learning, and the agent-assisted refinement flow. - [Team sharing](docs/TEAM-SHARING.md): one-command deploys to Vercel or a team-gated Cloudflare space. - [Skill docs](plugins/visual-explainer/SKILL.md): what an agent actually reads, plus the per-use-case [cards](plugins/visual-explainer/cards/). - [Verifier](plugins/visual-explainer/scripts/verify/): the deterministic design-quality gate and its [eval suite](evals/). diff --git a/docs/design-systems.md b/docs/design-systems.md new file mode 100644 index 0000000..d1fef5f --- /dev/null +++ b/docs/design-systems.md @@ -0,0 +1,154 @@ +# External design systems + +Artifacture's built-in presets (`mono-industrial`, `nothing`, `blueprint`, +`editorial`, `paper-ink`, `terminal`, `custom`) live in +`visual-explainer-mdx/global.css`. Everything else is a **design system**: a +user-owned artifact maintained OUTSIDE the skill and the repo, resolved from a +registry at export time. Your brand tokens survive skill upgrades because they +were never inside the skill to begin with. + +## File format + +One directory per system, named by its slug: + +``` +// + tokens.css the system's --ve-* custom properties + manifest.json metadata, provenance, fonts, notes +``` + +### tokens.css + +Declaration-only CSS: `--ve-*` custom properties inside a `:root { ... }` +block (bare declarations also work; any non-custom-property rules are +ignored). At export the loader re-scopes the declarations to +`[data-ve-preset=""]`, so plain `:root` keeps the file valid, +editor-friendly CSS while guaranteeing the tokens can never leak outside the +preset scope. + +Cover at least the core roles — bg / surfaces (`panel`, `panel-strong`, +`row`) / text (`heading`, `text`, `muted`, `faint`) / `accent` (+ `-soft`, +`-contrast`) / `rule` / status colors / the three font stacks / weights / +`radius` — plus the extended diagram, code, and poster groups. The canonical +key list is `REQUIRED_VE_TOKENS` in `scripts/ve-mdx/design-systems.mjs`; the +exporter warns about unset recommended tokens. Four diagram tokens +(`--ve-diagram-ink/muted/frame/accent-fill`) get derived fallbacks +automatically, mirroring the built-in `custom` preset. + +### manifest.json + +```json +{ + "name": "", + "description": "One-paragraph identity of the system.", + "source": { "kind": "code|url|image", "location": "...", "tool": "..." }, + "fonts": { + "imports": ["https://fonts.googleapis.com/css2?family=..."], + "stacks": { "display": "...", "body": "...", "mono": "..." }, + "note": "licensing / fallback expectations" + }, + "notes": ["Hard rules and usage guidance that travel with the system."] +} +``` + +- `source` is provenance: where the tokens came from and how. +- `fonts.imports` are inlined as `@import` lines ahead of the tokens. Remote + fonts are a self-containment trade-off — every stack must end in a system + fallback so artifacts degrade gracefully offline. +- `notes` carry the system's hard rules (e.g. "solid fills over grid paper", + "accent color reserved for CTAs") so an agent styling with the system can + honor them. + +## Registry resolution order + +1. `$ARTIFACTURE_DESIGN_DIR` — explicit override, wins outright. +2. `~/.artifacture/design-systems/` — the user-global registry (recommended + home for your systems). +3. `/design-systems/` — repo-local fallback, for clones that want + project-scoped systems. The repo itself ships NO systems here (design + systems are usually private brand material); only the directory README is + tracked. + +First hit wins; lookup is by directory name. + +**Collision note:** `~/.artifacture` may itself BE a clone of this repo. In +that case locations 2 and 3 are the *same* directory — the loader dedupes the +search list so it is consulted exactly once (at the higher-priority slot), and +the repo's `.gitignore` excludes `design-systems/*` so systems you learn into +your global registry can never be committed to the repo by accident. + +A directory that exists but is malformed (missing `tokens.css` or +`manifest.json`, unparseable manifest) fails the export loudly rather than +silently falling through to a lower-priority registry — a half-formed system +shadowing another is a debugging trap. + +## Using a system + +Reference its slug anywhere a preset name goes: + +```mdx + +``` + +`npm run ve:export` scans the source for preset references, resolves +non-built-in names against the registry, and inlines the tokens (scoped, with +derived fallbacks and font imports) into the standalone HTML as a +`