diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f84d0d9..fd6223e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,3 +35,4 @@ jobs: - run: npm ci - run: npx playwright install --with-deps chromium - run: npm run ve:eval + - run: npm run ve:eval-presentation diff --git a/CHANGELOG.md b/CHANGELOG.md index b4c51d7..06b5a8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,19 @@ ### Tooling - New `npm test` (node:test) covering the loader, extractors, and diagram layout; wired into CI alongside the expanded `ve:eval`. +### PresentationDeck engine + deck primitives +- **PresentationDeck engine + deck primitives** (`visual-explainer-mdx/presentation.tsx`, re-exported through `components.tsx`): an alternative to `SlideDeck` for presented, interactive decks. Fixed 1920×1080 stage scaled to fit (ResizeObserver + letterbox), collapsible slide rail (auto-collapse, hover expand, dot column), keyboard nav (arrows/Space/PageUp/PageDown/Home/End), 80px edge click zones, and a mono slide counter. Primitives: `PresentationSlide` (tone chrome via the existing `data-ve-tone` → `--ve-slide-*` mapping), `DrillCard`/`DrillChip`/`DrillSheet` (click-to-expand with corner-anchored expansion, Escape/X close, and a click-anywhere-to-close guard that ignores `button, a, input, select, textarea, [data-interactive]`), `LayerExplorer`, `LadderDiagram`/`FanoutDiagram` (grid-paper backdrops with opaque solid-over-grid fills), `PullQuote`, `Metric`/`StatRow`, `HairlineList`, `Stepper`, `CodePanel`, `MonoLabel`/`DisplayText`, `IconChip` + geometric icons, `ShineOverlay`/`trackShine`, and pure helpers `fitStage`/`shouldDismissDrillSheet`/`tint`/`solidTint`. Everything consumes `--ve-*` tokens (new root token: `--ve-deck-letterbox`); prefers-reduced-motion collapses all deck motion. +- **Presentation eval suite** (`evals/run-presentation.mjs`, `npm run ve:eval-presentation`, wired into CI): the engine's behavioral contract — click-anywhere-to-close guard matrix, keyboard-nav matrix, drill CTA contract (click/Enter/Space; primary solid vs secondary outline computed styles), LayerExplorer selection, reduced-motion, scale-to-fit geometry across viewports, letterboxing, rail collapse/expand widths, preset re-skin assertions, and an allowlist-based scan asserting the shipped module contains no color or font literals at all. Runs against the demo (`examples/visual-explainer-mdx/presentation-deck.tsx`) exported through the standard `ve:export` path. +- Unit tests for the pure deck logic (`visual-explainer-mdx/presentation-core.test.mjs`) and an `npm test` script (also runs the existing diagram-layout tests) now executed in CI. +- Docs: `docs/presentation-deck.md` (when to use PresentationDeck vs SlideDeck). + +### Changed (presentation) +- Keyboard guard hardened: modifier chords (meta/ctrl/alt) are never intercepted, and focus inside `input`/`textarea`/`select`/`[contenteditable]` now owns every key (arrows, Space, Home/End) instead of only Space/Enter. +- Navigation (edge click zones + nav keys) is gated while a drill sheet is open — an edge click can no longer blow past an open sheet; Escape closes first. +- Light-tone slides flip the CTA ink like accent-tone slides do, fixing primary chips rendering ink-on-ink where `--ve-accent` remaps to the ink color. All three pinned by new falsifiable evals. +- `ve-verify` profile detection: artifacts carrying `data-ve-presentation` (fixed-stage decks that never scroll) classify as `page` instead of tripping the scroll-snap `slides` heuristics via bundled Tailwind utilities. +- Roster-sync guard (`scripts/ve-mdx/check.mjs`) now also collects PascalCase named re-exports from `components.tsx`. + ## [0.7.0] - 2026-07-04 ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1b42f2b..19caf21 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,6 +32,22 @@ objects with `id`, `family`, `severity`, `spec`, etc.). To add one: 3. Add it to the component list in `plugins/visual-explainer/SKILL.md`. 4. Run `npm run ve:check` to confirm export integrity holds. +## Presentation deck changes + +The PresentationDeck engine (`visual-explainer-mdx/presentation.tsx` + +`presentation-core.ts`) is contract-pinned by two suites; run both after any +change there: + +- `npm test` — unit tests for the pure logic (scale-to-fit math, the + click-anywhere-to-close guard, tint helpers). +- `npm run ve:eval-presentation` — the behavioral eval suite + (`evals/run-presentation.mjs`, a documented sibling of `evals/run.mjs`): + exports `examples/visual-explainer-mdx/presentation-deck.tsx` through the + standard `ve:export` path and replays the interaction, geometry, and + token-consumption contracts headlessly with Playwright. New engine behavior + should land with a new eval here (and verify it can fail by breaking the + behavior locally before trusting it). + ## Adding a preset Presets are semantic-token layers in `visual-explainer-mdx/global.css`, diff --git a/README.md b/README.md index a28b12e..d7c23c4 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ Video formats (9:16 reel, 16:9 long-form) render to MP4 through Hyperframes; sam - **ve-verify** (`scripts/verify/`): a 200+ check deterministic design-quality gate — static scans, real-browser measurement (390px overflow, WCAG contrast in both themes, Mermaid render), and small blind LLM rubric passes. Exit codes and JSON reports make it usable as a CI gate. Its own eval suite of seeded-violation fixtures proves each check fires. - **Tiered agent docs**: SKILL.md is a ~2.5k-token bootstrap plus one ~300-token card per use case (`cards/`). A covered flow reads about 3,100 tokens instead of 62,000. Deep references load only on escalation. - **17 shared components** (`visual-explainer-mdx/components.tsx`): DiagramCanvas with computed layout and CSS-only mobile linearization, build-time Shiki CodeBlock, DiffBlock, TerminalBlock, JsonTree, an interactive Quiz, MermaidBlock with zoom/pan chrome, decks, posters, and more. Strict-export integrity checks catch bad edge ids and undefined components at build time. +- **PresentationDeck** (`visual-explainer-mdx/presentation.tsx`): a second deck engine for presented (not scrolled) decks — a fixed 1920×1080 stage scaled to fit any screen, collapsible slide rail, keyboard nav, and drill-down primitives (click-to-expand cards/sheets with a click-anywhere-to-close guard, ladder/fanout diagrams, metrics, steppers). Fully `--ve-*` token-driven so every preset skins it; its behavioral contract is pinned by a headless eval suite (`npm run ve:eval-presentation`). See [docs/presentation-deck.md](docs/presentation-deck.md) for when to use it vs `SlideDeck`. - **`/explain-diff`**: a literate diff mode (background → intuition → walkthrough → quiz), adapted from Geoffrey Litt's prompt pattern. - **Model-matrix eval harness** (`evals/model-matrix/`): the same briefs across Kimi, GLM, DeepSeek, Claude, and Codex, scored on deterministic compliance plus a blind screenshot judge. Point it at your own model in about ten minutes. - **One-command team sharing**: `share.sh` deploys to Vercel (zero setup, public) or sharehtml on Cloudflare (stable update-in-place URLs, team SSO via Cloudflare Access, comments). See `docs/TEAM-SHARING.md`. @@ -112,6 +113,7 @@ For private team sharing setup, see [`docs/TEAM-SHARING.md`](docs/TEAM-SHARING.m - [Features](docs/features.md): the full capability reference, including all output modes, aesthetics, and how generation works. - [Design systems](docs/design-systems.md): the external design-system registry format, resolution order, `ve:learn` token learning, and the agent-assisted refinement flow. +- [PresentationDeck vs SlideDeck](docs/presentation-deck.md): which deck engine to reach for, the drill-down primitives, and the eval-pinned behavioral contract. - [Team sharing](docs/TEAM-SHARING.md): one-command deploys to Vercel or a team-gated Cloudflare space. - [Skill docs](plugins/visual-explainer/SKILL.md): what an agent actually reads, plus the per-use-case [cards](plugins/visual-explainer/cards/). - [Verifier](plugins/visual-explainer/scripts/verify/): the deterministic design-quality gate and its [eval suite](evals/). diff --git a/docs/img/examples/presentation-deck.png b/docs/img/examples/presentation-deck.png new file mode 100644 index 0000000..35be144 Binary files /dev/null and b/docs/img/examples/presentation-deck.png differ diff --git a/docs/presentation-deck.md b/docs/presentation-deck.md new file mode 100644 index 0000000..28d5ac3 --- /dev/null +++ b/docs/presentation-deck.md @@ -0,0 +1,91 @@ +# PresentationDeck vs SlideDeck + +Artifacture ships two deck engines. They solve different problems; neither +replaces the other. + +| | `SlideDeck` / `Slide` | `PresentationDeck` / `PresentationSlide` | +|-|-|-| +| Mental model | A scrolling document of slide-sized sections | A fixed 1920×1080 stage a presenter drives | +| Layout | Responsive; content reflows per viewport | Designed once at stage size; scaled to fit, letterboxed | +| Navigation | Scroll / scroll-snap (vertical or horizontal) | Keyboard (arrows, Space, PageUp/Down, Home/End), edge click zones, slide rail | +| Interactivity | Static content, optional review tools | Drill-down cards and sheets, layer explorers, progressive disclosure | +| Reading mode | Self-serve: send the link, reader scrolls | Presented: one slide at a time, details on demand | +| Verifier profile | `slides` (scroll-snap contract) | `page` (fixed stage never scrolls) | +| Best for | Handouts, recaps, docs-as-slides, PDF-ish exports | Exec/architecture walkthroughs, demos, talks with Q&A drill-downs | + +## When to use which + +Use **SlideDeck** when the artifact is read without you in the room. It +behaves like a document: responsive, printable, scannable top to bottom. + +Use **PresentationDeck** when a human presents the artifact. The fixed stage +guarantees your layout survives any projector or window size (the engine +scales `min(w/1920, h/1080)` and letterboxes the rest), and the drill-down +primitives let you keep slides sparse while holding detail one click away — +the deck is the appendix. + +Both are exported the same way (`npm run ve:export -- --out `) +and both consume the `--ve-*` preset tokens, so the same preset skins either. + +## PresentationDeck quick start + +```tsx +import { PresentationDeck, PresentationSlide, DrillCard, StatRow } from 'visual-explainer-mdx/components'; + +export default function Deck() { + return ( + + + + + + + Detail content shown in an expanding sheet. + + + + ); +} +``` + +Notes: + +- **Slide metadata comes from the deck's direct children.** The rail and + tone-matched chrome read `shortTitle`/`tone` off each child element. If you + wrap `PresentationSlide` in your own component (e.g. to hold drill state), + pass `shortTitle` and `tone` at the usage site and forward them. +- **Tones** reuse `Slide`'s contract: `dark` (the preset's base surface), + `light` (`--ve-bg-alt`, opposite polarity), `accent` (`--ve-accent` surface; + CTAs automatically flip to the tone's ink color). +- **Stage size** defaults to 1920×1080; override with + `stageWidth`/`stageHeight`. All font sizes inside slides are stage-space + pixels — the scale transform handles the rest. + +## Primitives + +- **Drill-downs** — `DrillCard` (click-to-expand card), `DrillChip` + (CTA trigger: `variant="primary"` solid fill / `"secondary"` outline), + `DrillSheet` (the expanding surface: closes on Escape, the X, or any click + that isn't on `button, a, input, select, textarea, [data-interactive]`, + with `transform-origin` controlled by `origin` for corner-anchored + expansion), `LayerExplorer` (card list + detail panel). +- **Diagrams** — `LadderDiagram` (ascending staircase on grid paper; card + fills are always opaque so grid lines never bleed through), + `FanoutDiagram` (one source, N outputs). +- **Composition** — `StatRow`/`Metric`, `PullQuote`, `Stepper`, + `HairlineList`, `CodePanel`, `MonoLabel`, `DisplayText`, `IconChip` + the + geometric icon set, `ShineOverlay`/`trackShine` (pointer-follow shine). +- **Helpers** — `fitStage` (scale-to-fit math), `shouldDismissDrillSheet` + (the dismiss guard), `tint`/`solidTint` (hex tinting; for token-driven + fills use the `.ve-pres-solid` opaque-layer idiom instead). + +## Behavioral contract (evals) + +The engine's behavior is pinned by `evals/run-presentation.mjs` +(`npm run ve:eval-presentation`, runs in CI): the click-anywhere-to-close +guard matrix, keyboard-nav matrix, drill CTA contract (click + Enter + Space; +primary vs secondary computed styles), reduced-motion, scale-to-fit geometry +across viewports, rail collapse/expand widths, and preset re-skinning with a +an allowlist-based scan proving the module ships zero color/font literals. Unit tests for the pure logic live in +`visual-explainer-mdx/presentation-core.test.mjs` (`npm test`). Change the +engine, run both. diff --git a/evals/run-presentation.mjs b/evals/run-presentation.mjs new file mode 100644 index 0000000..acf7c8e --- /dev/null +++ b/evals/run-presentation.mjs @@ -0,0 +1,566 @@ +#!/usr/bin/env node +/* + * Behavioral eval suite for the PresentationDeck engine — the documented + * sibling of evals/run.mjs (which evals the ve-verify checker itself against + * seeded violations). This suite is the engine's behavioral CONTRACT: it + * exports the demo deck through the standard ve:export path and replays, + * deterministically and headlessly, the behaviors the engine must keep: + * + * interaction: click-anywhere-to-close guard matrix, Escape/X close, + * keyboard-nav matrix, drill CTA contract (click + Enter + + * Space; primary solid vs secondary outline), LayerExplorer + * selection, prefers-reduced-motion. + * geometry: stage transform == fitStage() math across viewports, + * letterboxing on mismatched ratios, rail collapse/expand + * widths. + * tokens: the same source re-skins under a second preset (computed + * colors actually change) and the shipped module contains no + * color or font literals outside a documented allowlist. + * + * Run: npm run ve:eval-presentation (CI: evals job, after ve:eval) + */ +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { chromium } from 'playwright'; +import { RAIL_COLLAPSED_WIDTH, RAIL_EXPANDED_WIDTH, fitStage } from '../visual-explainer-mdx/presentation-core.ts'; + +const EVAL_ROOT = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(EVAL_ROOT, '..'); +const DEMO_SOURCE = 'examples/visual-explainer-mdx/presentation-deck.tsx'; +const SECOND_PRESET = 'terminal'; + +const results = []; +const consoleErrors = []; + +function record(group, id, run) { + return (async () => { + try { + await run(); + results.push({ group, id, status: 'pass' }); + } catch (error) { + results.push({ group, id, status: 'fail', detail: error instanceof Error ? error.message : String(error) }); + } + })(); +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +function approx(actual, expected, tolerance, label) { + assert( + Math.abs(actual - expected) <= tolerance, + `${label}: expected ${expected} ±${tolerance}, got ${actual}`, + ); +} + +/** WCAG contrast ratio between two computed `rgb(...)`/`rgba(...)` colors. */ +function contrastRatio(a, b) { + const luminance = (css) => { + const match = css.match(/rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)/); + if (!match) throw new Error(`contrastRatio: cannot parse computed color "${css}"`); + const [r, g, bl] = match.slice(1, 4).map((v) => { + const channel = Number(v) / 255; + return channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * bl; + }; + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (hi + 0.05) / (lo + 0.05); +} + +function exportDemo(sourcePath, outPath) { + const run = spawnSync(process.execPath, ['scripts/ve-mdx/export.mjs', sourcePath, '--out', outPath], { + cwd: REPO_ROOT, + encoding: 'utf8', + }); + if (run.status !== 0) { + throw new Error(`ve:export failed for ${sourcePath}:\n${run.stdout}\n${run.stderr}`); + } +} + +async function withPage(browser, { viewport = { width: 1440, height: 900 }, reducedMotion = 'no-preference', url }, fn) { + const context = await browser.newContext({ viewport, reducedMotion }); + const page = await context.newPage(); + page.on('console', (message) => { + if (message.type() === 'error') consoleErrors.push(message.text()); + }); + page.on('pageerror', (error) => consoleErrors.push(String(error))); + try { + await page.goto(url, { waitUntil: 'load' }); + await page.waitForSelector('[data-stage]', { timeout: 10_000 }); + return await fn(page); + } finally { + await context.close(); + } +} + +async function slideIndex(page) { + return Number(await page.locator('[data-slide-index]').getAttribute('data-slide-index')); +} + +async function counterText(page) { + return (await page.locator('[data-ve-deck-counter]').textContent()).trim(); +} + +async function goToSlide(page, index) { + await page.keyboard.press('Home'); + for (let i = 0; i < index; i += 1) await page.keyboard.press('ArrowRight'); + assert((await slideIndex(page)) === index, `failed to navigate to slide ${index}`); +} + +const sheetOpen = (page) => page.locator('[data-drill-open]'); + +async function expectSheetOpen(page, label) { + await page.waitForSelector('[data-drill-open]', { state: 'attached', timeout: 3_000 }).catch(() => { + throw new Error(`${label}: drill sheet did not open`); + }); +} + +async function expectSheetClosed(page, label) { + await page.waitForSelector('[data-drill-open]', { state: 'detached', timeout: 3_000 }).catch(() => { + throw new Error(`${label}: drill sheet did not close`); + }); +} + +async function main() { + const workDir = join(REPO_ROOT, '.ve-mdx-tmp', `pres-eval-${process.pid}`); + mkdirSync(workDir, { recursive: true }); + const primaryHtml = join(workDir, 'presentation-deck.html'); + const secondHtml = join(workDir, `presentation-deck-${SECOND_PRESET}.html`); + const wrapperSource = join(workDir, `wrapper-${SECOND_PRESET}.tsx`); + + // 1. Export the demo through the standard ve:export path — the artifact + // under eval is the exact artifact users ship. + exportDemo(DEMO_SOURCE, primaryHtml); + writeFileSync( + wrapperSource, + `import React from 'react';\nimport PresentationDeckDemo from '../../examples/visual-explainer-mdx/presentation-deck.tsx';\n\nexport default function SecondPresetDemo() {\n return ;\n}\n`, + ); + exportDemo(wrapperSource, secondHtml); + + const primaryUrl = pathToFileURL(primaryHtml).href; + const secondUrl = pathToFileURL(secondHtml).href; + + const browser = await chromium.launch(); + try { + /* ------------------------------------------------------------ */ + /* interaction */ + /* ------------------------------------------------------------ */ + + await record('interaction', 'keyboard-nav-matrix', () => + withPage(browser, { url: primaryUrl }, async (page) => { + const steps = [ + ['ArrowRight', 1], + ['ArrowRight', 2], + ['ArrowLeft', 1], + [' ', 2], + ['PageDown', 3], + ['PageUp', 2], + ['End', 3], + ['ArrowRight', 3], // clamped at the last slide + ['Home', 0], + ['ArrowLeft', 0], // clamped at the first slide + ['PageDown', 1], + ]; + for (const [key, expected] of steps) { + await page.keyboard.press(key === ' ' ? 'Space' : key); + const actual = await slideIndex(page); + assert(actual === expected, `after ${key === ' ' ? 'Space' : key}: expected slide ${expected}, got ${actual}`); + const counter = await counterText(page); + const expectedCounter = `${String(expected + 1).padStart(2, '0')} / 04`; + assert(counter === expectedCounter, `counter after ${key}: expected "${expectedCounter}", got "${counter}"`); + } + }), + ); + + await record('interaction', 'edge-click-zones', () => + withPage(browser, { url: primaryUrl }, async (page) => { + await page.locator('[data-edge-next]').click({ position: { x: 40, y: 450 }, force: true }); + assert((await slideIndex(page)) === 1, 'right edge zone should advance'); + await page.locator('[data-edge-prev]').click({ position: { x: 40, y: 450 }, force: true }); + assert((await slideIndex(page)) === 0, 'left edge zone should go back'); + }), + ); + + await record('interaction', 'drill-dismiss-guard-matrix', () => + withPage(browser, { url: primaryUrl }, async (page) => { + await goToSlide(page, 1); + await page.locator('[data-drill-target="sys-guard"]').click(); + await expectSheetOpen(page, 'sys-guard'); + // Clicks on every interactive element class must NOT close the sheet. + for (const fixture of ['button', 'link', 'input', 'select', 'textarea', 'opt-out']) { + await page.locator(`[data-drill-open] [data-fixture="${fixture}"]`).click(); + assert( + (await sheetOpen(page).count()) === 1, + `click on [data-fixture="${fixture}"] must not dismiss the sheet`, + ); + } + // A click on passive prose MUST close it. + await page.locator('[data-drill-open] p', { hasText: 'as does Escape or the X' }).click(); + await expectSheetClosed(page, 'passive prose click'); + }), + ); + + await record('interaction', 'drill-escape-and-x-close', () => + withPage(browser, { url: primaryUrl }, async (page) => { + await goToSlide(page, 1); + await page.locator('[data-drill-target="sys-guard"]').click(); + await expectSheetOpen(page, 'sys-guard'); + await page.keyboard.press('Escape'); + await expectSheetClosed(page, 'Escape'); + await page.locator('[data-drill-target="sys-guard"]').click(); + await expectSheetOpen(page, 'sys-guard (reopen)'); + await page.locator('[data-drill-close]').click(); + await expectSheetClosed(page, 'close X'); + }), + ); + + await record('interaction', 'drill-cta-open-contract', () => + withPage(browser, { url: primaryUrl }, async (page) => { + // Every sheet-opening trigger must open via click AND Enter AND Space. + const triggers = [ + [0, 'thesis-contract'], + [1, 'sys-guard'], + [1, 'sys-scale'], + [1, 'sys-tokens'], + [2, 'layers-tones'], + [3, 'ask-fanout'], + ]; + for (const [slide, drillId] of triggers) { + for (const mode of ['click', 'Enter', 'Space']) { + await goToSlide(page, slide); + const trigger = page.locator(`[data-drill-target="${drillId}"]`); + if (mode === 'click') { + await trigger.click(); + } else { + await trigger.focus(); + await page.keyboard.press(mode); + } + await expectSheetOpen(page, `${drillId} via ${mode}`); + await page.keyboard.press('Escape'); + await expectSheetClosed(page, `${drillId} via ${mode}`); + } + } + }), + ); + + await record('interaction', 'drill-cta-variant-styles', () => + withPage(browser, { url: primaryUrl }, async (page) => { + const styleOf = (selector) => + page.locator(selector).evaluate((el) => { + const s = getComputedStyle(el); + return { bg: s.backgroundColor, border: s.borderTopColor, color: s.color }; + }); + const primary = await styleOf('[data-drill-target="thesis-contract"][data-drill-variant="primary"]'); + assert(primary.bg !== 'rgba(0, 0, 0, 0)' && primary.bg !== 'transparent', 'primary CTA must have a solid fill'); + assert(primary.bg === primary.border, `primary CTA fill must match its border (solid accent), got bg=${primary.bg} border=${primary.border}`); + assert(primary.color !== primary.bg, 'primary CTA text must contrast its fill'); + await goToSlide(page, 3); + const secondary = await styleOf('[data-drill-target="ask-fanout"][data-drill-variant="secondary"]'); + assert(secondary.bg === 'rgba(0, 0, 0, 0)' || secondary.bg === 'transparent', `secondary CTA must be an outline (transparent fill), got ${secondary.bg}`); + assert(secondary.border === secondary.color, `secondary CTA outline must match its text color, got border=${secondary.border} color=${secondary.color}`); + assert(primary.bg !== secondary.bg, 'primary and secondary CTAs must be visually distinct'); + }), + ); + + await record('interaction', 'layer-explorer-selection', () => + withPage(browser, { url: primaryUrl }, async (page) => { + await goToSlide(page, 2); + assert((await sheetOpen(page).count()) === 0, 'explorer starts on the initial layer (no drill-open)'); + await page.locator('[data-drill-target="deck-layer-primitives"]').click(); + await expectSheetOpen(page, 'layer selection'); + const pressed = await page.locator('[data-drill-target="deck-layer-primitives"]').getAttribute('aria-pressed'); + assert(pressed === 'true', 'selected layer card must be aria-pressed'); + await page.keyboard.press('Escape'); + await expectSheetClosed(page, 'layer selection reset'); + }), + ); + + await record('interaction', 'keyboard-guard-typing-and-modifiers', () => + withPage(browser, { url: primaryUrl }, async (page) => { + // Modifier chords are browser/OS shortcuts — the deck must not steal + // them. (Playwright still delivers the keydown, so a missing guard + // would navigate.) + for (const chord of ['Meta+ArrowRight', 'Control+ArrowRight', 'Alt+ArrowRight', 'Meta+End']) { + await page.keyboard.press(chord); + assert((await slideIndex(page)) === 0, `deck must ignore ${chord}`); + } + await page.keyboard.press('ArrowRight'); + assert((await slideIndex(page)) === 1, 'plain ArrowRight must still navigate'); + // Typing context: with focus in a text field, EVERY key belongs to + // the field. Space must produce a space character (a missing guard + // preventDefault()s it away) and arrows must not navigate. + await page.locator('[data-drill-target="sys-guard"]').click(); + await expectSheetOpen(page, 'typing-guard fixture'); + const textarea = page.locator('[data-drill-open] [data-fixture="textarea"]'); + await textarea.click(); + await textarea.pressSequentially(' typed x y'); + const value = await textarea.inputValue(); + assert(value.includes(' typed x y'), `Space/keys must type into the field, got "${value}"`); + await textarea.press('ArrowLeft'); + await textarea.press('Home'); + await textarea.press('End'); + assert((await slideIndex(page)) === 1, 'arrow/Home/End inside a field must not navigate'); + assert((await sheetOpen(page).count()) === 1, 'typing must not dismiss the sheet'); + }), + ); + + await record('interaction', 'nav-gated-while-sheet-open', () => + withPage(browser, { url: primaryUrl }, async (page) => { + await goToSlide(page, 1); + await page.locator('[data-drill-target="sys-guard"]').click(); + await expectSheetOpen(page, 'nav-gate fixture'); + // Edge zones sit above the sheet (z-50 vs z-40): clicks there must + // neither navigate nor blow past the open sheet. + await page.locator('[data-edge-next]').click({ position: { x: 40, y: 450 }, force: true }); + assert((await slideIndex(page)) === 1, 'edge zone must not navigate while a sheet is open'); + assert((await sheetOpen(page).count()) === 1, 'edge-zone click must not dismiss the sheet'); + for (const key of ['ArrowRight', 'ArrowLeft', 'End', 'Home', 'PageDown']) { + await page.keyboard.press(key); + assert((await slideIndex(page)) === 1, `${key} must not navigate while a sheet is open`); + } + assert((await sheetOpen(page).count()) === 1, 'sheet must survive gated nav keys'); + await page.keyboard.press('Escape'); + await expectSheetClosed(page, 'nav-gate Escape'); + await page.keyboard.press('ArrowRight'); + assert((await slideIndex(page)) === 2, 'nav must resume once the sheet closes'); + }), + ); + + await record('interaction', 'light-tone-primary-cta-contrast', () => + withPage(browser, { url: primaryUrl }, async (page) => { + // On light-tone slides --ve-accent remaps to the ink color, so a + // primary CTA filled with it must flip its text to the slide surface + // — otherwise it renders ink-on-ink (invisible label). + await goToSlide(page, 2); + const chip = await page + .locator('[data-drill-target="layers-tones"][data-drill-variant="primary"]') + .evaluate((el) => { + const s = getComputedStyle(el); + return { bg: s.backgroundColor, border: s.borderTopColor, color: s.color }; + }); + assert(chip.bg === chip.border, `light-tone primary CTA must stay a solid fill, got bg=${chip.bg} border=${chip.border}`); + const ratio = contrastRatio(chip.color, chip.bg); + assert( + ratio >= 3, + `light-tone primary CTA text must contrast its fill (got ${ratio.toFixed(2)}:1 for ${chip.color} on ${chip.bg})`, + ); + }), + ); + + await record('interaction', 'reduced-motion-disables-animation', () => + withPage(browser, { url: primaryUrl, reducedMotion: 'reduce' }, async (page) => { + const animation = await page + .locator('[data-stage] > div') + .first() + .evaluate((el) => getComputedStyle(el).animationDuration); + assert(parseFloat(animation) <= 0.001, `slide-in animation must collapse under reduced motion, got ${animation}`); + const transition = await page.locator('[data-rail]').evaluate((el) => getComputedStyle(el).transitionDuration); + assert( + transition.split(',').every((t) => parseFloat(t) <= 0.001), + `rail transition must collapse under reduced motion, got ${transition}`, + ); + }), + ); + + /* ------------------------------------------------------------ */ + /* geometry */ + /* ------------------------------------------------------------ */ + + for (const viewport of [ + { width: 1440, height: 900 }, + { width: 1920, height: 1080 }, + { width: 1024, height: 768 }, + { width: 800, height: 1000 }, + ]) { + await record('geometry', `scale-to-fit-${viewport.width}x${viewport.height}`, () => + withPage(browser, { url: primaryUrl, viewport }, async (page) => { + const measured = await page.evaluate(() => { + const main = document.querySelector('main'); + const stage = document.querySelector('[data-stage]'); + const matrix = new DOMMatrixReadOnly(getComputedStyle(stage).transform); + return { + availW: main.clientWidth, + availH: main.clientHeight, + scale: matrix.a, + left: parseFloat(getComputedStyle(stage).left), + top: parseFloat(getComputedStyle(stage).top), + }; + }); + const expected = fitStage(measured.availW, measured.availH); + approx(measured.scale, expected.scale, 0.001, 'stage scale'); + approx(measured.left, expected.left, 1, 'letterbox left'); + approx(measured.top, expected.top, 1, 'letterbox top'); + }), + ); + } + + await record('geometry', 'letterbox-on-mismatched-ratio', () => + withPage(browser, { url: primaryUrl, viewport: { width: 800, height: 1000 } }, async (page) => { + const { top, availH, scale } = await page.evaluate(() => { + const main = document.querySelector('main'); + const stage = document.querySelector('[data-stage]'); + const matrix = new DOMMatrixReadOnly(getComputedStyle(stage).transform); + return { top: parseFloat(getComputedStyle(stage).top), availH: main.clientHeight, scale: matrix.a }; + }); + assert(top > 40, `portrait viewport must letterbox above the stage, got top=${top}`); + assert(1080 * scale + top * 2 <= availH + 2, 'stage plus letterbox bands must fill the container height'); + const backdrop = await page.locator('.ve-pres-root').evaluate((el) => getComputedStyle(el).backgroundColor); + assert(backdrop !== 'rgba(0, 0, 0, 0)', 'letterbox backdrop must be painted (via --ve-deck-letterbox)'); + }), + ); + + await record('geometry', 'rail-collapse-and-hover-expand', () => + withPage(browser, { url: primaryUrl }, async (page) => { + const rail = page.locator('[data-rail]'); + await page.waitForFunction( + () => document.querySelector('[data-rail]')?.getAttribute('data-rail-expanded') === 'false', + undefined, + { timeout: 5_000 }, + ); + await page.waitForFunction( + (w) => Math.abs(document.querySelector('[data-rail]').offsetWidth - w) <= 1, + RAIL_COLLAPSED_WIDTH, + { timeout: 3_000 }, + ); + await rail.hover(); + await page.waitForFunction( + (w) => Math.abs(document.querySelector('[data-rail]').offsetWidth - w) <= 1, + RAIL_EXPANDED_WIDTH, + { timeout: 3_000 }, + ); + assert((await rail.getAttribute('data-rail-expanded')) === 'true', 'rail must report expanded on hover'); + // Rail navigation drives the deck. + await page.locator('[data-rail-item="3"]').click(); + assert((await slideIndex(page)) === 2, 'rail item click must navigate'); + }), + ); + + await record('geometry', 'metric-value-no-wrap', async () => { + // Regression: Metric values must render on a single line and inside + // their cell under EVERY preset, including mono-display presets whose + // glyphs are much wider than the default. The second-preset export + // (terminal: fully monospace display font) is the worst case. + for (const url of [primaryUrl, secondUrl]) { + await withPage(browser, { url }, async (page) => { + const metrics = await page.locator('[data-ve-metric-value]').evaluateAll((els) => + els.map((el) => ({ + text: el.textContent, + clientHeight: el.clientHeight, + scrollWidth: el.scrollWidth, + fontSize: parseFloat(getComputedStyle(el).fontSize), + cellWidth: el.parentElement.clientWidth, + })), + ); + assert(metrics.length > 0, 'demo must render at least one Metric'); + for (const m of metrics) { + assert( + m.clientHeight <= m.fontSize * 1.4, + `Metric "${m.text}" wrapped onto multiple lines (height ${m.clientHeight} vs font ${m.fontSize})`, + ); + assert( + m.scrollWidth <= m.cellWidth + 1, + `Metric "${m.text}" overflows its cell (scrollWidth ${m.scrollWidth} vs cell ${m.cellWidth})`, + ); + } + }); + } + }); + + /* ------------------------------------------------------------ */ + /* tokens */ + /* ------------------------------------------------------------ */ + + await record('tokens', 'preset-reskin-changes-computed-colors', async () => { + const sample = (url) => + withPage(browser, { url }, async (page) => ({ + slideBg: await page.locator('[data-ve-slide]').evaluate((el) => getComputedStyle(el).backgroundColor), + ctaBg: await page + .locator('[data-drill-target="thesis-contract"]') + .evaluate((el) => getComputedStyle(el).backgroundColor), + font: await page.locator('[data-ve-slide] h1').evaluate((el) => getComputedStyle(el).fontFamily), + })); + const a = await sample(primaryUrl); + const b = await sample(secondUrl); + assert(a.slideBg !== b.slideBg, `slide surface must reskin per preset (both ${a.slideBg})`); + assert(a.ctaBg !== b.ctaBg, `primary CTA fill must reskin per preset (both ${a.ctaBg})`); + assert(a.font !== b.font, `display font must reskin per preset (both ${a.font})`); + }); + + await record('tokens', 'no-hardcoded-color-or-font-literals-in-module', async () => { + // Generic token-consumption contract: the shipped presentation module + // must contain NO color literals and NO concrete font families at all — + // every color and font arrives via a --ve-* custom property, so any + // preset (including private ones) skins the deck without the module + // knowing a single palette value. Any hex or rgb()/hsl()/oklch()/oklab() + // literal outside the per-file allowlist below fails. This is stronger + // than scanning for known-bad values and needs no knowledge of any + // specific palette. (Neutral CSS keywords like `transparent`/`black` + // inside color-mix() ratios are primitives, not palette values, and are + // out of scope.) + const allowedLiterals = new Map([ + [ + 'visual-explainer-mdx/presentation.tsx', + new Set([ + '#161616', // documented neutral fallback for var(--ve-deck-letterbox, …) + '#0a0a0a', // documented neutral fallback for var(--ve-code-bg, …) + ]), + ], + [ + 'visual-explainer-mdx/presentation-core.ts', + new Set([ + '#336699', // arbitrary hex used in the tint()/solidTint() docstring examples + ]), + ], + [DEMO_SOURCE, new Set()], + ]); + const colorLiteral = /(? { + assert(consoleErrors.length === 0, `console errors during evals:\n${consoleErrors.join('\n')}`); + }); + } finally { + await browser.close(); + rmSync(workDir, { recursive: true, force: true }); + } + + console.log('eval_id,group,status'); + for (const row of results) { + console.log(`${row.id},${row.group},${row.status}`); + } + const failures = results.filter((row) => row.status !== 'pass'); + if (failures.length) { + console.error('\nFailures:'); + for (const failure of failures) { + console.error(`- ${failure.group}/${failure.id}: ${failure.detail}`); + } + process.exit(1); + } + console.log(`\nAll ${results.length} presentation-deck evals passed.`); +} + +main().catch((error) => { + console.error(error); + process.exit(2); +}); diff --git a/examples/visual-explainer-mdx/presentation-deck.tsx b/examples/visual-explainer-mdx/presentation-deck.tsx new file mode 100644 index 0000000..ef26012 --- /dev/null +++ b/examples/visual-explainer-mdx/presentation-deck.tsx @@ -0,0 +1,361 @@ +import React from 'react'; +import { + CodePanel, + DrillCard, + DrillChip, + DrillSheet, + FanoutDiagram, + HairlineList, + IconCorpus, + IconFile, + IconFit, + IconGauge, + IconLoop, + IconTool, + LadderDiagram, + LayerExplorer, + PresentationDeck, + PresentationSlide, + PullQuote, + StatRow, + Stepper, +} from '../../visual-explainer-mdx/components'; +import type { PresentationTone } from '../../visual-explainer-mdx/components'; + +/* PresentationDeck reads shortTitle/tone from its DIRECT children to build + the rail and tone-matched chrome. These slides are wrapped in local + components (they own drill state), so each wrapper receives the metadata + at its usage site and forwards it to the PresentationSlide inside. */ +type SlideMeta = { shortTitle: string; tone: PresentationTone }; + +/* + * Demo for the PresentationDeck engine: a fixed 1920×1080 stage scaled to + * fit, collapsible rail, keyboard nav, and drill-down primitives. The eval + * harness (evals/run-presentation.mjs) drives this exact source through the + * standard ve:export path, so the slides double as interaction fixtures — + * notably the drill sheet on slide 2, which deliberately contains every + * interactive element class the click-anywhere-to-close guard must ignore. + */ + +function ThesisSlide({ shortTitle, tone }: SlideMeta) { + const [open, setOpen] = React.useState(false); + return ( + +
+
+ + +
+
+ +
+ setOpen(true)} /> +
+
+
+ {open ? ( + setOpen(false)} origin="right center"> +
+ --out '], + ['artifact', 'self-contained HTML, inlined CSS and JS'], + ['verify', 've-verify + evals/run-presentation.mjs'], + ]} + /> + +
+
+ ) : null} +
+ ); +} + +function SystemSlide({ shortTitle, tone }: SlideMeta) { + return ( + +
+ +
+ +
+ + {/* Deliberate interaction fixture: one of every element class the + dismiss guard must ignore. The eval clicks each in turn. */} +
+ + + A link keeps its click + + + + +
guarded region
+ +
`); + return document; +} + +test('guard: clicks on passive prose, panels, and the sheet itself dismiss', () => { + const doc = sheetDom(); + for (const id of ['prose', 'panel', 'panel-text']) { + assert.equal(shouldDismissDrillSheet(doc.getElementById(id)), true, `#${id} should dismiss`); + } + assert.equal(shouldDismissDrillSheet(doc.querySelector('[data-drill-open]')), true, 'sheet surface should dismiss'); +}); + +test('guard: clicks on interactive elements never dismiss', () => { + const doc = sheetDom(); + for (const id of ['btn', 'link', 'input', 'select', 'option', 'textarea', 'optout', 'trigger']) { + assert.equal(shouldDismissDrillSheet(doc.getElementById(id)), false, `#${id} should be guarded`); + } +}); + +test('guard: clicks on descendants of interactive elements never dismiss', () => { + const doc = sheetDom(); + for (const id of ['btn-inner', 'link-inner', 'optout-inner', 'trigger-inner']) { + assert.equal(shouldDismissDrillSheet(doc.getElementById(id)), false, `#${id} should be guarded via closest()`); + } +}); + +test('guard: a still-mounted drill trigger ([data-drill-target]) is guarded so the opening click cannot re-close the sheet', () => { + const doc = sheetDom(); + assert.equal(shouldDismissDrillSheet(doc.getElementById('trigger')), false); +}); + +test('guard: null/closest-less targets fail safe (dismiss)', () => { + assert.equal(shouldDismissDrillSheet(null), true); + assert.equal(shouldDismissDrillSheet(undefined), true); + assert.equal(shouldDismissDrillSheet({}), true); +}); + +test('guard: selector covers exactly the documented element classes', () => { + assert.equal( + DRILL_DISMISS_GUARD_SELECTOR, + 'button, a, input, select, textarea, [data-interactive], [data-drill-target]', + ); +}); + +/* ------------------------------------------------------------------ */ +/* tint / solidTint */ +/* ------------------------------------------------------------------ */ + +test('tint: appends the alpha suffix', () => { + assert.equal(tint('#336699', '10'), '#33669910'); +}); + +test('solidTint: composites fg over bg into an opaque hex', () => { + assert.equal(solidTint('#336699', '#FFFFFF', '10'), '#F2F5F9'); + assert.equal(solidTint('#204060', '#F0F0E8', '20'), '#D6DAD7'); + assert.equal(solidTint('#000000', '#FFFFFF', '00'), '#FFFFFF'); + assert.equal(solidTint('#000000', '#FFFFFF', 'FF'), '#000000'); +}); + +test('solidTint: rejects non-hex inputs (CSS vars must use the .ve-pres-solid idiom instead)', () => { + assert.throws(() => solidTint('var(--ve-accent)', '#FFFFFF', '10')); + assert.throws(() => solidTint('#336699', 'white', '10')); +}); diff --git a/visual-explainer-mdx/presentation-core.ts b/visual-explainer-mdx/presentation-core.ts new file mode 100644 index 0000000..8a4d75e --- /dev/null +++ b/visual-explainer-mdx/presentation-core.ts @@ -0,0 +1,104 @@ +/* + * presentation-core.ts — pure, DOM-light logic for the PresentationDeck + * engine. No JSX and no React imports so node:test (and the eval harness) + * can import this file directly under Node's type stripping, the same way + * diagram-layout.test.mjs imports diagram-layout.ts. + */ + +/** Signature easing used for every deck transition. */ +export const presentationEase = 'cubic-bezier(0.22,1,0.36,1)'; + +/** Rail geometry, shared with the eval harness so the numbers cannot drift. */ +export const RAIL_EXPANDED_WIDTH = 260; +export const RAIL_COLLAPSED_WIDTH = 44; + +export interface StageFit { + scale: number; + left: number; + top: number; +} + +/** + * Scale-to-fit math for the fixed-size stage: the stage renders at + * stageWidth×stageHeight and is scaled by min(availW/stageW, availH/stageH), + * then centered (letterboxed) in the leftover space. Scale is clamped at 0 so + * a transiently unmeasured (0×0) container can never produce a negative or + * NaN transform. + */ +export function fitStage( + availWidth: number, + availHeight: number, + stageWidth = 1920, + stageHeight = 1080, +): StageFit { + if (stageWidth <= 0 || stageHeight <= 0) { + throw new Error(`fitStage: stage dimensions must be positive, got ${stageWidth}x${stageHeight}`); + } + const scale = Math.max(0, Math.min(availWidth / stageWidth, availHeight / stageHeight)); + return { + scale, + left: (availWidth - stageWidth * scale) / 2, + top: (availHeight - stageHeight * scale) / 2, + }; +} + +/** Clamp a requested slide index into [0, count-1]. Empty decks pin to 0. */ +export function clampSlideIndex(next: number, count: number): number { + return Math.max(0, Math.min(Math.max(0, count - 1), next)); +} + +/** + * Click-anywhere-to-close guard (hard rule carried over from the production + * deck this module was generalized from): a click on an open DrillSheet + * closes it UNLESS it lands on (or inside) an interactive element — a + * button, link, form control, a region opted out via data-interactive, or a + * still-mounted drill trigger (so the opening click can never immediately + * re-close the sheet it just opened). + */ +export const DRILL_DISMISS_GUARD_SELECTOR = + 'button, a, input, select, textarea, [data-interactive], [data-drill-target]'; + +interface ClosestLike { + closest?: (selector: string) => unknown; +} + +/** + * Returns true when a click landing on `target` should dismiss the sheet. + * Accepts anything Element-like (needs only .closest) so it is testable with + * linkedom and reusable outside React. + */ +export function shouldDismissDrillSheet(target: ClosestLike | null | undefined): boolean { + if (!target || typeof target.closest !== 'function') return true; + return !target.closest(DRILL_DISMISS_GUARD_SELECTOR); +} + +/** Alpha-suffix tinting idiom for 6-digit hex colors: tint('#336699', '10'). */ +export function tint(color: string, alphaHex: string): string { + return `${color}${alphaHex}`; +} + +/** + * Opaque equivalent of tint(): composites `fg` at the given hex alpha over an + * opaque `bg` and returns the resulting SOLID hex. Required for any fill that + * sits on a grid-paper backdrop — grid lines must never show through a box. + * Both inputs must be 6-digit hex colors. When colors are only available as + * CSS custom properties, use the equivalent CSS idiom instead: an opaque + * background-color layered under a translucent background-image gradient + * (see .ve-pres-solid in presentation.tsx). + */ +export function solidTint(fg: string, bg: string, alphaHex: string): string { + const hexPattern = /^#[0-9a-fA-F]{6}$/; + if (!hexPattern.test(fg) || !hexPattern.test(bg)) { + throw new Error(`solidTint: fg and bg must be 6-digit hex colors, got ${fg} / ${bg}`); + } + const alpha = parseInt(alphaHex, 16) / 255; + if (!Number.isFinite(alpha)) throw new Error(`solidTint: invalid alphaHex "${alphaHex}"`); + const channel = (hex: string, i: number) => parseInt(hex.slice(1 + 2 * i, 3 + 2 * i), 16); + return `#${[0, 1, 2] + .map((i) => + Math.round(alpha * channel(fg, i) + (1 - alpha) * channel(bg, i)) + .toString(16) + .padStart(2, '0'), + ) + .join('')}`.toUpperCase(); +} diff --git a/visual-explainer-mdx/presentation.tsx b/visual-explainer-mdx/presentation.tsx new file mode 100644 index 0000000..2d3c43f --- /dev/null +++ b/visual-explainer-mdx/presentation.tsx @@ -0,0 +1,1805 @@ +/* + * presentation.tsx — PresentationDeck engine + deck primitives. + * + * An alternative to SlideDeck/Slide for interactive, presenter-driven decks: + * a fixed-size stage (default 1920×1080) scaled to fit the viewport, a + * collapsible slide rail, keyboard navigation, and drill-down primitives + * (click-to-expand cards and sheets) for progressive disclosure. + * + * Generalized from a production deck. Every color and font is consumed from + * the pipeline's --ve-* custom properties so any preset skins it; slide tones + * reuse the same data-ve-tone="dark|light|accent" → --ve-slide-* mapping that + * Slide uses. Hard-won behaviors preserved: + * - solid-fill-over-grid: boxes on grid-paper backdrops are always opaque + * (opaque background-color under a translucent gradient — the CSS + * equivalent of presentation-core's solidTint()). + * - CTA variant discipline: primary = solid accent fill, secondary = + * accent outline. Drill triggers never look like passive content. + * - click-anywhere-to-close on sheets, guarded by + * closest('button, a, input, select, textarea, [data-interactive], …'). + * - corner-anchored expansion (transform-origin per card position). + * - keyboard accessibility everywhere (real + ); +} + +/** + * Mono trigger chip for drill-downs. Renders a real + ); +} + +/** + * Expanding drill-down surface. Absolutely fills the NEAREST POSITIONED + * ANCESTOR (PresentationSlide's content wrapper is position:relative, so by + * default a sheet covers the slide content area). Escape closes; the X is + * focused on open. Click-anywhere-to-close: any click that does not land on + * an interactive element (see presentation-core's guard selector) closes it. + */ +export function DrillSheet({ + eyebrow, + onClose, + origin, + children, +}: { + eyebrow: string; + onClose: () => void; + /** transform-origin for the corner-anchored open animation, e.g. "left center" */ + origin?: string; + children: ReactNode; +}) { + useEscape(true, onClose); + const handleSurfaceClick = (e: React.MouseEvent) => { + if (shouldDismissDrillSheet(e.target as Element | null)) onClose(); + }; + return ( +
+
+ + {eyebrow} + + +
+ {/* overflow:hidden guarantees sheet content can never cross the sheet + border or overprint the slide footer below it; slides remain + responsible for sizing content to fit unclipped. */} +
{children}
+
+ ); +} + +/** + * Click-to-expand card. Collapsed: bordered card (real button; Enter/Space + * native; data-drill-target; always-visible secondary CTA hint). Open: a + * DrillSheet expanding over the neighbors within the nearest positioned + * ancestor, corner-anchored via `origin`. + */ +export function DrillCard({ + drillId, + eyebrow, + title, + body, + hint = 'Click for detail', + accent, + detailEyebrow, + origin = 'left center', + minHeight = 158, + cardStyle, + children, +}: { + drillId: string; + eyebrow?: string; + title: string; + body?: ReactNode; + hint?: string; + /** custom accent CSS color for border/fill tint; defaults to the tone CTA */ + accent?: string; + detailEyebrow?: string; + origin?: string; + minHeight?: number; + cardStyle?: CSSProperties; + children: ReactNode; +}) { + const [open, setOpen] = useState(false); + const cta = accent ?? 'var(--ve-pres-cta)'; + return ( + <> + + {open ? ( + setOpen(false)} + origin={origin} + > + {children} + + ) : null} + + ); +} + +/* ==================================================================== */ +/* Composition primitives */ +/* ==================================================================== */ + +/** Hairline-ruled pull quote. panel=true wraps it in a soft opaque surface. */ +export function PullQuote({ + quote, + attribution, + size = 29, + panel = false, +}: { + quote: string; + attribution: string; + size?: number; + panel?: boolean; +}) { + const inner = ( + <> + + “{quote}” + +

+ + {attribution} + +

+ + ); + if (panel) { + return ( +
+ {inner} +
+ ); + } + return
{inner}
; +} + +/** Single large metric (display-font number + mono label). */ +export function Metric({ value, label, size = 54 }: { value: string; label: string; size?: number }) { + /* Wide display fonts (e.g. mono-display presets) can push long values past + their cell: the value must never wrap ("1920×1080" breaking into two + lines reads as a different number). nowrap keeps it on one line, and the + character-count clamp shrinks values longer than 8 chars proportionally + so they still fit at any preset's glyph width. Pinned by the + metric-value-no-wrap eval against the widest built-in display font. */ + const fitSize = value.length > 8 ? Math.round((size * 8) / value.length) : size; + return ( +
+

+ {value} +

+

+ + {label} + +

+
+ ); +} + +/** Row of metrics separated by hairlines. */ +export function StatRow({ stats }: { stats: Array<{ value: string; label: string }> }) { + return ( +
+ {stats.map((s, i) => ( +
10 ? 1.5 : 1, + borderLeft: i === 0 ? 'none' : '1px solid var(--ve-pres-hair)', + padding: '24px 26px', + }} + > + +
+ ))} +
+ ); +} + +export type HairlineItem = string | { head: string; body: string }; + +/** Left-hairline list rows. Accepts plain strings or {head, body} pairs. */ +export function HairlineList({ + items, + accent, + gap = 16, + columns = 1, +}: { + items: HairlineItem[]; + /** border color override (any CSS color) */ + accent?: string; + gap?: number; + columns?: number; +}) { + const border = accent ?? 'var(--ve-pres-hair)'; + return ( +
1 + ? { display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)`, gap } + : { display: 'flex', flexDirection: 'column', gap } + } + > + {items.map((item, i) => { + const isPair = typeof item !== 'string'; + return ( +
+ {isPair ? ( + <> + + {item.head} + +

{item.body}

+ + ) : ( + {item} + )} +
+ ); + })} +
+ ); +} + +/** Numbered, hairline-connected horizontal stepper. */ +export function Stepper({ + steps, + accentIndex = 0, +}: { + steps: Array<{ num: string; name: string; body: string }>; + accentIndex?: number; +}) { + return ( +
+ + ); +} + +/** Code/JSON panel on the preset's code surface. rows render a JSON object. */ +export function CodePanel({ + rows, + lines, + fontSize = 14.5, +}: { + /** JSON mode: [key, value] string pairs */ + rows?: Array<[string, string]>; + /** raw mode: plain mono lines */ + lines?: string[]; + fontSize?: number; +}) { + return ( +
+ {rows ? ( + <> +
{'{'}
+ {rows.map(([k, v], i) => ( +
+ "{k}" + {': '} + "{v}" + {i < rows.length - 1 ? ',' : ''} +
+ ))} +
{'}'}
+ + ) : null} + {lines + ? lines.map((l, i) => ( +
+ {l} +
+ )) + : null} +
+ ); +} + +/* ==================================================================== */ +/* LadderDiagram — ascending staircase of stage cards on grid paper */ +/* ==================================================================== */ + +export interface LadderStage { + num: string; + name: string; + short?: string; + /** mono tag rendered above the card, e.g. "◀ THIS DECK" */ + tag?: string; + /** dim the stage content (the box stays opaque — grid-safe) */ + dim?: boolean; + /** accent CSS color: colored border + solid tinted fill */ + accent?: string; +} + +/** + * Ascending staircase of stage cards on a grid-paper backdrop with a dashed + * ascent line. Pass renderStage to substitute your own card (e.g. a + * DrillCard); the container is position:relative, so expanded DrillCards + * cover the ladder area. Stage fills are opaque (solid-over-grid rule). + */ +export function LadderDiagram({ + stages, + stepOffset = 58, + gridBackdrop = true, + renderStage, + framed = true, +}: { + stages: LadderStage[]; + stepOffset?: number; + gridBackdrop?: boolean; + renderStage?: (stage: LadderStage, i: number) => ReactNode; + framed?: boolean; +}) { + const defaultStage = (s: LadderStage, i: number) => ( +
+
+ + {s.num} + +

+ {s.name} +

+ {s.short ? ( +

{s.short}

+ ) : null} +
+
+ ); + return ( +
+ +
+ {stages.map((s, i) => ( +
+ {s.tag ? ( +

+ + {s.tag} + +

+ ) : null} + {(renderStage ?? defaultStage)(s, i)} +
+ ))} +
+
+ ); +} + +/* ==================================================================== */ +/* FanoutDiagram — one source node → N outputs */ +/* ==================================================================== */ + +export interface FanoutOutput { + label: string; + cap?: string; + icon?: ReactNode; +} + +export function FanoutDiagram({ + source, + outputs, + sourceWidth = 340, + connectorWidth = 140, +}: { + source: { label: string; body?: string; icon?: ReactNode; accent?: string }; + outputs: FanoutOutput[]; + sourceWidth?: number; + connectorWidth?: number; +}) { + const n = outputs.length; + const H = 440; + const ys = outputs.map((_, i) => ((i + 0.5) / n) * H); + return ( +
+
+
+ {source.icon ? : null} +

+ + {source.label} + +

+ {source.body ? ( +

{source.body}

+ ) : null} +
+
+ +
+ {outputs.map((o) => ( +
+ {o.icon ? : null} + + {o.label} + + {o.cap ? {o.cap} : null} +
+ ))} +
+
+ ); +} + +/* ==================================================================== */ +/* LayerExplorer — clickable layer cards + right detail panel */ +/* ==================================================================== */ + +export interface ExplorerLayer { + id: string; + num: string; + name: string; + lead: string; + icon?: ReactNode; + points: string[]; + foot?: string; +} + +export function LayerExplorer({ + layers, + initialIndex = 0, + drillIdPrefix = 'layer', + listWidth = 560, +}: { + layers: ExplorerLayer[]; + initialIndex?: number; + drillIdPrefix?: string; + listWidth?: number; +}) { + const [sel, setSel] = useState(initialIndex); + useEscape(sel !== initialIndex, () => setSel(initialIndex)); + const layer = layers[sel]; + return ( +
+
+ {layers.map((l, i) => { + const active = i === sel; + return ( + + ); + })} +
+
+ + LAYER {layer.num} · {layer.name} + + + {layer.lead} + +
+ {layer.points.map((p) => ( +
+ {p} +
+ ))} +
+ {layer.foot ? ( +

+ + {layer.foot} + +

+ ) : null} +
+
+ ); +} + +/* ==================================================================== */ +/* PresentationSlide chrome */ +/* ==================================================================== */ + +const DeckContext = createContext<{ index: number; count: number } | null>(null); + +export interface PresentationSlideProps { + /** Kicker text next to the dot, e.g. "01 · Thesis" */ + kicker: string; + /** Display-font headline. Omit for fully custom layouts. */ + title?: ReactNode; + /** Short label for the slide rail; falls back to string titles. */ + shortTitle?: string; + /** Surface polarity, same contract as Slide: dark = preset base surface, + light = --ve-bg-alt (opposite polarity), accent = --ve-accent. */ + tone?: PresentationTone; + titleSize?: number; + titleMax?: number; + /** Top-right mono label */ + rightLabel?: string; + /** Footer-left mono motif text */ + footer?: string; + /** Optional node rendered directly under the title */ + sub?: ReactNode; + contentMarginTop?: number; + children: ReactNode; +} + +export function PresentationSlide({ + kicker, + title, + tone = 'dark', + children, + titleSize, + titleMax = 1500, + rightLabel, + footer, + sub, + contentMarginTop = 36, +}: PresentationSlideProps) { + const deck = useContext(DeckContext); + const autoSize = titleSize ?? (typeof title === 'string' && title.length > 44 ? 70 : 76); + return ( +
+
+
+
+ {rightLabel ? ( + + {rightLabel} + + ) : null} +
+ {title ? ( +

+ {title} +

+ ) : null} + {sub} + {/* position:relative — drill sheets fill this content area */} +
{children}
+
+ + {footer ?? ''} + + {/* spacer: the deck root overlays prev/next + counter here */} + +
+
+ ); +} + +/* ==================================================================== */ +/* Slide rail (collapsible) */ +/* ==================================================================== */ + +function NavChevron({ dir }: { dir: 'prev' | 'next' }) { + return ( + + ); +} + +/** + * Collapsible left rail. Starts expanded, auto-collapses after + * `autoCollapseDelay` ms, re-expands on hover, collapses 320ms after the + * pointer leaves. Expanded: mono slide numbers + short titles with a 2px + * accent indicator on the active row. Collapsed: dot column (accent = + * active, filled = visited, outline = ahead). + */ +function SlideRail({ + entries, + index, + onNavigate, + title, + eyebrow, + autoCollapseDelay, +}: { + entries: string[]; + index: number; + onNavigate: (i: number) => void; + title: string; + eyebrow?: string; + autoCollapseDelay: number; +}) { + const [expanded, setExpanded] = useState(true); + const leaveTimer = useRef | null>(null); + + useEffect(() => { + const t = setTimeout(() => setExpanded(false), autoCollapseDelay); + return () => { + clearTimeout(t); + if (leaveTimer.current) clearTimeout(leaveTimer.current); + }; + }, [autoCollapseDelay]); + + const handleEnter = () => { + if (leaveTimer.current) { + clearTimeout(leaveTimer.current); + leaveTimer.current = null; + } + setExpanded(true); + }; + const handleLeave = () => { + leaveTimer.current = setTimeout(() => setExpanded(false), 320); + }; + + return ( + + ); +} + +/* ==================================================================== */ +/* PresentationDeck root */ +/* ==================================================================== */ + +export interface PresentationDeckProps { + /** Deck title (rail header) */ + title: string; + /** Small mono subtitle under the rail title */ + eyebrow?: string; + preset?: string; + /** Fixed stage size the slides are designed against */ + stageWidth?: number; + stageHeight?: number; + /** ms before the rail auto-collapses (hover re-expands) */ + railAutoCollapseMs?: number; + children: ReactNode; +} + +/** + * Deck root: fixed stageWidth×stageHeight stage scaled to fit the area right + * of the collapsible rail (ResizeObserver keeps it fitted while the rail + * animates), letterboxed on --ve-deck-letterbox, keyboard nav + * (arrows/Space/PageUp/PageDown/Home/End), 80px edge click zones, and a + * bottom-right mono slide counter. Children are PresentationSlide elements; + * only the active slide is mounted. + */ +export function PresentationDeck({ + title, + eyebrow, + preset = 'mono-industrial', + stageWidth = 1920, + stageHeight = 1080, + railAutoCollapseMs = 900, + children, +}: PresentationDeckProps) { + const slides = React.Children.toArray(children).filter((child): child is ReactElement => + isValidElement(child), + ); + const count = slides.length; + const [index, setIndex] = useState(0); + const mainRef = useRef(null); + const [avail, setAvail] = useState<{ w: number; h: number }>({ w: stageWidth, h: stageHeight }); + + /* Measure the area right of the rail; ResizeObserver keeps the stage + scaled while the rail's width transition runs. */ + useIsoLayoutEffect(() => { + const el = mainRef.current; + if (!el) return; + const measure = () => setAvail({ w: el.clientWidth, h: el.clientHeight }); + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + window.addEventListener('orientationchange', measure); + return () => { + ro.disconnect(); + window.removeEventListener('orientationchange', measure); + }; + }, []); + + const go = useCallback( + (n: number) => { + setIndex(clampSlideIndex(n, count)); + }, + [count], + ); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + // Never intercept browser/OS shortcuts (Cmd+Arrow history nav, + // Ctrl+Space, Alt+Arrow word-jump, …). + if (e.metaKey || e.ctrlKey || e.altKey) return; + const t = e.target as HTMLElement | null; + const closest = t && typeof t.closest === 'function' ? (sel: string) => t.closest(sel) : () => null; + // Typing context: text fields own EVERY key (arrows move the caret, + // Space types a space, Home/End jump within the value). Never hijack. + if (closest("input, textarea, select, [contenteditable]:not([contenteditable='false'])")) { + return; + } + // Let focused controls activate with Enter/Space (drill triggers, rail + // items, links, anything inside an open dialog). + if (closest("button, a, [role='dialog']") && (e.key === ' ' || e.key === 'Enter')) { + return; + } + // While a drill sheet is open the deck is in inspect mode: navigation + // pauses (Escape closes the sheet first). Mirrors the edge-zone gate. + if (document.querySelector('[data-drill-open]')) return; + if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'PageDown') { + e.preventDefault(); + go(index + 1); + } else if (e.key === 'ArrowLeft' || e.key === 'PageUp') { + e.preventDefault(); + go(index - 1); + } else if (e.key === 'Home') { + e.preventDefault(); + go(0); + } else if (e.key === 'End') { + e.preventDefault(); + go(count - 1); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [index, count, go]); + + const { scale, left, top } = fitStage(avail.w, avail.h, stageWidth, stageHeight); + const entries = slides.map((slide, i) => { + const p = slide.props; + return p.shortTitle ?? (typeof p.title === 'string' ? p.title : p.kicker) ?? `Slide ${i + 1}`; + }); + const active = slides[index] ?? null; + + /* The root deliberately does NOT carry data-ve-deck: the verifier's slides + profile asserts scroll-snap decks; a fixed-stage presentation verifies as + a page. */ + return ( +
+ + +
+
+ +
+ {active} +
+
+
+ + + + {String(index + 1).padStart(2, '0')} / {String(count).padStart(2, '0')} + + + +
+
+ {/* Edge click zones sit above the stage (and therefore above an open + drill sheet, z-40), so they are gated while a sheet is open: an + edge click must never blow past the sheet and change slides. */} +
+
+ ); +}