PresentationTPL
The HTML presentation primitive: a fixed 1600×900 design surface scaled uniformly to the mount (responsive viewer, fixed-coordinate slide - no fluid layout inside slides), keyboard/touch/fullscreen navigation, presenter notes, and the Motion entrance vocabulary replayed per slide. Zero dependencies beyond Defuss.
On this page (9)
§Default
A ten-slide QBR on the fixed 1600×900 artboard - click the deck and use ← / → (or the controls). Shipped components compose INSIDE slides: an ECharts revenue chart (df$.shadcn.chart.mount, text sized for the artboard), a .table of the quarterly numbers, a .mk-showcase product video that plays only while its slide is on stage, and an .avatar on the quote. Every slide slides in and out through the df$.anim engine (data-anim-in / data-anim-out on the deck, 1.5 s by default), the in-slide chart replays its entrance whenever its slide comes on stage, and entrances are the shared Motion vocabulary, replayed on activation: staggered agenda, animated counters, an SVG draw-in trend, an iris quote, a spring pop on the close. Slides carry header lines and presenter notes. Try N for notes, F for fullscreen.
§Slide chrome + Art direction
Six slides of a 4:3 brand kickoff, art-directed entirely through the deck's local --presentation-* surface (ink / paper / accent) and the shared --df-motion-* defaults (900 ms, 3rem drift) - theme tokens stay untouched. The chrome composes inside the artboard coordinates: .presentation-header (section label + hairline + accent tick), .presentation-slide-number (the runtime fills NN⁄NN - the fraction slash ⁄ slants by itself), controls and progress. Slides slide in and fade out (data-anim-in/-out on the deck); each one's content builds with its own entrances. Use ← / → or the buttons.
§Chart story deck
One chart for the whole deck: a .chart.presentation-stage child of the mount, laid out in artboard coordinates by presentation.css. Slides name the state they show with data-chart-state; df$.shadcn.chart.deck(deck, { base, states }) applies each state to the SAME instance, so bars → grouped bars → donut morph (universalTransition is on by default; keep series ids and item names stable). Slides without a state fade the stage out; the next chart slide morphs on from there. The deck declares its slide transition: data-anim-in="slideIn" / data-anim-out="fadeOut", and the closing slide a blocksIn curtain.
§Live data slide
The bar race (adapted from echarts-feat 09-bar-race) runs only while its slide is on stage: the same data-active MutationObserver starts the setInterval-driven setOption race when slide two activates and stops it when the deck moves on - realtimeSort + valueAnimation do the ranking morph.
219 components - 51 with JavaScript, 168 CSS-only - 307.9 KiB minified + compressed - 190.3 KiB as the all.css/all.js bundle. Measured from the shipped files. Full details in: dist/stats.json - updated on every build.
§States
Named states via the shared State API, driven per instance through the bound api:
default- the authored surface;setState('default', { index })activates slide N andgetState().config.slidereports the live indexnotes- presenter notes of the active slide visible (data-notes)fullscreen- mount pinned to the viewport (data-fullscreen): native fullscreen or the fixed-overlay fallback
The demo element carries data-state-demo; bun run screenshots drives it via el.api.setState(name).
Machine contract - verified against presentation.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
slide | number | — | 0 | Active slide index - setState('default', { index }); mirrored as data-current-slide and updated by keyboard/click/hash. |
notes | boolean | true, false | false | Presenter notes of the active slide visible (data-notes on the mount). |
fullscreen | boolean | true, false | false | Mount pinned to the viewport (data-fullscreen): native fullscreen or the honest fixed-overlay fallback. |
§API
§Imperative control
The same deck driven from JS: the State API on the element (open the State tab to drive it), plus the shared-library scope for clamped navigation. Motion replays come free with activation.
§CSS view file
The deck surface: artboard scaling, ink/paper themes, slide chrome (header line, slash counter), presenter notes and the shared --df-motion-* defaults. The entrance keyframes themselves ship in motion.css.
/* -- Presentation component ------------------------------------ Fixed-coordinate deck runtime: an artboard (default 1600×900) uniformly scaled to the mount, declarative entrance animations, native <progress> advance, presenter notes. Local --presentation-* custom properties are the deck's art-direction contract (literal defaults, token-free on purpose: a slide surface must stay identical under every theme); consumers override them per deck. */@layer components { .presentation { /* artboard contract - override on the mount for other formats */ --presentation-width: 1600; --presentation-height: 900; /* live uniform scale, owned by presentation.js (ResizeObserver) */ --presentation-scale: 1; /* slide surfaces (fixed art direction, like the taxonomy badge colors) */ --presentation-ink: #0d1424; --presentation-ink-foreground: #e9eef8; --presentation-paper: #ffffff; --presentation-paper-foreground: #101828; --presentation-accent: #38bdf8; /* motion: entrances come from the SHARED motion component (motion.css keyframes + [data-df-entrance]); a deck only tunes the --df-motion-* defaults for its artboard scale - per-element inline vars still win */ --presentation-out: cubic-bezier(0.16, 1, 0.3, 1); --presentation-spring: cubic-bezier(0.34, 1.56, 0.64, 1); /* slow and deliberate: slide transitions (df$.anim, default 1500ms), entrances and the chart stage share one tempo */ --presentation-duration: 1500ms; --df-motion-duration: var(--presentation-duration); --df-motion-ease: var(--presentation-out); --df-motion-distance: 3.5rem; /* artboard units - scales with the deck */ position: relative; display: grid; width: 100%; aspect-ratio: var(--presentation-width) / var(--presentation-height); overflow: hidden; background: var(--presentation-ink); color: var(--presentation-ink-foreground); font-family: var(--font-sans); /* fullscreen MODE: when native fullscreen is active this element already owns the viewport; when the request was DENIED (embeds without allow="fullscreen") the same attribute pins it as a fixed overlay — one honest mode, two carriers. */ &[data-fullscreen] { position: fixed; inset: 0; z-index: 50; width: 100vw; height: 100vh; aspect-ratio: auto; } & > [data-slide] { position: absolute; inset-block-start: 0; inset-inline-start: 0; inline-size: calc(var(--presentation-width) * 1px); block-size: calc(var(--presentation-height) * 1px); /* uniform artboard scale via zoom, NOT transform: the slide's transform belongs to the df$.anim transitions (slide/zoom/spin/flip keyframes animate transform - a scale() here would be overwritten mid-transition and the slide would jump to artboard size) */ zoom: var(--presentation-scale); display: flex; flex-direction: column; justify-content: center; padding: calc(var(--presentation-height) * 0.08px) calc(var(--presentation-width) * 0.07px); box-sizing: border-box; /* the TRANSITION is the df$.anim engine (presentation.js plays a data-anim-out on the leaving slide and a data-anim-in on the arriving one); CSS only says who is on stage: the active slide on top, the leaving slide beneath it until its out-animation ends */ visibility: hidden; &[data-active], &[data-leaving] { visibility: visible; } &[data-active] { z-index: 1; } &[data-leaving] { z-index: 0; } &[data-theme="ink"] { --presentation-surface: var(--presentation-ink); --presentation-on-surface: var(--presentation-ink-foreground); } &[data-theme="paper"] { --presentation-surface: var(--presentation-paper); --presentation-on-surface: var(--presentation-paper-foreground); } &[data-theme] { background: var(--presentation-surface); color: var(--presentation-on-surface); /* shipped components on a slide (table, badge, avatar, button, card) speak the SLIDE's surface, not the page theme: the existing semantic tokens are re-pointed at the deck's art direction, so a paper slide stays legible in dark mode and vice versa */ --background: var(--presentation-surface); --foreground: var(--presentation-on-surface); --card: var(--presentation-surface); --card-foreground: var(--presentation-on-surface); --primary: var(--presentation-on-surface); --primary-foreground: var(--presentation-surface); --secondary: color-mix(in oklch, var(--presentation-on-surface) 10%, var(--presentation-surface)); --secondary-foreground: var(--presentation-on-surface); --muted: color-mix(in oklch, var(--presentation-on-surface) 7%, var(--presentation-surface)); --muted-foreground: color-mix(in oklch, var(--presentation-on-surface) 64%, var(--presentation-surface)); --accent: color-mix(in oklch, var(--presentation-on-surface) 10%, var(--presentation-surface)); --accent-foreground: var(--presentation-on-surface); --border: color-mix(in oklch, var(--presentation-on-surface) 18%, var(--presentation-surface)); --input: color-mix(in oklch, var(--presentation-on-surface) 24%, var(--presentation-surface)); --ring: var(--presentation-accent); } } /* charts on the artboard read at slide scale: the chart theme adapter derives every label, gap and stroke from --chart-font-size */ & .chart { --chart-font-size: 24px; } /* -- the chart stage: ONE chart for the whole deck (chart.deck()) ------- A deck-level layer in artboard coordinates (same scale transform as the slides) stacked ABOVE them, so every slide keeps its own surface. Slides that name a data-chart-state show it; the chart morphs from state to state while the slides crossfade underneath. Hidden = faded out in step with the slide fade, never unmounted. */ & > .presentation-stage { position: absolute; inset-block-start: 0; inset-inline-start: 0; z-index: 2; inline-size: calc(var(--presentation-width) * 1px); block-size: calc(var(--presentation-height) * 1px); transform: scale(var(--presentation-scale)); transform-origin: top left; visibility: hidden; opacity: 0; pointer-events: none; transition: opacity var(--presentation-duration) var(--presentation-out), visibility 0s linear var(--presentation-duration); &[data-visible] { visibility: visible; opacity: 1; pointer-events: auto; transition: opacity var(--presentation-duration) var(--presentation-out); } } /* a curtain (blocksIn → blocksOut) passes over the SLIDES; the chart stage steps aside while it runs and returns with the arriving slide */ &[data-curtain] > .presentation-stage[data-visible] { opacity: 0; transition-duration: 300ms; } /* -- entrances: the SHARED motion component owns the vocabulary -------- Slides simply carry [data-df-entrance] / [data-df-draw] / [data-df- stagger] elements; the keyframes + --df-motion-* tunables live in motion.css (shipped in the same all.css bundle). The deck runtime REPLAYS every entrance when its slide activates - revisiting a slide re-runs its entrance, and animations (unlike transitions) fire deterministically on first application, so decks animate correctly on load without a rendered "from" state. */ /* -- in-slide chrome (artboard coordinates, scales with the deck) ------ */ /* header line: a mono eyebrow strip with a hairline rule, pinned to the slide's top edge - section label left, optional meta right */ & .presentation-header { position: absolute; inset-block-start: 0; inset-inline: 0; display: flex; align-items: baseline; gap: 1.25rem; padding: 3.5rem 4.5rem 1.75rem; font-family: var(--font-mono); font-size: 1.5rem; letter-spacing: 0.14em; text-transform: uppercase; color: color-mix(in oklch, currentColor 65%, transparent); border-block-end: 1px solid color-mix(in oklch, currentColor 16%, transparent); /* the accent tick gives the line a deliberate start, not a stray rule */ &::before { content: ''; inline-size: 2.75rem; block-size: 0.3rem; align-self: center; background: var(--presentation-accent); } /* the LAST span (meta) rides to the right edge - label stays with the tick */ & > :last-child { margin-inline-start: auto; } } /* slide number: NN ⁄ NN in the bottom corner - the fraction slash (U+2044) is inherently slanted, so the motif needs no extra decoration. The runtime fills the text; authors just drop the element into a slide. */ & .presentation-slide-number { position: absolute; inset-block-end: 2.75rem; /* bottom-LEFT: the bottom-right corner belongs to the deck controls */ inset-inline-start: 4.5rem; font-family: var(--font-mono); font-size: 1.6rem; font-weight: 500; letter-spacing: 0.08em; color: color-mix(in oklch, currentColor 55%, transparent); } /* -- chrome (outside slide coordinates, viewport-fixed) -------------- */ & .presentation-controls { position: absolute; inset-block-end: 1rem; inset-inline-end: 1rem; display: flex; align-items: center; gap: 0.375rem; z-index: 3; } & .presentation-control { display: inline-flex; align-items: center; justify-content: center; inline-size: 2rem; block-size: 2rem; padding: 0; border: 1px solid color-mix(in oklch, var(--presentation-paper-foreground) 18%, transparent); border-radius: var(--radius-md); background: color-mix(in oklch, var(--presentation-paper) 82%, transparent); color: var(--presentation-paper-foreground); cursor: pointer; font: inherit; line-height: 1; &:hover { background: color-mix(in oklch, var(--presentation-paper) 95%, transparent); } &:disabled { opacity: 0.4; cursor: default; } } & .presentation-counter { font-family: var(--font-mono); font-size: 0.75rem; padding-inline: 0.5rem; color: var(--presentation-ink-foreground); background: color-mix(in oklch, var(--presentation-ink) 60%, transparent); border-radius: var(--radius-sm); } /* native <progress> - the platform's completion indicator */ & .presentation-progress { position: absolute; inset-block-end: 0; inset-inline: 0; inline-size: 100%; block-size: 3px; margin: 0; border: none; background: color-mix(in oklch, var(--presentation-ink-foreground) 18%, transparent); accent-color: var(--presentation-accent); z-index: 3; &::-webkit-progress-bar { background: color-mix(in oklch, var(--presentation-ink-foreground) 18%, transparent); } &::-webkit-progress-value { background: var(--presentation-accent); } &::-moz-progress-bar { background: var(--presentation-accent); } } /* presenter notes - hidden until the deck toggles [data-notes] */ & .presentation-note { display: none; } &[data-notes] > [data-slide][data-active] > .presentation-note { display: block; position: absolute; inset-block-end: 0; inset-inline: 0; margin: 0; padding: 1.25rem 4rem; font-size: 1.75rem; line-height: 1.4; background: color-mix(in oklch, #000000 82%, transparent); border-block-start: 1px solid color-mix(in oklch, #ffffff 20%, transparent); color: #ffffff; } /* slide typography helpers (fixed artboard sizes, not site type scale) */ & .presentation-eyebrow { font-family: var(--font-mono); font-size: 1.5rem; letter-spacing: 0.08em; text-transform: uppercase; opacity: 0.7; margin: 0 0 1rem; } & .presentation-title { font-size: 5.5rem; line-height: 1.05; letter-spacing: -0.02em; font-weight: 700; margin: 0 0 1.5rem; text-wrap: balance; } & .presentation-lede { font-size: 2rem; line-height: 1.4; opacity: 0.85; margin: 0; text-wrap: pretty; } } /* accessibility: honor motion + contrast preferences (REQUIRED). Entrances flatten via motion.css's own 1ms rule (keeps the JS finished contract); here only the deck's own slide-fade transition flattens. */ @media (prefers-reduced-motion: reduce) { .presentation > [data-slide], .presentation > .presentation-stage { transition: none; } } @media (prefers-contrast: more) { .presentation .presentation-control { border-width: 2px; border-color: var(--presentation-paper-foreground); } } @media (forced-colors: active) { .presentation .presentation-control { border-color: CanvasText; color: CanvasText; background: Canvas; } .presentation .presentation-progress { border-block-start: 1px solid CanvasText; } }}§JavaScript view file
The smallest presentation engine that works: slide lifecycle, ResizeObserver scaling, keyboard/click/touch/hash navigation, counters, notes, fullscreen - and the per-activation replay of the shared Motion entrances via ddf$.entrance / ddf$.draw.
// -- Presentation ------------------------------------------------// The deck runtime: a fixed-coordinate artboard (default 1600×900) uniformly// scaled into the mount, with declarative CSS entrance animations// ([data-reveal], [data-draw]) that fire when a slide gets [data-active].// JS is the thin part - slide lifecycle (activation, inert/aria-hidden),// keyboard/click/hash navigation, ResizeObserver scaling, presenter notes and// fullscreen - plus the animated counters (the one effect CSS can't express)// and the slide TRANSITIONS: every slide change plays an out-animation on the// leaving slide and an in-animation on the arriving one through the shared// df$.anim engine (data-anim-in / data-anim-out, per slide or deck-wide on// the mount - the same attribute contract the animation canvas uses).// Everything else is presentation.css (AGENTS.md "Native web platform first").//// Markup contract: `.presentation` mount > `[data-slide]` children (each with// data-theme="ink|paper"); optional in-deck chrome: [data-presentation-action]// buttons, .presentation-counter, progress.presentation-progress,// .presentation-note per slide. State lives ON THE ELEMENT (dataset.stateName// + data-current-slide) - the mount's bound `api` is the only mutator// (AGENTS.md "State API").// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/, and// the same functions are published under the global `ddf$` alias.import { defussGlobals, animateCount, bindGlobalKeys, clampIndex, coerceIndex, draw, entrance, anim } from '../../shared/state-api.js';import type { AnimChannel, AnimDirection, AnimOptions } from '../../shared/anim.js';const df$ = defussGlobals();const presentationStates = ['default', 'notes', 'fullscreen'];/** Typed view of a mount's per-instance extras (module-private state bag). */type Deck = HTMLElement & { _presentationActivate?: (index: number, forward?: boolean) => void; /** settles the in-flight transition (finish + cleanup) before the next starts */ _presentationSettle?: () => void;};/** A deck that declares nothing still animates every slide in and out. */const DEFAULT_IN = 'fadeIn';const DEFAULT_OUT = 'fadeOut';/** Registry lookup with the engine's fail-loud contract (typos throw). */function channelFor(name: string): AnimChannel { if (!(anim.names as readonly string[]).includes(name)) { throw new Error(`presentation: unknown animation "${name}" (supported: ${anim.names.join(', ')})`); } return anim[name as keyof typeof anim] as AnimChannel;}/** * The declared animation for one phase: the slide's own data-anim-in|out * (+ -direction|-duration|-easing|-origin|-distance|-scale|-blocks|-stagger| * -color), else the mount's deck-wide declaration, else fade. Direction * defaults to the travel: forward arrives from the east and leaves west. */function animSpec(root: HTMLElement, slide: HTMLElement, phase: 'in' | 'out', forward: boolean): { name: string; opts: AnimOptions } { const p = phase === 'in' ? 'animIn' : 'animOut'; const pick = (suffix = ''): string | undefined => (slide.dataset as Record<string, string | undefined>)[p + suffix] ?? (root.dataset as Record<string, string | undefined>)[p + suffix]; const travel: AnimDirection = phase === 'in' ? (forward ? 'east' : 'west') : forward ? 'west' : 'east'; const opts: AnimOptions = { direction: (pick('Direction') as AnimDirection | undefined) ?? travel }; const num = (suffix: string): number | undefined => { const n = parseFloat(pick(suffix) ?? ''); return Number.isFinite(n) ? n : undefined; }; if (num('Duration') !== undefined) opts.duration = num('Duration'); if (num('Scale') !== undefined) opts.scale = num('Scale'); if (num('Blocks') !== undefined) opts.blocks = Math.round(num('Blocks') as number); if (num('Stagger') !== undefined) opts.stagger = num('Stagger'); if (pick('Easing')) opts.easing = pick('Easing'); if (pick('Origin')) opts.origin = pick('Origin'); if (pick('Distance')) opts.distance = pick('Distance'); if (pick('Color')) opts.color = pick('Color'); return { name: pick() || (phase === 'in' ? DEFAULT_IN : DEFAULT_OUT), opts };}/** One 1×1 canvas resolves any CSS color (hex, oklch, color-mix…) to sRGB. */let probe: CanvasRenderingContext2D | null = null;function rgbOf(css: string): [number, number, number, number] | null { if (!css || typeof document === 'undefined') return null; probe ??= document.createElement('canvas').getContext('2d', { willReadFrequently: true }); if (!probe) return null; probe.clearRect(0, 0, 1, 1); probe.fillStyle = 'rgba(1, 2, 3, 0.5)'; probe.fillStyle = css; if (probe.fillStyle === 'rgba(1, 2, 3, 0.5)') return null; probe.fillRect(0, 0, 1, 1); const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data; return [r, g, b, a / 255];}/** WCAG relative-luminance contrast ratio of two sRGB colors. */function contrast(a: number[], b: number[]): number { const lum = (c: number[]): number => { const [r, g, bl] = c.slice(0, 3).map((v) => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; }); return 0.2126 * r + 0.7152 * g + 0.0722 * bl; }; const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x); return (hi + 0.05) / (lo + 0.05);}/** * Why: a curtain (blocksIn/blocksOut) is only seen when its color differs * from BOTH surfaces it passes over - a curtain in the slide's own * background color is an invisible transition. The declared color wins when * it contrasts; otherwise the first candidate that does: the deck accent, * the leaving slide's text color, the ink, the paper. */function curtainColor(root: HTMLElement, from: HTMLElement, to: HTMLElement, declared?: string): string { const surfaces = [from, to] .map((s) => rgbOf(getComputedStyle(s).backgroundColor)) .filter((c): c is [number, number, number, number] => !!c && c[3] > 0.5); const cs = getComputedStyle(root); const candidates = [ declared, cs.getPropertyValue('--presentation-accent').trim(), getComputedStyle(from).color, cs.getPropertyValue('--presentation-ink').trim(), cs.getPropertyValue('--presentation-paper').trim(), ]; for (const c of candidates) { if (!c) continue; const rgb = rgbOf(c); if (rgb && surfaces.every((bg) => contrast(rgb, bg) >= 1.6)) return c; } return getComputedStyle(from).color;}/** The mount's slide elements, document order (direct children only). */const slidesOf = (root: HTMLElement): HTMLElement[] => Array.from(root.querySelectorAll<HTMLElement>(':scope > [data-slide]'));/** The live 0-based index, mirrored by activate() for ddf$ / bridge reads. */const indexOf = (root: HTMLElement): number => coerceIndex(root.dataset.currentSlide, 0);/** * The deck currently in CONFIRMED native fullscreen (its promise resolved). * Exit bookkeeping can then distinguish "this deck left native fullscreen" * (clear its data-fullscreen) from a deck sitting in the fallback mode * (which never fires fullscreenchange and must keep its attribute). */let nativeDeck: HTMLElement | null = null;/** * Enter fullscreen: the standard spelling with a rejection fallback. An * embedded deck (iframe without allow="fullscreen") or a gesture-less * programmatic call rejects - instead of silently lying, the deck switches * into the fixed-overlay focus mode (same data-fullscreen attribute; CSS * owns the visual). A resolving promise confirms NATIVE mode; the * fullscreenchange listener keeps the attribute in sync from there on. */function enterFullscreen(root: Deck): void { const host = root as HTMLElement & { webkitRequestFullscreen?: () => void }; if (typeof host.requestFullscreen === 'function') { host.requestFullscreen().then( () => { nativeDeck = root; }, () => { root.dataset.fullscreen = ''; // denied → honest fallback overlay }, ); return; } host.webkitRequestFullscreen?.(); nativeDeck = root; root.dataset.fullscreen = '';}/** Leave fullscreen (native when active) and always drop the mode attribute. */function exitFullscreen(root: Deck): void { delete root.dataset.fullscreen; const doc = document as Document & { webkitFullscreenElement?: Element | null; webkitExitFullscreen?: () => void }; if ((doc.fullscreenElement ?? doc.webkitFullscreenElement) === root) { try { (doc.exitFullscreen?.bind(doc) ?? doc.webkitExitFullscreen?.bind(doc))?.(); } catch { /* already gone */ } } if (nativeDeck === root) nativeDeck = null;}/** * UI side of setState: 'default' = activate the configured slide (optional * { index }); a bare setState('default') (the bridge reset) additionally drops * the mode toggles back to the authored surface. 'notes'/'fullscreen' turn * their mode ON (the bridge sends value:false to clear). Unknown names throw. */function triggerStateChange(root: Deck, stateName: string, config: Record<string, unknown> = {}): void { if (!presentationStates.includes(stateName)) { throw new Error(`presentation: unknown state "${stateName}" (supported: ${presentationStates.join(', ')})`); } if (stateName === 'default') { if (config.index !== undefined) root._presentationActivate?.(clampIndex(config.index, slidesOf(root).length)); // bare reset (no index, no explicit mode) → authored surface if (config.index === undefined && config.notes === undefined && config.fullscreen === undefined) { delete root.dataset.notes; exitFullscreen(root); } return; } if (stateName === 'notes') { root.toggleAttribute('data-notes', config.value !== false); return; } if (config.value === false) exitFullscreen(root); else enterFullscreen(root);}/** Registry-level API; pass the mount explicitly. Unknown names throw. */export const presentationApi = { setState(root: HTMLElement, stateName: string, config: Record<string, unknown> = {}) { triggerStateChange(root as Deck, stateName, config); // state lives on the ELEMENT, not module scope (AGENTS.md "State API") root.dataset.stateName = stateName; root._stateConfig = config; }, getState(root: HTMLElement) { // reflect reality: keyboard/controls/hash move the deck without setState() return { name: root.dataset.stateName || 'default', config: { ...root._stateConfig, slide: indexOf(root), notes: root.hasAttribute('data-notes'), fullscreen: root.hasAttribute('data-fullscreen'), }, }; },};df$.presentationApi = presentationApi;df$.presentationStates = presentationStates;/** One document-level keyboard listener for all decks (global-flag guard). */let keysBound = false;/** * Why: keyboard is the deck's primary surface (← → Space Home End, N notes, * F fullscreen). One listener routes by the key's context - while focus sits * inside a deck, that deck answers; bare arrows (focus on <body>) drive the * first deck. Form fields and Space on focused controls are never hijacked. */function bindKeyboard(): void { if (keysBound) return; keysBound = true; // the shared global-key listener (src/shared/keys.ts) already skips keys // typed into inputs, textareas, selects and contenteditable bindGlobalKeys((e) => { const target = e.target as HTMLElement | null; const root = (target?.closest('.presentation') as Deck | null) ?? document.querySelector<Deck>('.presentation'); if (!root) return; // Space belongs to a focused control, not to the deck if (e.key === ' ' && target?.closest('button, a, [role="button"]')) return; const total = slidesOf(root).length; const go = (index: number, forward?: boolean): void => root._presentationActivate?.(index, forward); const step = (delta: number): void => { const next = indexOf(root) + delta; // data-loop wraps at both ends (a deck is a ring, when asked to be) if (root.hasAttribute('data-loop') && total > 1) go((next + total) % total, delta > 0); else go(next, delta > 0); }; let handled = true; switch (e.key) { case 'ArrowRight': case 'PageDown': case ' ': step(1); break; case 'ArrowLeft': case 'PageUp': step(-1); break; case 'Home': go(0); break; case 'End': go(total - 1); break; case 'n': case 'N': root.toggleAttribute('data-notes'); break; case 'f': case 'F': triggerStateChange(root, 'fullscreen', { value: !root.hasAttribute('data-fullscreen') }); break; default: handled = false; } if (!handled) return; e.preventDefault(); return true; // this key belonged to the deck - later global handlers skip it });}/** Hash deep-links (#slide-id) and later hashchange navigations move a deck. */let hashBound = false;function bindHash(): void { if (hashBound) return; hashBound = true; addEventListener('hashchange', () => { const id = decodeURIComponent(location.hash.slice(1)); if (!id) return; const slide = document.getElementById(id); const root = slide?.closest('.presentation') as Deck | null; if (root && slide) root._presentationActivate?.(slidesOf(root).indexOf(slide)); });}/** * Native-fullscreen bookkeeping (once per document): entering confirms the * attribute; leaving (Escape / browser chrome) clears it - but only for the * deck that was actually in NATIVE mode, never for one in the fallback mode. */let fullscreenBound = false;function bindFullscreen(): void { if (fullscreenBound) return; fullscreenBound = true; document.addEventListener('fullscreenchange', () => { const el = document.fullscreenElement as HTMLElement | null; if (el?.classList.contains('presentation')) el.dataset.fullscreen = ''; if (!el && nativeDeck) { delete nativeDeck.dataset.fullscreen; nativeDeck = null; } });}function init(): void { document.querySelectorAll<Deck>('.presentation:not([data-init])').forEach((root) => { root.dataset.init = ''; // bind-scope the api per instance: `$('#deck').api.setState('notes')` root.api = { setState: (stateName: string, config?: Record<string, unknown>) => presentationApi.setState(root, stateName, config), getState: () => presentationApi.getState(root), }; // ── the visual flip: the one place slide visibility (and its a11y state) // changes, plus everything a slide does on arrival ────────────────────── const enter = (target: HTMLElement): void => { const slides = slidesOf(root); slides.forEach((slide) => { const on = slide === target; slide.toggleAttribute('data-active', on); // visually-hidden must also be inert for AT + tab order (not just // visibility:hidden - inactive slides hold no interactive surface) slide.inert = !on; slide.setAttribute('aria-hidden', String(!on)); // media belongs to the stage it is on: autoplay videos run only on // the active slide and restart on every arrival slide.querySelectorAll<HTMLVideoElement>('video[autoplay]').forEach((video) => { if (on) { video.currentTime = 0; void video.play()?.catch(() => {}); } else video.pause(); }); }); // animated counters are per-slide on activation (a revisit re-runs them) target.querySelectorAll<HTMLElement>('[data-count]').forEach((el) => animateCount(el)); // entrances replay per activation through the SHARED motion controller // (motion.css keyframes): cancel → play is deterministic, so a fresh // load AND a revisit get identical entrances (AGENTS.md: animations, // not transitions - no rendered "from" state required) target.querySelectorAll<HTMLElement>('[data-df-entrance]').forEach((el) => { entrance(el); }); target.querySelectorAll<SVGElement>('[data-df-draw]').forEach((el) => { draw(el); }); }; /** * Why: every slide that comes and goes animates. A curtain target * (data-anim-in="blocksIn") covers the LEAVING slide, the flip happens * under the cover, then the cover rolls off the arriving slide - in a * color that contrasts with both surfaces. Everything else plays the * leaving slide's out-animation and the arriving slide's in-animation * concurrently ([data-leaving] keeps the old slide visible meanwhile). * A new navigation first SETTLES the running transition (finish + * cleanup), so fast arrow keys are never swallowed. */ const transition = (from: HTMLElement | undefined, to: HTMLElement, forward: boolean): void => { root._presentationSettle?.(); root._presentationSettle = undefined; const inSpec = animSpec(root, to, 'in', forward); if (!from || from === to) { enter(to); channelFor(inSpec.name === 'blocksIn' ? 'fadeIn' : inSpec.name).play(to, inSpec.opts); return; } if (inSpec.name === 'blocksIn') { // the curtain is ONE transition: cover + reveal share its duration const cfg: AnimOptions = { ...inSpec.opts, duration: (inSpec.opts.duration ?? 1500) / 2, color: curtainColor(root, from, to, inSpec.opts.color), }; root.setAttribute('data-curtain', ''); const cover = channelFor('blocksIn').play(from, cfg); let flipped = false; const flip = (): void => { if (flipped) return; flipped = true; cover.reset(); // a settled blocksIn keeps its overlay - the hidden slide must not enter(to); root.removeAttribute('data-curtain'); const reveal = channelFor('blocksOut').play(to, cfg); root._presentationSettle = () => reveal.finish(); }; root._presentationSettle = () => { cover.finish(); flip(); }; void cover.finished.then(flip); return; } const outSpec = animSpec(root, from, 'out', forward); from.setAttribute('data-leaving', ''); enter(to); const arriving = channelFor(inSpec.name).play(to, inSpec.opts); const leaving = channelFor(outSpec.name === 'blocksOut' ? DEFAULT_OUT : outSpec.name).play(from, outSpec.opts); let done = false; const cleanup = (): void => { if (done) return; done = true; from.removeAttribute('data-leaving'); leaving.reset(); }; void leaving.finished.then(cleanup); root._presentationSettle = () => { arriving.finish(); cleanup(); }; }; // ── activation: chrome updates now, the flip runs through transition() ── let booted = false; const activate = (index: number, forward?: boolean): void => { const slides = slidesOf(root); if (slides.length === 0) return; const clamped = clampIndex(index, slides.length); // the first activation is an arrival without a departure (authored // data-active markup is normalized by enter()) const previous = booted ? slides.find((s) => s.hasAttribute('data-active')) : undefined; const fromIndex = previous ? slides.indexOf(previous) : -1; if (booted && previous === slides[clamped]) return; booted = true; transition(previous, slides[clamped], forward ?? clamped >= fromIndex); // mirror the live index for ddf$, the bridge and getState() root.dataset.currentSlide = String(clamped); const counter = root.querySelector('.presentation-counter'); if (counter) counter.textContent = `${clamped + 1} / ${slides.length}`; const progress = root.querySelector('progress.presentation-progress'); if (progress) { progress.setAttribute('max', String(slides.length)); progress.setAttribute('value', String(clamped + 1)); } const loop = root.hasAttribute('data-loop'); const prev = root.querySelector<HTMLButtonElement>('[data-presentation-action="prev"]'); const next = root.querySelector<HTMLButtonElement>('[data-presentation-action="next"]'); if (prev) prev.disabled = clamped === 0 && !loop; if (next) next.disabled = clamped === slides.length - 1 && !loop; // in-slide number chip: NN ⁄ NN (fraction slash - inherently slanted), // filled per activation; the chrome counter remains the a11y surface const pad = (n: number): string => String(n).padStart(2, '0'); const number = slides[clamped].querySelector('.presentation-slide-number'); if (number) number.textContent = `${pad(clamped + 1)}⁄${pad(slides.length)}`; }; root._presentationActivate = activate; // ── fixed artboard → uniform scale (ResizeObserver does the math, // never a window.resize listener; container-accurate) ──────────────── const applyScale = (): void => { const cs = getComputedStyle(root); const w = parseFloat(cs.getPropertyValue('--presentation-width')) || 1600; const h = parseFloat(cs.getPropertyValue('--presentation-height')) || 900; const box = root.getBoundingClientRect(); const scale = Math.min(box.width / w, box.height / h); if (Number.isFinite(scale) && scale > 0) root.style.setProperty('--presentation-scale', String(scale)); }; new ResizeObserver(applyScale).observe(root); // ── in-deck control buttons (click delegation on the mount) ───────── root.addEventListener('click', (e) => { const btn = (e.target as HTMLElement | null)?.closest?.('[data-presentation-action]'); if (!btn || !root.contains(btn)) return; const total = slidesOf(root).length; const at = indexOf(root); switch (btn.getAttribute('data-presentation-action')) { case 'next': activate(root.hasAttribute('data-loop') ? (at + 1) % total : at + 1, true); break; case 'prev': activate(root.hasAttribute('data-loop') && at === 0 ? total - 1 : at - 1, false); break; case 'first': activate(0); break; case 'last': activate(total - 1); break; case 'notes': root.toggleAttribute('data-notes'); break; case 'fullscreen': triggerStateChange(root, 'fullscreen', { value: !root.hasAttribute('data-fullscreen') }); break; } }); bindKeyboard(); bindHash(); bindFullscreen(); // initial slide: #hash deep-link > authored data-current-slide > first const hashId = decodeURIComponent(location.hash.slice(1)); const hashIndex = hashId ? slidesOf(root).findIndex((s) => s.id === hashId) : -1; activate(hashIndex >= 0 ? hashIndex : coerceIndex(root.dataset.currentSlide, 0)); applyScale(); });}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub