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/.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/CHANGELOG.md b/CHANGELOG.md index b8deaa3..b4c51d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # 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. + +### Security +- Token values and manifest font imports are validated before they can reach a shared artifact: values containing `<` or control characters are rejected loudly (style-element breakout), font imports must be plain http(s) URLs, and the CSS injector uses a function replacer (no `$&` splicing) plus a final ``/` + + + + 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); +} diff --git a/package.json b/package.json index 1a9a977..03f4b8f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "artifacture", - "version": "0.7.0", + "version": "0.8.0", "description": "Agent skill that generates verified, self-contained HTML visual explainers from MDX/React sources — with a deterministic design-quality verifier and a multi-model eval harness", "keywords": [ "claude-code-plugin", @@ -16,9 +16,11 @@ "ve:check": "node scripts/ve-mdx/check.mjs", "ve:check-integrity": "node scripts/ve-mdx/check-integrity.mjs", "ve:verify": "node plugins/visual-explainer/scripts/verify/ve-verify.mjs", - "ve:eval": "node evals/run.mjs", + "ve:eval": "node evals/run.mjs && node evals/design-systems/run.mjs", "ve:eval-rubrics": "node evals/run-rubrics.mjs", - "check:manifests": "node scripts/check-manifests.mjs" + "check:manifests": "node scripts/check-manifests.mjs", + "ve:learn": "node scripts/ve-mdx/learn.mjs", + "test": "node --test visual-explainer-mdx/diagram-layout.test.mjs scripts/ve-mdx/design-systems.test.mjs scripts/ve-mdx/learn-extractors.test.mjs" }, "dependencies": { "@mdx-js/rollup": "^3.1.1", diff --git a/plugins/visual-explainer/.claude-plugin/plugin.json b/plugins/visual-explainer/.claude-plugin/plugin.json index 017b30a..80204cc 100644 --- a/plugins/visual-explainer/.claude-plugin/plugin.json +++ b/plugins/visual-explainer/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "visual-explainer", "description": "Generate beautiful HTML pages for diagrams, diff reviews, plan reviews, and data tables", - "version": "0.7.0", + "version": "0.8.0", "author": { "name": "Clayton Kim" }, diff --git a/plugins/visual-explainer/SKILL.md b/plugins/visual-explainer/SKILL.md index 0588cc2..157160f 100644 --- a/plugins/visual-explainer/SKILL.md +++ b/plugins/visual-explainer/SKILL.md @@ -6,7 +6,7 @@ compatibility: Requires a browser to view generated HTML files. Optional surf-cl metadata: author: nicobailon (original visual-explainer) maintainer: Clayton Kim - version: "0.7.0" + version: "0.8.0" --- # Visual Explainer @@ -58,7 +58,7 @@ Workflow, in order: - Slide(title,kicker?,tone?) - PosterCanvas(eyebrow?,title,stat?,footer?,preset?) -Presets: mono-industrial, nothing, blueprint, editorial, paper-ink, terminal, custom; use `--ve-*`. +Presets: mono-industrial, nothing, blueprint, editorial, paper-ink, terminal, custom; use `--ve-*`. Any other preset name resolves against the external design-system registry ($ARTIFACTURE_DESIGN_DIR → ~/.artifacture/design-systems → repo design-systems/) and its tokens.css is inlined at export; learn new systems with `npm run ve:learn -- --name `. See docs/design-systems.md. |Flow|Card|Tier 2| |-|-|-| diff --git a/scripts/ve-mdx/design-systems.mjs b/scripts/ve-mdx/design-systems.mjs new file mode 100644 index 0000000..574a811 --- /dev/null +++ b/scripts/ve-mdx/design-systems.mjs @@ -0,0 +1,389 @@ +// 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; + +// tokens.css values end up inside a ", +// "