diff --git a/CHANGELOG.md b/CHANGELOG.md index 06b5a8f..23f17a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index bb88ebc..902e8f5 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/docs/presentation-deck.md b/docs/presentation-deck.md index 28d5ac3..ec3ffe3 100644 --- a/docs/presentation-deck.md +++ b/docs/presentation-deck.md @@ -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) | @@ -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 ( + +
+ {/* render state 0, 1, or 2 */} +
+
+ ); +} +``` + +`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` @@ -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 diff --git a/evals/run-presentation.mjs b/evals/run-presentation.mjs index acf7c8e..002355d 100644 --- a/evals/run-presentation.mjs +++ b/evals/run-presentation.mjs @@ -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); diff --git a/examples/visual-explainer-mdx/presentation-deck.tsx b/examples/visual-explainer-mdx/presentation-deck.tsx index ef26012..2e16025 100644 --- a/examples/visual-explainer-mdx/presentation-deck.tsx +++ b/examples/visual-explainer-mdx/presentation-deck.tsx @@ -19,6 +19,7 @@ import { PullQuote, StatRow, Stepper, + usePresentationStateNavigation, } from '../../visual-explainer-mdx/components'; import type { PresentationTone } from '../../visual-explainer-mdx/components'; @@ -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 ( -
+
void) { }, [active, onClose]); } +type PresentationStateDirection = 'previous' | 'next'; + +export interface PresentationStateNavigationOptions { + index: number; + count: number; + onChange: (index: number) => void; +} + +const PRESENTATION_STATE_NAV_EVENT = 'presentation-state-navigate'; + +function getPresentationStateDirection(event: Event): PresentationStateDirection | null { + if (!('detail' in event)) return null; + const detail = event.detail; + if (!detail || typeof detail !== 'object' || !('direction' in detail)) return null; + return detail.direction === 'previous' || detail.direction === 'next' + ? detail.direction + : null; +} + +/** + * Register one ordered custom state navigator for the active slide. + * + * Spread the returned props onto a stable wrapper inside PresentationSlide. + * ArrowUp/ArrowDown then move within this state sequence while + * ArrowLeft/ArrowRight continue to change slides. A registered navigator + * takes precedence over the deck's automatic data-drill-target traversal + * until its boundary; ArrowDown can then continue to the next slide. + */ +export function usePresentationStateNavigation({ + index, + count, + onChange, +}: PresentationStateNavigationOptions) { + if (!Number.isInteger(count) || count < 1) { + throw new Error('usePresentationStateNavigation: count must be a positive integer'); + } + if (!Number.isInteger(index) || index < 0 || index >= count) { + throw new Error(`usePresentationStateNavigation: index ${index} must be within 0..${count - 1}`); + } + const ref = useRef(null); + const stateRef = useRef({ index, count, onChange }); + stateRef.current = { index, count, onChange }; + + useEffect(() => { + const node = ref.current; + if (!node) return; + const handleStateNavigation = (rawEvent: Event) => { + const direction = getPresentationStateDirection(rawEvent); + if (!direction) return; + const current = stateRef.current; + const nextIndex = clampSlideIndex( + current.index + (direction === 'next' ? 1 : -1), + current.count, + ); + if (nextIndex === current.index) return; + rawEvent.preventDefault(); + // Advance the live ref before React commits so rapid key repeats cannot + // collapse into repeated updates from the same stale index. + current.index = nextIndex; + current.onChange(nextIndex); + }; + node.addEventListener(PRESENTATION_STATE_NAV_EVENT, handleStateNavigation); + return () => node.removeEventListener(PRESENTATION_STATE_NAV_EVENT, handleStateNavigation); + }, []); + + return { + ref, + 'data-presentation-state-nav': 'true', + 'data-presentation-state-index': index, + 'data-presentation-state-count': count, + } as const; +} + export function CloseX({ onClose }: { onClose: () => void }) { const ref = useRef(null); useEffect(() => { @@ -1578,13 +1653,93 @@ export interface PresentationDeckProps { children: ReactNode; } +function isRenderedDrillTrigger(element: HTMLElement): boolean { + const rect = element.getBoundingClientRect(); + if (element.hasAttribute('disabled') || rect.width <= 0 || rect.height <= 0) return false; + for (let current: HTMLElement | null = element; current; current = current.parentElement) { + const style = window.getComputedStyle(current); + if ( + style.display === 'none' || + style.visibility === 'hidden' || + style.pointerEvents === 'none' || + (current === element && Number(style.opacity) === 0) + ) { + return false; + } + } + return true; +} + +function getPresentationCustomStateNavigator(stage: HTMLElement): HTMLElement | null { + const customNavigators = stage.querySelectorAll('[data-presentation-state-nav="true"]'); + if (customNavigators.length > 1) { + throw new Error('PresentationDeck: an active slide may register only one custom state navigator'); + } + return customNavigators[0] ?? null; +} + +function requestPresentationVerticalNavigation( + stage: HTMLElement, + direction: PresentationStateDirection, + openDrillId: React.MutableRefObject, +): boolean { + const customNavigator = getPresentationCustomStateNavigator(stage); + if (customNavigator) { + const EventConstructor = customNavigator.ownerDocument.defaultView?.CustomEvent; + if (EventConstructor) { + const event = new EventConstructor(PRESENTATION_STATE_NAV_EVENT, { + cancelable: true, + detail: { direction }, + }); + customNavigator.dispatchEvent(event); + if (event.defaultPrevented) return true; + return false; + } + } + + const triggers = Array.from(stage.querySelectorAll('[data-drill-target]')).filter(isRenderedDrillTrigger); + if (triggers.length === 0) return false; + + const openSurface = stage.querySelector('[data-drill-open]'); + const activePressedIndex = triggers.findIndex((trigger) => trigger.getAttribute('aria-pressed') === 'true'); + const rememberedIndex = openSurface + ? triggers.findIndex((trigger) => trigger.dataset.drillTarget === openDrillId.current) + : -1; + const currentIndex = rememberedIndex >= 0 ? rememberedIndex : activePressedIndex; + const delta = direction === 'next' ? 1 : -1; + const nextIndex = currentIndex + delta; + + if (nextIndex < 0) { + const close = openSurface?.querySelector('[data-drill-close]'); + if (close) close.click(); + openDrillId.current = null; + return true; + } + if (nextIndex >= triggers.length) { + openSurface?.querySelector('[data-drill-close]')?.click(); + openDrillId.current = null; + return false; + } + + const openNext = () => triggers[nextIndex]?.click(); + const close = openSurface?.querySelector('[data-drill-close]'); + if (openSurface && close) { + close.click(); + window.setTimeout(openNext, 0); + } else { + openNext(); + } + return true; +} + /** * 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. + * animates), letterboxed on --ve-deck-letterbox, two-axis keyboard nav + * (Left/Right = slides, Up/Down = internal states; Space/PageUp/PageDown and + * Home/End retain slide navigation), 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, @@ -1601,6 +1756,7 @@ export function PresentationDeck({ const count = slides.length; const [index, setIndex] = useState(0); const mainRef = useRef(null); + const openDrillIdRef = 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 @@ -1626,6 +1782,12 @@ export function PresentationDeck({ [count], ); + useEffect(() => { + openDrillIdRef.current = null; + const stage = mainRef.current?.querySelector('[data-stage]'); + if (stage) getPresentationCustomStateNavigator(stage); + }, [index]); + useEffect(() => { const handler = (e: KeyboardEvent) => { // Never intercept browser/OS shortcuts (Cmd+Arrow history nav, @@ -1643,8 +1805,22 @@ export function PresentationDeck({ 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 (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + e.preventDefault(); + const stage = mainRef.current?.querySelector('[data-stage]'); + const handled = stage + ? requestPresentationVerticalNavigation( + stage, + e.key === 'ArrowDown' ? 'next' : 'previous', + openDrillIdRef, + ) + : false; + if (!handled && e.key === 'ArrowDown') go(index + 1); + return; + } + // While a drill sheet is open horizontal navigation pauses. Vertical + // navigation remains available so the presenter can move among the + // slide's click-ins or return to its base state. if (document.querySelector('[data-drill-open]')) return; if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); @@ -1694,6 +1870,12 @@ export function PresentationDeck({
{ + const target = event.target instanceof Element + ? event.target.closest('[data-drill-target]') + : null; + if (target) openDrillIdRef.current = target.dataset.drillTarget ?? null; + }} style={{ position: 'absolute', width: stageWidth,