Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
- Docs: `docs/presentation-deck.md` (when to use PresentationDeck vs SlideDeck).

### Changed (presentation)
- Presentation navigation now has two axes: Left/Right always changes slides, while Up/Down traverses the active slide's ordered click-ins. Down falls through to the next slide when the sequence is exhausted. Ordinary drill triggers are discovered automatically in DOM order; custom progressive slides can register one navigator with `usePresentationStateNavigation`.
- 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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +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 routed specialist judgment. Exit codes and JSON reports make it usable as a CI gate. Seeded-violation fixtures prove deterministic checks fire; the visual-model policy separately selects the smallest model and screenshot batch size that clear precision, recall, silence, grounding, schema, latency, and cost gates.
- **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`.
- **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, two-axis keyboard navigation (Left/Right for slides; Up/Down for ordered click-ins or custom states, falling through to the next slide when exhausted), 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.
- **Two model eval harnesses**: `evals/model-matrix/` compares artifact generation, while `evals/visual-model-policy/` qualifies visual-verification models and screenshot batch sizes. Generation quality and review quality are deliberately not treated as the same benchmark.
- **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`.
Expand Down
52 changes: 50 additions & 2 deletions docs/presentation-deck.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ replaces the other.
|-|-|-|
| 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 |
| Navigation | Scroll / scroll-snap (vertical or horizontal) | Two-axis keyboard navigation: Left/Right changes slides; Up/Down changes internal states. Space, PageUp/Down, Home/End, edge click zones, and the rail remain slide-level navigation. |
| 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) |
Expand Down Expand Up @@ -61,6 +61,54 @@ Notes:
`stageWidth`/`stageHeight`. All font sizes inside slides are stage-space
pixels — the scale transform handles the rest.

## Two-axis navigation

`PresentationDeck` reserves the horizontal axis for the deck and the vertical
axis for the active slide:

- `ArrowLeft` / `ArrowRight` move slide by slide.
- `ArrowUp` / `ArrowDown` move through the active slide's ordered states.
- When `ArrowDown` has no internal state left, it advances to the next slide.
- Space, PageUp/PageDown, Home/End, the rail, pager, and edge zones continue
to navigate slides.

For ordinary drill-downs, no extra wiring is required. Visible
`data-drill-target` triggers become a vertical sequence in DOM order:
ArrowDown opens the first click-in, advances to the next, then continues to
the next slide; ArrowUp reverses the sequence and returns from the first
click-in to the slide's base state. Horizontal navigation remains paused
while a sheet is open.

For a custom progressive slide, register exactly one ordered state navigator:

```tsx
function ProgressiveSlide() {
const [step, setStep] = React.useState(0);
const stateNavigation = usePresentationStateNavigation({
index: step,
count: 3,
onChange: setStep,
});

return (
<PresentationSlide title="One slide, three states" shortTitle="Progression">
<div {...stateNavigation}>
{/* render state 0, 1, or 2 */}
</div>
</PresentationSlide>
);
}
```

`count` must be a positive integer and `index` must remain within
`0..count - 1`; invalid state bounds fail immediately instead of producing an
ambiguous navigation order.

The custom navigator takes precedence over automatic drill traversal. This
keeps the module's interface small: the deck owns keyboard routing, bounds,
and fallthrough to the next slide; the slide owns only its ordered state and
rendering.

## Primitives

- **Drill-downs** — `DrillCard` (click-to-expand card), `DrillChip`
Expand All @@ -83,7 +131,7 @@ Notes:

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;
guard matrix, two-axis 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
Expand Down
129 changes: 129 additions & 0 deletions evals/run-presentation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,135 @@ async function main() {
}),
);

await record('interaction', 'vertical-fallthrough-without-click-ins', () =>
withPage(browser, { url: primaryUrl }, async (page) => {
assert((await slideIndex(page)) === 0, 'fallthrough fixture must start on slide 0');
await page.locator('[data-stage] [data-drill-target]').evaluateAll((triggers) => {
for (const trigger of triggers) {
const wrapper = trigger.parentElement;
if (wrapper) wrapper.style.pointerEvents = 'none';
}
});
assert(
(await page.locator('[data-stage] [data-presentation-state-nav]').count()) === 0,
'fallthrough fixture slide must have no custom state navigator',
);
await page.keyboard.press('ArrowDown');
await page.waitForFunction(() => document.querySelector('[data-slide-index]')?.getAttribute('data-slide-index') === '1');
assert((await slideIndex(page)) === 1, 'ArrowDown without an effectively rendered internal state must advance to the next slide');
}),
);

await record('interaction', 'vertical-drill-navigation', () =>
withPage(browser, { url: primaryUrl }, async (page) => {
await goToSlide(page, 1);
const label = () => sheetOpen(page).getAttribute('aria-label');

await page.keyboard.press('ArrowDown');
await expectSheetOpen(page, 'first vertical drill');
assert((await label()) === 'Behavior · Click-anywhere-to-close', 'ArrowDown must open the first drill');
assert((await slideIndex(page)) === 1, 'vertical navigation must not change slides');

await page.keyboard.press('ArrowDown');
await page.waitForTimeout(50);
await expectSheetOpen(page, 'second vertical drill');
assert((await label()) === 'Engine · Scale-to-fit stage', 'second ArrowDown must advance to the next drill');

await page.keyboard.press('ArrowDown');
await page.waitForTimeout(50);
assert((await label()) === 'Theming · Preset-driven', 'third ArrowDown must advance to the last drill');

await page.keyboard.press('ArrowUp');
await page.waitForTimeout(50);
assert((await label()) === 'Engine · Scale-to-fit stage', 'ArrowUp must reverse through drills');
await page.keyboard.press('ArrowUp');
await page.waitForTimeout(50);
assert((await label()) === 'Behavior · Click-anywhere-to-close', 'ArrowUp must reach the first drill');
await page.keyboard.press('ArrowUp');
await expectSheetClosed(page, 'vertical return to base state');
assert((await slideIndex(page)) === 1, 'returning to the base state must stay on the slide');

await page.keyboard.press('ArrowDown');
await expectSheetOpen(page, 'first drill before fallthrough');
await page.keyboard.press('ArrowDown');
await page.waitForTimeout(50);
await page.keyboard.press('ArrowDown');
await page.waitForTimeout(50);
await page.keyboard.press('ArrowDown');
await page.waitForTimeout(50);
assert((await slideIndex(page)) === 2, 'ArrowDown after the final drill must advance to the next slide');
await expectSheetClosed(page, 'final drill fallthrough');
}),
);

await record('interaction', 'vertical-custom-state-navigation', () =>
withPage(browser, { url: primaryUrl }, async (page) => {
await goToSlide(page, 3);
const stateIndex = () =>
page.locator('[data-presentation-state-nav]').getAttribute('data-presentation-state-index').then(Number);
const expectStateIndex = async (expected, label) => {
await page.waitForFunction(
(value) =>
document.querySelector('[data-presentation-state-nav]')
?.getAttribute('data-presentation-state-index') === String(value),
expected,
);
assert((await stateIndex()) === expected, label);
};

assert((await stateIndex()) === 0, 'custom state navigator must start at state 0');
await page.evaluate(() => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
});
await expectStateIndex(2, 'two synchronous ArrowDown events must reach the final custom state');
await page.keyboard.press('ArrowUp');
await expectStateIndex(1, 'ArrowUp must reverse custom state');
await page.keyboard.press('ArrowDown');
await expectStateIndex(2, 'ArrowDown must return to the final custom state');
await page.keyboard.press('ArrowDown');
await expectStateIndex(2, 'the final custom state must remain rendered when the deck itself is at its end');
await page.keyboard.press('ArrowUp');
await expectStateIndex(1, 'ArrowUp must reverse custom state');
assert((await slideIndex(page)) === 3, 'custom state navigation must not change slides');
await page.keyboard.press('ArrowLeft');
assert((await slideIndex(page)) === 2, 'ArrowLeft must still change slides');
}),
);

await record('interaction', 'vertical-layer-explorer-navigation', () =>
withPage(browser, { url: primaryUrl }, async (page) => {
await goToSlide(page, 2);
const pressedId = () =>
page.locator('[data-drill-target][aria-pressed="true"]').getAttribute('data-drill-target');

assert((await pressedId()) === 'deck-layer-engine', 'layer explorer must start on its initial state');
await page.keyboard.press('ArrowDown');
assert((await pressedId()) === 'deck-layer-primitives', 'ArrowDown must advance the layer explorer');
await page.keyboard.press('ArrowDown');
assert((await pressedId()) === 'deck-layer-tokens', 'ArrowDown must reach the final layer');
await page.keyboard.press('ArrowUp');
assert((await pressedId()) === 'deck-layer-primitives', 'ArrowUp must reverse the layer explorer');
await page.keyboard.press('ArrowUp');
assert((await pressedId()) === 'deck-layer-engine', 'ArrowUp must return to the initial layer');
assert((await sheetOpen(page).count()) === 0, 'initial layer is the explorer base state');
await page.keyboard.press('ArrowDown');
await page.waitForTimeout(50);
assert((await pressedId()) === 'deck-layer-primitives', 'fallthrough setup must reach the middle layer');
await page.keyboard.press('ArrowDown');
await page.waitForTimeout(50);
assert((await pressedId()) === 'deck-layer-tokens', 'fallthrough setup must reach the final layer');
await page.keyboard.press('ArrowDown');
await page.waitForTimeout(50);
await expectSheetOpen(page, 'tone-remapping click-in after the final layer');
assert((await slideIndex(page)) === 2, 'a remaining click-in must be consumed before slide fallthrough');
await page.keyboard.press('ArrowDown');
await page.waitForTimeout(50);
assert((await slideIndex(page)) === 3, 'ArrowDown after the final layer must advance to the next slide');
await expectSheetClosed(page, 'final layer fallthrough');
}),
);

await record('interaction', 'drill-dismiss-guard-matrix', () =>
withPage(browser, { url: primaryUrl }, async (page) => {
await goToSlide(page, 1);
Expand Down
14 changes: 12 additions & 2 deletions examples/visual-explainer-mdx/presentation-deck.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
PullQuote,
StatRow,
Stepper,
usePresentationStateNavigation,
} from '../../visual-explainer-mdx/components';
import type { PresentationTone } from '../../visual-explainer-mdx/components';

Expand Down Expand Up @@ -303,6 +304,12 @@ function LayersSlide({ shortTitle, tone }: SlideMeta) {

function AskSlide({ shortTitle, tone }: SlideMeta) {
const [open, setOpen] = React.useState(false);
const [step, setStep] = React.useState(0);
const stateNavigation = usePresentationStateNavigation({
index: step,
count: 3,
onChange: setStep,
});
return (
<PresentationSlide
kicker="04 · Adoption"
Expand All @@ -312,10 +319,13 @@ function AskSlide({ shortTitle, tone }: SlideMeta) {
rightLabel="Presentation Deck"
footer="PresentationDeck vs SlideDeck: docs/presentation-deck.md"
>
<div style={{ display: 'grid', gridTemplateRows: 'auto 1fr', gap: 44, height: '100%' }}>
<div
{...stateNavigation}
style={{ display: 'grid', gridTemplateRows: 'auto 1fr', gap: 44, height: '100%' }}
>
<div style={{ height: 240 }}>
<Stepper
accentIndex={2}
accentIndex={step}
steps={[
{ num: '1', name: 'Scrolling handout', body: 'Use SlideDeck: scroll-snap sections that read top to bottom and print well.' },
{ num: '2', name: 'Editorial spread', body: 'Use SlideDeck with orientation=horizontal for magazine-style pagination.' },
Expand Down
12 changes: 11 additions & 1 deletion visual-explainer-mdx/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,18 @@ export {
IconArrowRight,
trackShine,
useEscape,
usePresentationStateNavigation,
} from './presentation';
export type {
PresentationTone,
PresentationSlideProps,
PresentationDeckProps,
PresentationStateNavigationOptions,
LadderStage,
FanoutOutput,
ExplorerLayer,
HairlineItem,
} from './presentation';
export type { PresentationTone, PresentationSlideProps, PresentationDeckProps, LadderStage, FanoutOutput, ExplorerLayer, HairlineItem } from './presentation';
export {
fitStage,
clampSlideIndex,
Expand Down
Loading
Loading