Files
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

2620 lines
96 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Art-Direct an Annual Report like a Magazine Creative Director - Design craft</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Source+Serif+4:opsz,wght@8..60,300;8..60,400;8..60,500;8..60,600;8..60,700;8..60,800&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
<style>deck-stage:not(:defined){visibility:hidden}</style>
<script>
/* === DECK-STAGE RUNTIME (inlined from upstream beautiful-html-templates/runtime/deck-stage.js, MIT © Zara Zhang) ===
Fixed 1920×1080 design stage, uniformly scaled to the viewport with transform: scale()
(factor = min(viewportW/1920, viewportH/1080)), letterboxed and centered. Keyboard
navigation (←/→, Space, PgUp/PgDn, Home/End, number keys), #<n> hash deep-link restore
and write-back, thumbnail rail, print-to-PDF layout. Do not rewrite this runtime. */
/**
* <deck-stage> — reusable web component for HTML decks.
*
* Handles:
* (a) speaker notes — reads <script type="application/json" id="speaker-notes">
* and posts {slideIndexChanged: N} to the parent window on nav.
* (b) keyboard navigation — ←/→, PgUp/PgDn, Space, Home/End, number keys.
* (c) press R to reset to slide 0 (with a tasteful keyboard hint).
* (d) bottom-center overlay showing slide count + hints, fades out on idle.
* (e) auto-scaling — inner canvas is a fixed design size (default 1920×1080)
* scaled with `transform: scale()` to fit the viewport, letterboxed.
* Set the `noscale` attribute to render at authored size (1:1) — the
* PPTX exporter sets this so its DOM capture sees unscaled geometry.
* (f) print — `@media print` lays every slide out as its own page at the
* design size, so the browser's Print → Save as PDF produces a clean
* one-page-per-slide PDF with no extra setup.
* (g) thumbnail rail — resizable left-hand column of per-slide thumbnails
* (static clones). Click to navigate; ↑/↓ with a thumbnail focused to
* step between slides; drag to reorder; right-click for
* Skip / Move up / Move down / Delete (opens a Cancel/Delete confirm
* dialog). Drag the rail's right edge to resize; width persists to
* localStorage. Skipped slides carry `data-deck-skip`, are dimmed in
* the rail, omitted from prev/next navigation, and hidden at print.
* The rail is suppressed in presenting mode, on `noscale`, and via
* the `no-rail` attribute. Rail mutations dispatch a `deckchange`
* CustomEvent on the element: detail = {action, from, to, slide}.
*
* Slides are HIDDEN, not unmounted. Non-active slides stay in the DOM with
* `visibility: hidden` + `opacity: 0`, so their state (videos, iframes,
* form inputs, React trees) is preserved across navigation.
*
* Lifecycle event — the component dispatches a `slidechange` CustomEvent on
* itself whenever the active slide changes (including the initial mount).
* The event bubbles and composes out of shadow DOM, so you can listen on
* the <deck-stage> element or on document:
*
* document.querySelector('deck-stage').addEventListener('slidechange', (e) => {
* e.detail.index // new 0-based index
* e.detail.previousIndex // previous index, or -1 on init
* e.detail.total // total slide count
* e.detail.slide // the new active slide element
* e.detail.previousSlide // the prior slide element, or null on init
* e.detail.reason // 'init' | 'keyboard' | 'click' | 'tap' | 'api'
* });
*
* Persistence: none at the deck level. The host app keeps the current slide
* in its own URL (?slide=) and re-delivers it via location.hash on load, so a
* bare load with no hash always starts at slide 1.
*
* Usage:
* <style>deck-stage:not(:defined){visibility:hidden}</style>
* <deck-stage width="1920" height="1080">
* <section data-label="Title">...</section>
* <section data-label="Agenda">...</section>
* </deck-stage>
* <script src="deck-stage.js"><\/script>
*
* The :not(:defined) rule prevents a flash of the first slide at its
* authored styles before this script runs and attaches the shadow root.
*
* Slides are the direct element children of <deck-stage>. Each slide is
* automatically tagged with:
* - data-screen-label="NN Label" (1-indexed, for comment flow)
* - data-om-validate="no_overflowing_text,no_overlapping_text,slide_sized_text"
*/
(() => {
const DESIGN_W_DEFAULT = 1920;
const DESIGN_H_DEFAULT = 1080;
const OVERLAY_HIDE_MS = 1800;
const VALIDATE_ATTR = 'no_overflowing_text,no_overlapping_text,slide_sized_text';
const pad2 = (n) => String(n).padStart(2, '0');
const stylesheet = `
:host {
position: fixed;
inset: 0;
display: block;
background: #000;
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif;
overflow: hidden;
}
/* connectedCallback holds this until document.fonts.ready (capped 2s) so
* the first visible paint has the deck's real typography + final rail
* layout. opacity (not visibility) so the active slide can't un-hide
* itself via the ::slotted([data-deck-active]) visibility:visible rule.
* Only the stage/rail hide — the black :host background stays, so the
* iframe doesn't flash the page's default white. */
:host([data-fonts-pending]) .stage,
:host([data-fonts-pending]) .rail { opacity: 0; pointer-events: none; }
.stage {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.canvas {
position: relative;
transform-origin: center center;
flex-shrink: 0;
background: #fff;
will-change: transform;
}
/* Slides live in light DOM (via <slot>) so authored CSS still applies.
We absolutely position each slotted child to stack them. */
::slotted(*) {
position: absolute !important;
inset: 0 !important;
width: 100% !important;
height: 100% !important;
box-sizing: border-box !important;
overflow: hidden;
opacity: 0;
pointer-events: none;
visibility: hidden;
}
::slotted([data-deck-active]) {
opacity: 1;
pointer-events: auto;
visibility: visible;
}
/* Tap zones for mobile — back/forward thirds like Stories.
Transparent, no visible UI, don't block the overlay. */
.tapzones {
position: fixed;
inset: 0;
display: flex;
z-index: 2147482000;
pointer-events: none;
}
.tapzone {
flex: 1;
pointer-events: auto;
-webkit-tap-highlight-color: transparent;
}
/* Only activate tap zones on coarse pointers (touch devices). */
@media (hover: hover) and (pointer: fine) {
.tapzones { display: none; }
}
.overlay {
position: fixed;
left: 50%;
bottom: 22px;
transform: translate(-50%, 8px);
display: flex;
align-items: center;
gap: 4px;
padding: 4px;
background: #000;
color: #fff;
border-radius: 999px;
font-size: 12px;
font-feature-settings: "tnum" 1;
letter-spacing: 0.01em;
opacity: 0;
pointer-events: none;
transition: opacity 200ms ease, transform 200ms cubic-bezier(.2,.8,.2,1);
transform-origin: center bottom;
z-index: 2147483000;
user-select: none;
}
.overlay[data-visible] {
opacity: 1;
pointer-events: auto;
transform: translate(-50%, 0);
}
.btn {
appearance: none;
-webkit-appearance: none;
background: transparent;
border: 0;
margin: 0;
padding: 0;
color: inherit;
font: inherit;
cursor: default;
display: inline-flex;
align-items: center;
justify-content: center;
height: 28px;
min-width: 28px;
border-radius: 999px;
color: rgba(255,255,255,0.72);
transition: background 140ms ease, color 140ms ease;
-webkit-tap-highlight-color: transparent;
}
.btn:hover { background: rgba(255,255,255,0.12); color: #fff; }
.btn:active { background: rgba(255,255,255,0.18); }
.btn:focus { outline: none; }
.btn:focus-visible { outline: none; }
.btn::-moz-focus-inner { border: 0; }
.btn svg { width: 14px; height: 14px; display: block; }
.btn.reset {
font-size: 11px;
font-weight: 500;
letter-spacing: 0.02em;
padding: 0 10px 0 12px;
gap: 6px;
color: rgba(255,255,255,0.72);
}
.btn.reset .kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 16px;
height: 16px;
padding: 0 4px;
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 10px;
line-height: 1;
color: rgba(255,255,255,0.88);
background: rgba(255,255,255,0.12);
border-radius: 4px;
}
.count {
font-variant-numeric: tabular-nums;
color: #fff;
font-weight: 500;
padding: 0 8px;
min-width: 42px;
text-align: center;
font-size: 12px;
}
.count .sep { color: rgba(255,255,255,0.45); margin: 0 3px; font-weight: 400; }
.count .total { color: rgba(255,255,255,0.55); }
.divider {
width: 1px;
height: 14px;
background: rgba(255,255,255,0.18);
margin: 0 2px;
}
/* ── Thumbnail rail ──────────────────────────────────────────────────
Fixed column on the left; each thumbnail is a static deep-clone of
the light-DOM slide scaled into a 16:9 (or design-aspect) frame. The
stage re-fits around it (see _fit); hidden during present / noscale
/ print so capture geometry and fullscreen output are unchanged. */
.rail {
position: fixed;
left: 0;
top: 0;
bottom: 0;
width: var(--deck-rail-w, 188px);
background: #141414;
border-right: 1px solid rgba(255,255,255,0.08);
overflow-y: auto;
overflow-x: hidden;
padding: 12px 10px;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 12px;
z-index: 2147482500;
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.18) transparent;
}
.rail::-webkit-scrollbar { width: 8px; }
.rail::-webkit-scrollbar-track { background: transparent; margin: 2px; }
.rail::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.18);
border-radius: 4px;
border: 2px solid transparent;
background-clip: content-box;
}
.rail::-webkit-scrollbar-thumb:hover {
background: rgba(255,255,255,0.28);
border: 2px solid transparent;
background-clip: content-box;
}
:host([no-rail]) .rail,
:host([noscale]) .rail { display: none; }
.rail[data-presenting] { display: none; }
/* User-driven show/hide (the TweaksPanel toggle) slides instead of
popping. Transitions are gated on :host([data-rail-anim]) — set only
for the 200ms around the toggle — so window-resize and rail-width
drag (which also call _fit) don't lag behind the cursor. */
.rail[data-user-hidden] { transform: translateX(-100%); }
:host([data-rail-anim]) .rail { transition: transform 200ms cubic-bezier(.3,.7,.4,1); }
:host([data-rail-anim]) .stage { transition: left 200ms cubic-bezier(.3,.7,.4,1); }
:host([data-rail-anim]) .canvas { transition: transform 200ms cubic-bezier(.3,.7,.4,1); }
/* transition shorthand replaces rather than merges — repeat the base
.overlay opacity/transform/filter transitions so visibility changes
during the 200ms toggle window still fade instead of popping. */
:host([data-rail-anim]) .overlay {
transition: margin-left 200ms cubic-bezier(.3,.7,.4,1),
opacity 260ms ease,
transform 260ms cubic-bezier(.2,.8,.2,1),
filter 260ms ease;
}
:host([data-rail-anim]) .tapzones { transition: left 200ms cubic-bezier(.3,.7,.4,1); }
.thumb {
position: relative;
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
user-select: none;
}
.thumb .num {
width: 16px;
flex-shrink: 0;
font-size: 11px;
font-weight: 500;
text-align: right;
color: rgba(255,255,255,0.55);
padding-top: 2px;
font-variant-numeric: tabular-nums;
}
.thumb .frame {
position: relative;
flex: 1;
min-width: 0;
aspect-ratio: var(--deck-aspect);
background: #fff;
border-radius: 4px;
outline: 2px solid transparent;
outline-offset: 0;
overflow: hidden;
transition: outline-color 120ms ease;
}
.thumb:hover .frame { outline-color: rgba(255,255,255,0.25); }
.thumb { outline: none; }
.thumb:focus-visible .frame { outline-color: rgba(255,255,255,0.5); }
.thumb[data-current] .num { color: #fff; }
.thumb[data-current] .frame { outline-color: #D97757; }
.thumb[data-dragging] { opacity: 0.35; }
.thumb::before {
content: '';
position: absolute;
left: 24px;
right: 0;
height: 3px;
border-radius: 2px;
background: #D97757;
opacity: 0;
pointer-events: none;
}
.thumb[data-drop="before"]::before { top: -8px; opacity: 1; }
.thumb[data-drop="after"]::before { bottom: -8px; opacity: 1; }
.thumb[data-skip] .frame { opacity: 0.35; }
.thumb[data-skip] .frame::after {
content: 'Skipped';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.45);
color: #fff;
font-size: 10px;
font-weight: 500;
letter-spacing: 0.04em;
}
.ctxmenu {
position: fixed;
min-width: 150px;
padding: 4px;
background: #242424;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 7px;
box-shadow: 0 8px 24px rgba(0,0,0,0.45);
z-index: 2147483100;
display: none;
font-size: 12px;
}
.ctxmenu[data-open] { display: block; }
.ctxmenu button {
display: block;
width: 100%;
appearance: none;
border: 0;
background: transparent;
color: #e8e8e8;
font: inherit;
text-align: left;
padding: 6px 10px;
border-radius: 4px;
cursor: pointer;
}
.ctxmenu button:hover:not(:disabled) { background: rgba(255,255,255,0.08); }
.ctxmenu button:disabled { opacity: 0.35; cursor: default; }
.ctxmenu hr {
border: 0;
border-top: 1px solid rgba(255,255,255,0.1);
margin: 4px 2px;
}
.rail-resize {
position: fixed;
left: calc(var(--deck-rail-w, 188px) - 3px);
top: 0;
bottom: 0;
width: 6px;
cursor: col-resize;
z-index: 2147482600;
touch-action: none;
}
.rail-resize:hover,
.rail-resize[data-dragging] { background: rgba(255,255,255,0.12); }
:host([no-rail]) .rail-resize,
:host([noscale]) .rail-resize,
.rail[data-presenting] + .rail-resize,
.rail[data-user-hidden] + .rail-resize { display: none; }
/* Delete-confirm popup — matches the SPA's ConfirmDialog layout
(title + message body, depressed footer with Cancel / Delete). */
.confirm-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.45);
z-index: 2147483200;
display: none;
align-items: center;
justify-content: center;
}
.confirm-backdrop[data-open] { display: flex; }
.confirm {
width: 320px;
max-width: calc(100vw - 32px);
background: #2a2a2a;
color: #e8e8e8;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 12px;
box-shadow: 0 12px 32px rgba(0,0,0,0.5);
overflow: hidden;
font-family: inherit;
animation: deck-confirm-in 0.18s ease;
}
@keyframes deck-confirm-in {
from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: scale(1); }
}
.confirm .body { padding: 20px 20px 16px; }
.confirm .title { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
.confirm .msg { font-size: 13px; line-height: 1.5; color: rgba(255,255,255,0.65); }
.confirm .footer {
padding: 14px 20px;
background: #1f1f1f;
border-top: 1px solid rgba(255,255,255,0.08);
display: flex;
justify-content: flex-end;
gap: 8px;
}
.confirm button {
appearance: none;
font: inherit;
font-size: 13px;
font-weight: 500;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
}
.confirm .cancel {
background: transparent;
border: 0;
color: rgba(255,255,255,0.8);
}
.confirm .cancel:hover { background: rgba(255,255,255,0.08); }
.confirm .danger {
background: #c96442;
border: 1px solid rgba(0,0,0,0.15);
color: #fff;
box-shadow: 0 1px 3px rgba(166,50,68,0.3), 0 2px 6px rgba(166,50,68,0.18);
}
.confirm .danger:hover { background: #b5563a; }
/* ── Print: one page per slide, no chrome ────────────────────────────
The screen layout stacks every slide at inset:0 inside a scaled
canvas; for print we want them in document flow at the authored
design size so the browser paginates one slide per sheet. The
@page size is set from the width/height attributes via the inline
<style id="deck-stage-print-page"> that connectedCallback injects
into <head> (the @page at-rule has no effect inside shadow DOM). */
@media print {
:host {
position: static;
inset: auto;
background: none;
overflow: visible;
color: inherit;
}
.stage { position: static; display: block; }
.canvas {
transform: none !important;
width: auto !important;
height: auto !important;
background: none;
will-change: auto;
}
::slotted(*) {
position: relative !important;
inset: auto !important;
width: var(--deck-design-w) !important;
height: var(--deck-design-h) !important;
box-sizing: border-box !important;
opacity: 1 !important;
visibility: visible !important;
pointer-events: auto;
break-after: page;
page-break-after: always;
break-inside: avoid;
overflow: hidden;
}
/* :last-child alone isn't enough once data-deck-skip hides the
trailing slide(s) — the last *visible* slide still carries
break-after:page and prints a blank sheet. _markLastVisible()
maintains data-deck-last-visible on the last non-skipped slide. */
::slotted(*:last-child),
::slotted([data-deck-last-visible]) {
break-after: auto;
page-break-after: auto;
}
::slotted([data-deck-skip]) { display: none !important; }
.overlay, .tapzones, .rail, .rail-resize, .ctxmenu, .confirm-backdrop { display: none !important; }
}
`;
class DeckStage extends HTMLElement {
static get observedAttributes() { return ['width', 'height', 'noscale', 'no-rail']; }
constructor() {
super();
this._root = this.attachShadow({ mode: 'open' });
this._index = 0;
this._slides = [];
this._notes = [];
this._hideTimer = null;
this._mouseIdleTimer = null;
this._menuIndex = -1;
this._onKey = this._onKey.bind(this);
this._onResize = this._onResize.bind(this);
this._onSlotChange = this._onSlotChange.bind(this);
this._onMouseMove = this._onMouseMove.bind(this);
this._onTapBack = this._onTapBack.bind(this);
this._onTapForward = this._onTapForward.bind(this);
this._onMessage = this._onMessage.bind(this);
// Capture-phase close so a click anywhere dismisses the menu, but
// ignore clicks that land inside the menu itself — otherwise the
// capture handler runs before the menu's own (bubble) handler and
// clears _menuIndex out from under it.
this._onDocClick = (e) => {
if (this._menu && e.composedPath && e.composedPath().includes(this._menu)) return;
this._closeMenu();
};
}
get designWidth() {
return parseInt(this.getAttribute('width'), 10) || DESIGN_W_DEFAULT;
}
get designHeight() {
return parseInt(this.getAttribute('height'), 10) || DESIGN_H_DEFAULT;
}
connectedCallback() {
// Presenter-view popup loads deckUrl?_snthumb=...#N for its prev/cur/
// next thumbnails — the rail has no business rendering inside those
// (wrong scale, and it offsets the stage so the thumb shows a gutter).
if (/[?&]_snthumb=/.test(location.search)) this.setAttribute('no-rail', '');
this._render();
this._loadNotes();
this._syncPrintPageRule();
window.addEventListener('keydown', this._onKey);
window.addEventListener('resize', this._onResize);
window.addEventListener('mousemove', this._onMouseMove, { passive: true });
window.addEventListener('message', this._onMessage);
window.addEventListener('click', this._onDocClick, true);
// Initial collection + layout happens via slotchange, which fires on mount.
this._enableRail();
// Hold the stage hidden until webfonts are ready so the first visible
// paint has the deck's real typography — the :not(:defined) guard in
// the page HTML only covers custom-element upgrade, not font load.
// Capped so a 404'd font URL can't blank the deck indefinitely.
this.setAttribute('data-fonts-pending', '');
const reveal = () => this.removeAttribute('data-fonts-pending');
// rAF first: fonts.ready is a pre-resolved promise until layout has
// resolved the slotted text's font-family and pushed a FontFace into
// 'loading'. Reading it here in connectedCallback (parse-time) would
// settle the race in a microtask before any font fetch starts.
requestAnimationFrame(() => {
Promise.race([
document.fonts ? document.fonts.ready : Promise.resolve(),
new Promise((r) => setTimeout(r, 2000)),
]).then(reveal, reveal);
});
}
_enableRail() {
// Idempotent — older host builds still post __omelette_rail_enabled.
// no-rail guard keeps the observers/stylesheet walk off the cheap path
// for presenter-popup thumbnail iframes (up to 9 per view).
if (this._railEnabled || this.hasAttribute('no-rail')) return;
this._railEnabled = true;
// Per-viewer preference — restored alongside rail width. Default on;
// only a stored '0' (from the TweaksPanel toggle) hides it.
this._railVisible = true;
try {
if (localStorage.getItem('deck-stage.railVisible') === '0') this._railVisible = false;
} catch (e) {}
// Live thumbnail updates: watch the light-DOM slides for content
// edits and re-clone just the affected thumb(s), debounced. Ignore
// the data-deck-* / data-screen-label / data-om-validate attributes
// this component itself writes so nav and skip don't trigger
// spurious refreshes.
const OWN_ATTRS = /^data-(deck-|screen-label$|om-validate$)/;
this._liveDirty = new Set();
this._liveObserver = new MutationObserver((records) => {
for (const r of records) {
if (r.type === 'attributes' && OWN_ATTRS.test(r.attributeName || '')) continue;
let n = r.target;
while (n && n.parentElement !== this) n = n.parentElement;
if (n && this._slideSet && this._slideSet.has(n)) this._liveDirty.add(n);
}
if (this._liveDirty.size && !this._liveTimer) {
this._liveTimer = setTimeout(() => {
this._liveTimer = null;
this._liveDirty.forEach((s) => this._refreshThumb(s));
this._liveDirty.clear();
}, 200);
}
});
this._liveObserver.observe(this, {
subtree: true, childList: true, characterData: true, attributes: true,
});
// Lazy thumbnail materialization — clone the slide only when its
// frame scrolls into (or near) the rail viewport. rootMargin gives
// ~4 thumbs of pre-load so fast scrolling doesn't flash blanks.
this._railObserver = new IntersectionObserver((entries) => {
entries.forEach((e) => {
if (e.isIntersecting && e.target.__deckThumb) {
this._materialize(e.target.__deckThumb);
}
});
}, { root: this._rail, rootMargin: '400px 0px' });
// Tweaks typically change CSS vars / attrs OUTSIDE <deck-stage>
// (on <html>, <body>, a wrapper div, or a <style> tag), which
// _liveObserver can't see. Re-snapshot author CSS (constructable
// sheet is shared by reference, so one replaceSync updates every
// thumb shadow root) and re-sync each thumb host's attrs + custom
// properties. In-slide DOM mutations are _liveObserver's job.
// Debounced so slider drags don't thrash.
this._onTweakChange = () => {
clearTimeout(this._tweakTimer);
this._tweakTimer = setTimeout(() => {
this._snapshotAuthorCss();
// One getComputedStyle for the whole batch — each
// getPropertyValue read below reuses the same computed style
// as long as nothing invalidates layout between thumbs.
const cs = getComputedStyle(this);
(this._thumbs || []).forEach((t) => {
if (t.host) this._syncThumbHostAttrs(t.host, cs);
});
}, 120);
};
window.addEventListener('tweakchange', this._onTweakChange);
this._snapshotAuthorCss();
// Build the rail now that it's enabled — slotchange already fired,
// so _renderRail's early-return skipped the initial build.
this._syncRailHidden();
this._renderRail();
this._fit();
}
/** Snapshot document stylesheets into a constructable sheet that each
* thumbnail's nested shadow root adopts — so author CSS styles the
* cloned slide content without touching this component's chrome.
* Cross-origin sheets throw on .cssRules — skip them. Re-callable:
* the existing constructable sheet is reused via replaceSync so every
* already-adopted shadow root picks up the fresh CSS without re-adopt. */
_snapshotAuthorCss() {
// :root in an adopted sheet inside a shadow root matches nothing
// (only the document root qualifies), so author rules like
// `:root[data-voice="modern"] .serif` never reach the clones.
// Rewrite :root → :host and mirror <html>'s data-*/class/lang onto
// each thumb host (see _syncThumbHostAttrs) so the same selectors
// match inside the thumbnail's shadow tree.
const authorCss = Array.from(document.styleSheets).map((sh) => {
try {
return Array.from(sh.cssRules).map((r) => r.cssText).join('\n');
} catch (e) { return ''; }
}).join('\n')
// The shadow host is featureless outside the functional :host(...)
// form, so any compound on :root — [attr], .class, #id, :pseudo —
// must become :host(<compound>) not :host<compound>. Same for the
// html type selector (Tailwind class-strategy dark mode emits
// html.dark; Pico uses html[data-theme]), which has nothing to
// match inside the thumb's shadow tree.
.replace(/:root((?:\[[^\]]*\]|[.#][-\w]+|:[-\w]+(?:\([^)]*\))?)+)/g, ':host($1)')
.replace(/:root\b/g, ':host')
.replace(/(^|[\s,>~+(}])html((?:\[[^\]]*\]|[.#][-\w]+|:[-\w]+(?:\([^)]*\))?)+)(?![-\w])/g, '$1:host($2)')
.replace(/(^|[\s,>~+(}])html(?![-\w])/g, '$1:host');
// Every custom property the author references. _syncThumbHostAttrs
// mirrors each one's *computed* value at <deck-stage> onto the
// thumb host so the live value wins over the :host default above
// regardless of which ancestor the tweak wrote to (<html>, <body>,
// a wrapper div, or the deck-stage element itself all inherit
// down to getComputedStyle(this)).
this._authorVars = new Set(authorCss.match(/--[\w-]+/g) || []);
try {
if (!this._adoptedSheet) this._adoptedSheet = new CSSStyleSheet();
this._adoptedSheet.replaceSync(authorCss);
} catch (e) {
this._adoptedSheet = null;
this._authorCss = authorCss;
}
}
_syncThumbHostAttrs(host, cs) {
const de = document.documentElement;
// setAttribute overwrites but can't delete — an attr removed from
// <html> (toggleAttribute off, classList emptied) would linger on
// the host and :host([data-*]) / :host(.foo) rules would keep
// matching. Remove stale mirrored attrs first; iterate backward
// because removeAttribute mutates the live NamedNodeMap.
for (let i = host.attributes.length - 1; i >= 0; i--) {
const n = host.attributes[i].name;
if ((n.startsWith('data-') || n === 'class' || n === 'lang')
&& !de.hasAttribute(n)) {
host.removeAttribute(n);
}
}
for (const a of de.attributes) {
if (a.name.startsWith('data-') || a.name === 'class' || a.name === 'lang') {
host.setAttribute(a.name, a.value);
}
}
// The :root→:host rewrite in _snapshotAuthorCss pins each custom
// property to its stylesheet default on the thumb host, shadowing
// the live value that would otherwise inherit. Tweaks can write the
// live value on any ancestor — <html>, <body>, a wrapper div, the
// deck-stage element — so read it as the *computed* value at
// <deck-stage> (which sees the whole inheritance chain) rather than
// trying to guess which element the author wrote to. Inline on the
// host beats the :host{} rule. remove-stale covers vars dropped
// from the stylesheet between snapshots.
const vars = this._authorVars || new Set();
for (let i = host.style.length - 1; i >= 0; i--) {
const p = host.style[i];
if (p.startsWith('--') && !vars.has(p)) host.style.removeProperty(p);
}
const live = cs || getComputedStyle(this);
vars.forEach((p) => {
const v = live.getPropertyValue(p);
if (v) host.style.setProperty(p, v.trim());
else host.style.removeProperty(p);
});
}
disconnectedCallback() {
window.removeEventListener('keydown', this._onKey);
window.removeEventListener('resize', this._onResize);
window.removeEventListener('mousemove', this._onMouseMove);
window.removeEventListener('message', this._onMessage);
window.removeEventListener('click', this._onDocClick, true);
if (this._hideTimer) clearTimeout(this._hideTimer);
if (this._mouseIdleTimer) clearTimeout(this._mouseIdleTimer);
if (this._liveTimer) clearTimeout(this._liveTimer);
if (this._tweakTimer) clearTimeout(this._tweakTimer);
if (this._railAnimTimer) clearTimeout(this._railAnimTimer);
if (this._scaleRaf) cancelAnimationFrame(this._scaleRaf);
if (this._liveObserver) this._liveObserver.disconnect();
if (this._railObserver) this._railObserver.disconnect();
if (this._onTweakChange) window.removeEventListener('tweakchange', this._onTweakChange);
}
attributeChangedCallback() {
if (this._canvas) {
this._canvas.style.width = this.designWidth + 'px';
this._canvas.style.height = this.designHeight + 'px';
this._canvas.style.setProperty('--deck-design-w', this.designWidth + 'px');
this._canvas.style.setProperty('--deck-design-h', this.designHeight + 'px');
if (this._rail) {
this._rail.style.setProperty('--deck-aspect', this.designWidth + '/' + this.designHeight);
}
this._fit();
this._scaleThumbs();
this._syncPrintPageRule();
}
}
_render() {
const style = document.createElement('style');
style.textContent = stylesheet;
const stage = document.createElement('div');
stage.className = 'stage';
const canvas = document.createElement('div');
canvas.className = 'canvas';
canvas.style.width = this.designWidth + 'px';
canvas.style.height = this.designHeight + 'px';
canvas.style.setProperty('--deck-design-w', this.designWidth + 'px');
canvas.style.setProperty('--deck-design-h', this.designHeight + 'px');
const slot = document.createElement('slot');
slot.addEventListener('slotchange', this._onSlotChange);
canvas.appendChild(slot);
stage.appendChild(canvas);
// Tap zones (mobile): left third = back, right third = forward.
const tapzones = document.createElement('div');
tapzones.className = 'tapzones export-hidden';
tapzones.setAttribute('aria-hidden', 'true');
tapzones.setAttribute('data-noncommentable', '');
const tzBack = document.createElement('div');
tzBack.className = 'tapzone tapzone--back';
const tzMid = document.createElement('div');
tzMid.className = 'tapzone tapzone--mid';
tzMid.style.pointerEvents = 'none';
const tzFwd = document.createElement('div');
tzFwd.className = 'tapzone tapzone--fwd';
tzBack.addEventListener('click', this._onTapBack);
tzFwd.addEventListener('click', this._onTapForward);
tapzones.append(tzBack, tzMid, tzFwd);
// Overlay: compact, solid black, with clickable controls.
const overlay = document.createElement('div');
overlay.className = 'overlay export-hidden';
overlay.setAttribute('role', 'toolbar');
overlay.setAttribute('aria-label', 'Deck controls');
overlay.setAttribute('data-noncommentable', '');
overlay.innerHTML = `
<button class="btn prev" type="button" aria-label="Previous slide" title="Previous (←)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 3L5 8l5 5"/></svg>
</button>
<span class="count" style="display:none" aria-live="polite"><span class="current">1</span><span class="sep">/</span><span class="total">1</span></span>
<button class="btn next" type="button" aria-label="Next slide" title="Next (→)">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3l5 5-5 5"/></svg>
</button>
<span class="divider"></span>
<button class="btn reset" type="button" aria-label="Reset to first slide" title="Reset (R)">Reset<span class="kbd">R</span></button>
`;
overlay.querySelector('.prev').addEventListener('click', () => this._advance(-1, 'click'));
overlay.querySelector('.next').addEventListener('click', () => this._advance(1, 'click'));
overlay.querySelector('.reset').addEventListener('click', () => this._go(0, 'click'));
// Thumbnail rail + context menu. Thumbnails are populated in
// _renderRail() after _collectSlides().
const rail = document.createElement('div');
rail.className = 'rail export-hidden';
rail.setAttribute('data-noncommentable', '');
rail.style.setProperty('--deck-aspect', this.designWidth + '/' + this.designHeight);
// Edge auto-scroll while dragging a thumb near the rail's top/bottom
// so off-screen drop targets are reachable. Native dragover fires
// continuously while the pointer is stationary, so a per-event nudge
// (ramped by edge proximity) is enough — no rAF loop needed.
rail.addEventListener('dragover', (e) => {
if (this._dragFrom == null) return;
const r = rail.getBoundingClientRect();
const EDGE = 40;
const dt = e.clientY - r.top;
const db = r.bottom - e.clientY;
if (dt < EDGE) rail.scrollTop -= Math.ceil((EDGE - dt) / 3);
else if (db < EDGE) rail.scrollTop += Math.ceil((EDGE - db) / 3);
});
const menu = document.createElement('div');
menu.className = 'ctxmenu export-hidden';
menu.setAttribute('data-noncommentable', '');
menu.innerHTML = `
<button type="button" data-act="skip">Skip slide</button>
<button type="button" data-act="up">Move up</button>
<button type="button" data-act="down">Move down</button>
<hr>
<button type="button" data-act="delete">Delete slide</button>
`;
menu.addEventListener('click', (e) => {
const act = e.target && e.target.getAttribute && e.target.getAttribute('data-act');
if (!act) return;
const i = this._menuIndex;
this._closeMenu();
if (act === 'skip') this._toggleSkip(i);
else if (act === 'up') this._moveSlide(i, i - 1);
else if (act === 'down') this._moveSlide(i, i + 1);
else if (act === 'delete') this._openConfirm(i);
});
menu.addEventListener('contextmenu', (e) => e.preventDefault());
// Rail resize handle — drag to set --deck-rail-w, persisted to
// localStorage so the width survives reloads.
const resize = document.createElement('div');
resize.className = 'rail-resize export-hidden';
resize.setAttribute('data-noncommentable', '');
resize.addEventListener('pointerdown', (e) => {
e.preventDefault();
resize.setPointerCapture(e.pointerId);
resize.setAttribute('data-dragging', '');
const move = (ev) => this._setRailWidth(ev.clientX);
const up = () => {
resize.removeEventListener('pointermove', move);
resize.removeEventListener('pointerup', up);
resize.removeEventListener('pointercancel', up);
resize.removeAttribute('data-dragging');
try { localStorage.setItem('deck-stage.railWidth', String(this._railPx)); } catch (err) {}
};
resize.addEventListener('pointermove', move);
resize.addEventListener('pointerup', up);
resize.addEventListener('pointercancel', up);
});
// Delete-confirm dialog — mirrors the SPA's ConfirmDialog layout.
const confirm = document.createElement('div');
confirm.className = 'confirm-backdrop export-hidden';
confirm.setAttribute('data-noncommentable', '');
confirm.innerHTML = `
<div class="confirm" role="dialog" aria-modal="true">
<div class="body">
<div class="title">Delete slide?</div>
<div class="msg">This slide will be removed from the deck.</div>
</div>
<div class="footer">
<button type="button" class="cancel">Cancel</button>
<button type="button" class="danger">Delete</button>
</div>
</div>
`;
confirm.addEventListener('click', (e) => {
if (e.target === confirm) this._closeConfirm();
});
confirm.querySelector('.cancel').addEventListener('click', () => this._closeConfirm());
confirm.querySelector('.danger').addEventListener('click', () => {
const i = this._confirmIndex;
this._closeConfirm();
this._deleteSlide(i);
});
this._root.append(style, rail, resize, stage, tapzones, overlay, menu, confirm);
this._canvas = canvas;
this._slot = slot;
this._overlay = overlay;
this._tapzones = tapzones;
this._rail = rail;
this._resize = resize;
this._menu = menu;
this._confirm = confirm;
this._countEl = overlay.querySelector('.current');
this._totalEl = overlay.querySelector('.total');
// Restore persisted rail width.
let rw = 188;
try {
const s = localStorage.getItem('deck-stage.railWidth');
if (s) rw = parseInt(s, 10) || rw;
} catch (err) {}
this._setRailWidth(rw);
this._syncRailHidden();
}
_setRailWidth(px) {
const w = Math.max(120, Math.min(360, Math.round(px)));
this._railPx = w;
this.style.setProperty('--deck-rail-w', w + 'px');
this._fit();
// _scaleThumbs forces a sync layout (frame.offsetWidth) then writes
// N transforms. During a resize drag this runs per-pointermove;
// coalesce to one per frame.
if (!this._scaleRaf) {
this._scaleRaf = requestAnimationFrame(() => {
this._scaleRaf = null;
this._scaleThumbs();
});
}
}
/** @page must live in the document stylesheet — it's a no-op inside
* shadow DOM. Inject/update a single <head> style tag so the print
* sheet matches the design size and Save-as-PDF yields one slide per
* page with no margins. */
_syncPrintPageRule() {
const id = 'deck-stage-print-page';
let tag = document.getElementById(id);
if (!tag) {
tag = document.createElement('style');
tag.id = id;
document.head.appendChild(tag);
}
tag.textContent =
'@page { size: ' + this.designWidth + 'px ' + this.designHeight + 'px; margin: 0; } ' +
'@media print { html, body { margin: 0 !important; padding: 0 !important; background: none !important; overflow: visible !important; height: auto !important; } ' +
'* { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }';
}
_onSlotChange() {
// Rail mutations (delete/move) already reconcile synchronously and
// emit slidechange with reason 'api'; skip the async slotchange that
// would otherwise re-broadcast with reason 'init'.
if (this._squelchSlotChange) { this._squelchSlotChange = false; return; }
this._collectSlides();
this._restoreIndex();
this._applyIndex({ showOverlay: false, broadcast: true, reason: 'init' });
this._fit();
}
_collectSlides() {
const assigned = this._slot.assignedElements({ flatten: true });
this._slides = assigned.filter((el) => {
// Skip template/style/script nodes even if someone slots them.
const tag = el.tagName;
return tag !== 'TEMPLATE' && tag !== 'SCRIPT' && tag !== 'STYLE';
});
this._slideSet = new Set(this._slides);
this._slides.forEach((slide, i) => {
const n = i + 1;
// Determine a label for comment flow: prefer explicit data-label,
// then an existing data-screen-label, then first heading, else "Slide".
let label = slide.getAttribute('data-label');
if (!label) {
const existing = slide.getAttribute('data-screen-label');
if (existing) {
// Strip any leading number the author may have included.
label = existing.replace(/^\s*\d+\s*/, '').trim() || existing;
}
}
if (!label) {
const h = slide.querySelector('h1, h2, h3, [data-title]');
if (h) label = (h.textContent || '').trim().slice(0, 40);
}
if (!label) label = 'Slide';
slide.setAttribute('data-screen-label', `${pad2(n)} ${label}`);
// Validation attribute for comment flow / auto-checks.
if (!slide.hasAttribute('data-om-validate')) {
slide.setAttribute('data-om-validate', VALIDATE_ATTR);
}
slide.setAttribute('data-deck-slide', String(i));
});
if (this._totalEl) this._totalEl.textContent = String(this._slides.length || 1);
if (this._index >= this._slides.length) this._index = Math.max(0, this._slides.length - 1);
this._markLastVisible();
this._renderRail();
}
/** Tag the last non-skipped slide so print CSS can drop its
* break-after (see the @media print comment above — :last-child
* alone matches a hidden skipped slide). */
_markLastVisible() {
let last = null;
this._slides.forEach((s) => {
s.removeAttribute('data-deck-last-visible');
if (!s.hasAttribute('data-deck-skip')) last = s;
});
if (last) last.setAttribute('data-deck-last-visible', '');
}
_loadNotes() {
const tag = document.getElementById('speaker-notes');
if (!tag) { this._notes = []; return; }
try {
const parsed = JSON.parse(tag.textContent || '[]');
if (Array.isArray(parsed)) this._notes = parsed;
} catch (e) {
console.warn('[deck-stage] Failed to parse #speaker-notes JSON:', e);
this._notes = [];
}
}
_restoreIndex() {
// The host's ?slide= param is delivered as a #<int> hash (1-indexed) on
// the iframe src. No hash → slide 1; the deck itself keeps no position
// state across loads.
const h = (location.hash || '').match(/^#(\d+)$/);
if (h) {
const n = parseInt(h[1], 10) - 1;
if (n >= 0 && n < this._slides.length) this._index = n;
}
}
_applyIndex({ showOverlay = true, broadcast = true, reason = 'init' } = {}) {
if (!this._slides.length) return;
const prev = this._prevIndex == null ? -1 : this._prevIndex;
const curr = this._index;
// Keep the iframe's own hash in sync so an in-iframe location.reload()
// (reload banner path in viewer-handle.ts) lands on the current slide,
// not the stale deep-link hash from initial load.
try { history.replaceState(null, '', '#' + (curr + 1)); } catch (e) {}
this._slides.forEach((s, i) => {
if (i === curr) s.setAttribute('data-deck-active', '');
else s.removeAttribute('data-deck-active');
});
if (this._countEl) this._countEl.textContent = String(curr + 1);
// Follow-scroll on every navigation (init deep-link, keyboard, click,
// tap, external goTo) — the only time we *don't* want the rail to
// track current is after a rail-internal mutation, where _renderRail
// has already restored the user's scroll position and yanking back to
// current would undo it.
this._syncRail(reason !== 'mutation');
if (broadcast) {
// (1) Legacy: host-window postMessage for speaker-notes renderers.
try { window.postMessage({ slideIndexChanged: curr, deckTotal: this._slides.length, deckSkipped: this._skippedIndices() }, '*'); } catch (e) {}
// (2) In-page CustomEvent on the <deck-stage> element itself.
// Bubbles and composes out of shadow DOM so slide code can listen:
// document.querySelector('deck-stage').addEventListener('slidechange', e => {
// e.detail.index, e.detail.previousIndex, e.detail.total, e.detail.slide, e.detail.reason
// });
const detail = {
index: curr,
previousIndex: prev,
total: this._slides.length,
slide: this._slides[curr] || null,
previousSlide: prev >= 0 ? (this._slides[prev] || null) : null,
reason: reason, // 'init' | 'keyboard' | 'click' | 'tap' | 'api'
};
this.dispatchEvent(new CustomEvent('slidechange', {
detail,
bubbles: true,
composed: true,
}));
}
this._prevIndex = curr;
if (showOverlay) this._flashOverlay();
}
_flashOverlay() {
// Host posts __omelette_presenting while in fullscreen/tab presentation
// mode — suppress the nav footer entirely (both hover and slide-change
// flash) so the audience sees clean slides.
if (!this._overlay || this._presenting) return;
this._overlay.setAttribute('data-visible', '');
if (this._hideTimer) clearTimeout(this._hideTimer);
this._hideTimer = setTimeout(() => {
this._overlay.removeAttribute('data-visible');
}, OVERLAY_HIDE_MS);
}
_railWidth() {
// State-based, no offsetWidth: the first _fit() can run before the
// rail has had layout on some load paths, and a 0 there paints the
// slide full-width for one frame before the post-slotchange _fit()
// corrects it.
if (!this._railEnabled || !this._railVisible || this.hasAttribute('no-rail')
|| this.hasAttribute('noscale') || this._presenting) return 0;
return this._railPx || 0;
}
_fit() {
if (!this._canvas) return;
const stage = this._canvas.parentElement;
// PPTX export sets noscale so the DOM capture sees authored-size
// geometry — the scaled canvas is in shadow DOM, so the exporter's
// resetTransformSelector can't reach .canvas.style.transform directly.
if (this.hasAttribute('noscale')) {
this._canvas.style.transform = 'none';
if (stage) stage.style.left = '0';
if (this._overlay) this._overlay.style.marginLeft = '0';
if (this._tapzones) this._tapzones.style.left = '0';
return;
}
const rw = this._railWidth();
if (stage) stage.style.left = rw + 'px';
// Overlay is centred on the viewport via left:50% + translate(-50%);
// marginLeft shifts the centre by rw/2 so it lands in the middle of
// the [rw, innerWidth] stage region. Tapzones just inset from rw.
if (this._overlay) this._overlay.style.marginLeft = (rw / 2) + 'px';
if (this._tapzones) this._tapzones.style.left = rw + 'px';
const vw = window.innerWidth - rw;
const vh = window.innerHeight;
const s = Math.min(vw / this.designWidth, vh / this.designHeight);
this._canvas.style.transform = `scale(${s})`;
}
_onResize() { this._fit(); }
_onMouseMove() {
// Keep overlay visible while mouse moves; hide after idle.
this._flashOverlay();
}
_onMessage(e) {
const d = e.data;
if (d && typeof d.__omelette_presenting === 'boolean') {
this._presenting = d.__omelette_presenting;
if (this._presenting && this._overlay) {
this._overlay.removeAttribute('data-visible');
if (this._hideTimer) clearTimeout(this._hideTimer);
}
this._syncRailHidden();
this._closeMenu();
this._closeConfirm();
this._fit();
this._scaleThumbs();
}
// Per-viewer show/hide, driven by the TweaksPanel's auto-injected
// "Thumbnail rail" toggle (or any author script). Independent of
// whether the Tweaks panel itself is open — closing the panel
// doesn't change rail visibility. Persists alongside rail width.
if (d && d.type === '__deck_rail_visible' && typeof d.on === 'boolean') {
if (d.on === this._railVisible) return;
this._railVisible = d.on;
try { localStorage.setItem('deck-stage.railVisible', d.on ? '1' : '0'); } catch (e) {}
// Arm the transition, commit it, then flip state — otherwise the
// browser coalesces both writes and nothing animates on show.
this.setAttribute('data-rail-anim', '');
void (this._rail && this._rail.offsetHeight);
this._syncRailHidden();
this._fit();
this._scaleThumbs();
clearTimeout(this._railAnimTimer);
this._railAnimTimer = setTimeout(() => this.removeAttribute('data-rail-anim'), 220);
}
if (d && d.type === '__omelette_rail_enabled') this._enableRail();
}
_syncRailHidden() {
if (!this._rail) return;
// data-presenting is the hard hide (display:none) for flag-off and
// presentation mode — instant, no transition. data-user-hidden is
// the soft hide (translateX(-100%)) for the viewer's rail toggle,
// so show/hide slides under :host([data-rail-anim]).
const hard = !this._railEnabled || this._presenting;
if (hard) this._rail.setAttribute('data-presenting', '');
else this._rail.removeAttribute('data-presenting');
if (!this._railVisible) this._rail.setAttribute('data-user-hidden', '');
else this._rail.removeAttribute('data-user-hidden');
// translateX hide leaves thumbs (tabIndex=0) in the tab order —
// inert keeps them unfocusable while the rail is off-screen.
this._rail.inert = hard || !this._railVisible;
}
_onTapBack(e) {
e.preventDefault();
this._advance(-1, 'tap');
}
_onTapForward(e) {
e.preventDefault();
this._advance(1, 'tap');
}
_onKey(e) {
// Ignore when the user is typing.
const t = e.target;
if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
// Confirm dialog swallows nav keys while open; Escape cancels. Enter
// is left to the focused button's native activation so Tab→Cancel
// →Enter activates Cancel, not the window-level confirm path.
if (this._confirm && this._confirm.hasAttribute('data-open')) {
if (e.key === 'Escape') { this._closeConfirm(); e.preventDefault(); }
return;
}
if (e.key === 'Escape' && this._menu && this._menu.hasAttribute('data-open')) {
this._closeMenu();
e.preventDefault();
return;
}
if (e.metaKey || e.ctrlKey || e.altKey) return;
const key = e.key;
let handled = true;
if (key === 'ArrowRight' || key === 'PageDown' || key === ' ' || key === 'Spacebar') {
this._advance(1, 'keyboard');
} else if (key === 'ArrowLeft' || key === 'PageUp') {
this._advance(-1, 'keyboard');
} else if (key === 'Home') {
this._go(0, 'keyboard');
} else if (key === 'End') {
this._go(this._slides.length - 1, 'keyboard');
} else if (key === 'r' || key === 'R') {
this._go(0, 'keyboard');
} else if (/^[0-9]$/.test(key)) {
// 1..9 jump to that slide; 0 jumps to 10.
const n = key === '0' ? 9 : parseInt(key, 10) - 1;
if (n < this._slides.length) this._go(n, 'keyboard');
} else {
handled = false;
}
if (handled) {
e.preventDefault();
this._flashOverlay();
}
}
_go(i, reason = 'api') {
if (!this._slides.length) return;
const clamped = Math.max(0, Math.min(this._slides.length - 1, i));
if (clamped === this._index) {
this._flashOverlay();
return;
}
this._index = clamped;
this._applyIndex({ showOverlay: true, broadcast: true, reason });
}
/** Step forward/back skipping any slide marked data-deck-skip. Falls
* back to _go's clamp-at-ends behaviour (flash overlay) when there's
* nothing further in that direction. */
_advance(dir, reason) {
if (!this._slides.length) return;
let i = this._index + dir;
while (i >= 0 && i < this._slides.length && this._slides[i].hasAttribute('data-deck-skip')) {
i += dir;
}
if (i < 0 || i >= this._slides.length) { this._flashOverlay(); return; }
this._go(i, reason);
}
// ── Thumbnail rail ────────────────────────────────────────────────────
//
// Thumbs are keyed by slide element and reused across _renderRail()
// calls, so a reorder/delete is an O(changed) DOM shuffle instead of an
// O(N) teardown-and-re-clone. Each thumb starts as a lightweight shell
// (num + empty frame); the clone is materialized lazily by an
// IntersectionObserver when the frame scrolls into (or near) view, so
// only visible-ish slides pay the clone + image-decode cost.
_renderRail() {
if (!this._rail || !this._railEnabled) { this._thumbs = []; return; }
// FLIP: record each *materialized* thumb's top before the reconcile.
// Off-screen (non-materialized) thumbs don't need the animation and
// skipping their getBoundingClientRect saves a forced layout per
// off-screen thumb on large decks.
const prevTops = new Map();
(this._thumbs || []).forEach(({ thumb, slide, host }) => {
if (host) prevTops.set(slide, thumb.getBoundingClientRect().top);
});
const st = this._rail.scrollTop;
// Reconcile: reuse thumbs that already exist for a slide, create
// shells for new slides, drop thumbs for removed slides.
const bySlide = new Map();
(this._thumbs || []).forEach((t) => bySlide.set(t.slide, t));
const next = [];
this._slides.forEach((slide) => {
let t = bySlide.get(slide);
if (t) bySlide.delete(slide);
else t = this._makeThumb(slide);
next.push(t);
});
// Orphans — slides removed since last render.
bySlide.forEach((t) => {
if (this._railObserver) this._railObserver.unobserve(t.frame);
t.thumb.remove();
});
// Put thumbs into document order to match _slides. insertBefore on
// an already-correctly-placed node is a no-op, so this is cheap
// when nothing moved.
next.forEach((t, i) => {
const want = t.thumb;
const at = this._rail.children[i];
if (at !== want) this._rail.insertBefore(want, at || null);
t.i = i;
t.num.textContent = String(i + 1);
if (t.slide.hasAttribute('data-deck-skip')) t.thumb.setAttribute('data-skip', '');
else t.thumb.removeAttribute('data-skip');
});
this._thumbs = next;
this._rail.scrollTop = st;
if (prevTops.size) {
const moved = [];
this._thumbs.forEach(({ thumb, slide }) => {
const old = prevTops.get(slide);
if (old == null) return;
const dy = old - thumb.getBoundingClientRect().top;
if (Math.abs(dy) < 1) return;
thumb.style.transition = 'none';
thumb.style.transform = `translateY(${dy}px)`;
moved.push(thumb);
});
if (moved.length) {
// Commit the inverted positions before flipping the transition
// on — otherwise the browser coalesces both style writes and
// nothing animates.
void this._rail.offsetHeight;
moved.forEach((t) => {
t.style.transition = 'transform 180ms cubic-bezier(.2,.7,.3,1)';
t.style.transform = '';
});
setTimeout(() => moved.forEach((t) => { t.style.transition = ''; }), 220);
}
}
requestAnimationFrame(() => this._scaleThumbs());
this._syncRail(false);
}
/** Create a lightweight thumb shell for one slide. The clone is
* materialized later by the IntersectionObserver. Event handlers
* look up the thumb's *current* index (via _thumbs.indexOf) so the
* same element can be reused across reorders. */
_makeThumb(slide) {
const thumb = document.createElement('div');
thumb.className = 'thumb';
thumb.tabIndex = 0;
const num = document.createElement('div');
num.className = 'num';
const frame = document.createElement('div');
frame.className = 'frame';
thumb.append(num, frame);
const entry = { thumb, num, frame, slide, clone: null, host: null, i: -1 };
// entry.i is refreshed on every _renderRail reconcile pass, so
// handlers read the thumb's current position without an O(N) scan.
const idx = () => entry.i;
thumb.addEventListener('click', () => this._go(idx(), 'click'));
// ↑/↓ step through the rail when a thumb has focus. _go clamps at the
// ends and _applyIndex→_syncRail scrolls the new current thumb into
// view; we move focus to it (preventScroll — _syncRail already
// scrolled) so a held key walks the whole list. stopPropagation keeps
// this out of the window-level _onKey nav handler.
thumb.addEventListener('keydown', (e) => {
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
e.preventDefault();
e.stopPropagation();
this._go(idx() + (e.key === 'ArrowDown' ? 1 : -1), 'keyboard');
const cur = this._thumbs && this._thumbs[this._index];
if (cur) cur.thumb.focus({ preventScroll: true });
});
thumb.addEventListener('contextmenu', (e) => {
e.preventDefault();
this._openMenu(idx(), e.clientX, e.clientY);
});
thumb.draggable = true;
thumb.addEventListener('dragstart', (e) => {
this._dragFrom = idx();
thumb.setAttribute('data-dragging', '');
e.dataTransfer.effectAllowed = 'move';
try { e.dataTransfer.setData('text/plain', String(this._dragFrom)); } catch (err) {}
});
thumb.addEventListener('dragend', () => {
thumb.removeAttribute('data-dragging');
this._clearDrop();
this._dragFrom = null;
});
thumb.addEventListener('dragover', (e) => {
if (this._dragFrom == null) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const r = thumb.getBoundingClientRect();
this._setDrop(idx(), e.clientY < r.top + r.height / 2 ? 'before' : 'after');
});
thumb.addEventListener('drop', (e) => {
if (this._dragFrom == null) return;
e.preventDefault();
const i = idx();
const r = thumb.getBoundingClientRect();
let to = e.clientY >= r.top + r.height / 2 ? i + 1 : i;
if (this._dragFrom < to) to--;
const from = this._dragFrom;
this._clearDrop();
this._dragFrom = null;
if (to !== from) this._moveSlide(from, to);
});
if (this._railObserver) this._railObserver.observe(frame);
frame.__deckThumb = entry;
return entry;
}
/** Lazily build the clone for a thumb that has scrolled into view. */
_materialize(entry) {
if (entry.host) return;
const dw = this.designWidth, dh = this.designHeight;
let clone = entry.slide.cloneNode(true);
clone.removeAttribute('id');
clone.removeAttribute('data-deck-active');
clone.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'));
// Neuter heavy media; replace <video> with its poster so the box
// keeps a visual. <iframe>/<audio> become empty placeholders.
clone.querySelectorAll('iframe, audio, object, embed').forEach((el) => {
el.removeAttribute('src');
el.removeAttribute('srcdoc');
el.removeAttribute('data');
el.innerHTML = '';
});
clone.querySelectorAll('video').forEach((el) => {
if (!el.poster) { el.removeAttribute('src'); el.innerHTML = ''; return; }
const img = document.createElement('img');
img.src = el.poster;
img.alt = '';
img.style.cssText = el.style.cssText + ';object-fit:cover;width:100%;height:100%;';
img.className = el.className;
el.replaceWith(img);
});
// Images: defer decode and let the browser pick the smallest
// srcset candidate for the ~140px thumb. Same-URL clones reuse the
// slide's decoded bitmap (URL-keyed cache), so the remaining cost
// is paint/composite — lazy+async keeps that off the main thread.
clone.querySelectorAll('img').forEach((el) => {
el.loading = 'lazy';
el.decoding = 'async';
if (el.srcset) el.sizes = (this._railPx || 188) + 'px';
});
// Custom elements inside the slide would have their
// connectedCallback fire when the clone is appended. Replace them
// with inert boxes so a component-heavy deck doesn't run N copies
// of each component's mount logic in the rail. Children are
// preserved so layout-wrapper elements (<my-column><h2>…</h2>)
// still show their authored content; the querySelectorAll NodeList
// is static, so nested custom elements in the moved subtree are
// still visited on later iterations.
const neuter = (el) => {
const box = document.createElement('div');
box.style.cssText = (el.getAttribute('style') || '') +
';background:rgba(0,0,0,0.06);border:1px dashed rgba(0,0,0,0.15);';
box.className = el.className;
// Preserve theming/i18n hooks so [data-*] / :lang() / [dir]
// descendant selectors still match the neutered root.
for (const a of el.attributes) {
const n = a.name;
if (n.startsWith('data-') || n.startsWith('aria-') ||
n === 'lang' || n === 'dir' || n === 'role' || n === 'title') {
box.setAttribute(n, a.value);
}
}
while (el.firstChild) box.appendChild(el.firstChild);
return box;
};
// querySelectorAll('*') returns descendants only — a custom-element
// slide root (<my-slide>…</my-slide>) would slip through and upgrade
// on append. Swap the root first.
if (clone.tagName.includes('-')) clone = neuter(clone);
clone.querySelectorAll('*').forEach((el) => {
if (el.tagName.includes('-')) el.replaceWith(neuter(el));
});
clone.style.cssText += ';position:absolute;top:0;left:0;transform-origin:0 0;' +
'pointer-events:none;width:' + dw + 'px;height:' + dh + 'px;' +
'box-sizing:border-box;overflow:hidden;visibility:visible;opacity:1;';
const host = document.createElement('div');
host.style.cssText = 'position:absolute;inset:0;';
this._syncThumbHostAttrs(host);
const sr = host.attachShadow({ mode: 'open' });
if (this._adoptedSheet) sr.adoptedStyleSheets = [this._adoptedSheet];
else {
const st = document.createElement('style');
st.textContent = this._authorCss || '';
sr.appendChild(st);
}
sr.appendChild(clone);
entry.frame.appendChild(host);
entry.host = host;
entry.clone = clone;
if (this._thumbScale) clone.style.transform = 'scale(' + this._thumbScale + ')';
// Once materialized the IO callback is a no-op early-return —
// unobserve so scroll doesn't keep firing it.
if (this._railObserver) this._railObserver.unobserve(entry.frame);
}
/** Re-clone a single thumb (live-update path). No-op if the thumb
* hasn't been materialized yet — it'll pick up current content when
* it scrolls into view. */
_refreshThumb(slide) {
const entry = (this._thumbs || []).find((t) => t.slide === slide);
if (!entry || !entry.host) return;
entry.host.remove();
entry.host = entry.clone = null;
this._materialize(entry);
}
_scaleThumbs() {
if (!this._thumbs || !this._thumbs.length) return;
// Every frame is the same width; if it reads 0 the rail is
// display:none (noscale / no-rail / presenting / print) — leave the
// clones as-is and re-run when the rail is revealed.
const fw = this._thumbs[0].frame.offsetWidth;
if (!fw) return;
this._thumbScale = fw / this.designWidth;
this._thumbs.forEach(({ clone }) => {
if (clone) clone.style.transform = 'scale(' + this._thumbScale + ')';
});
}
_setDrop(i, where) {
// dragover fires at pointer-event rate; touch only the previous
// and new target rather than sweeping all N thumbs.
const t = this._thumbs && this._thumbs[i];
if (this._dropOn && this._dropOn !== t) {
this._dropOn.thumb.removeAttribute('data-drop');
}
if (t) t.thumb.setAttribute('data-drop', where);
this._dropOn = t || null;
}
_clearDrop() {
if (this._dropOn) this._dropOn.thumb.removeAttribute('data-drop');
this._dropOn = null;
}
_syncRail(follow) {
if (!this._thumbs) return;
this._thumbs.forEach(({ thumb }, i) => {
if (i === this._index) {
thumb.setAttribute('data-current', '');
if (follow && typeof thumb.scrollIntoView === 'function') {
thumb.scrollIntoView({ block: 'nearest' });
}
} else {
thumb.removeAttribute('data-current');
}
});
}
_openMenu(i, x, y) {
if (!this._menu) return;
this._menuIndex = i;
const slide = this._slides[i];
const skip = slide && slide.hasAttribute('data-deck-skip');
this._menu.querySelector('[data-act="skip"]').textContent = skip ? 'Unskip slide' : 'Skip slide';
this._menu.querySelector('[data-act="up"]').disabled = i <= 0;
this._menu.querySelector('[data-act="down"]').disabled = i >= this._slides.length - 1;
this._menu.querySelector('[data-act="delete"]').disabled = this._slides.length <= 1;
// Place, then clamp to viewport after it's measurable.
this._menu.style.left = x + 'px';
this._menu.style.top = y + 'px';
this._menu.setAttribute('data-open', '');
const r = this._menu.getBoundingClientRect();
const nx = Math.min(x, window.innerWidth - r.width - 4);
const ny = Math.min(y, window.innerHeight - r.height - 4);
this._menu.style.left = Math.max(4, nx) + 'px';
this._menu.style.top = Math.max(4, ny) + 'px';
}
_closeMenu() {
if (this._menu) this._menu.removeAttribute('data-open');
this._menuIndex = -1;
}
_openConfirm(i) {
if (!this._confirm) return;
this._confirmIndex = i;
this._confirm.querySelector('.title').textContent = 'Delete slide ' + (i + 1) + '?';
this._confirm.setAttribute('data-open', '');
const btn = this._confirm.querySelector('.danger');
if (btn && btn.focus) btn.focus();
}
_closeConfirm() {
if (this._confirm) this._confirm.removeAttribute('data-open');
this._confirmIndex = -1;
}
_emitDeckChange(detail) {
this.dispatchEvent(new CustomEvent('deckchange', {
detail, bubbles: true, composed: true,
}));
}
_deleteSlide(i) {
const slide = this._slides[i];
if (!slide || this._slides.length <= 1) return;
const wasCurrent = i === this._index;
if (i < this._index || (wasCurrent && i === this._slides.length - 1)) this._index--;
this._squelchSlotChange = true;
slide.remove();
this._emitDeckChange({ action: 'delete', from: i, slide });
this._collectSlides();
this._applyIndex({ showOverlay: true, broadcast: true, reason: 'mutation' });
}
_toggleSkip(i) {
const slide = this._slides[i];
if (!slide) return;
const on = !slide.hasAttribute('data-deck-skip');
if (on) slide.setAttribute('data-deck-skip', '');
else slide.removeAttribute('data-deck-skip');
if (this._thumbs && this._thumbs[i]) {
if (on) this._thumbs[i].thumb.setAttribute('data-skip', '');
else this._thumbs[i].thumb.removeAttribute('data-skip');
}
this._markLastVisible();
this._emitDeckChange({ action: on ? 'skip' : 'unskip', from: i, slide });
// Re-broadcast so the presenter popup's prev/next thumbnails re-pick
// the nearest non-skipped slide without waiting for a nav event.
try { window.postMessage({ slideIndexChanged: this._index, deckTotal: this._slides.length, deckSkipped: this._skippedIndices() }, '*'); } catch (e) {}
}
_skippedIndices() {
const out = [];
for (let i = 0; i < this._slides.length; i++) {
if (this._slides[i].hasAttribute('data-deck-skip')) out.push(i);
}
return out;
}
_moveSlide(i, j) {
if (j < 0 || j >= this._slides.length || j === i) return;
const slide = this._slides[i];
const ref = j < i ? this._slides[j] : this._slides[j].nextSibling;
// Track the active slide across the reorder so the same content
// stays on screen.
const cur = this._index;
if (cur === i) this._index = j;
else if (i < cur && j >= cur) this._index = cur - 1;
else if (i > cur && j <= cur) this._index = cur + 1;
this._squelchSlotChange = true;
this.insertBefore(slide, ref);
this._emitDeckChange({ action: 'move', from: i, to: j, slide });
this._collectSlides();
this._applyIndex({ showOverlay: false, broadcast: true, reason: 'mutation' });
}
// Public API ------------------------------------------------------------
/** Current slide index (0-based). */
get index() { return this._index; }
/** Total slide count. */
get length() { return this._slides.length; }
/** Programmatically navigate. */
goTo(i) { this._go(i, 'api'); }
next() { this._advance(1, 'api'); }
prev() { this._advance(-1, 'api'); }
reset() { this._go(0, 'api'); }
}
if (!customElements.get('deck-stage')) {
customElements.define('deck-stage', DeckStage);
}
})();
</script>
<style>
/* === EDITORIAL FOREST DESIGN TOKENS (locked) === */
:root {
--green: #2e4a2a;
--green-deep: #243a21;
--green-lite: #3a5a36;
--pink: #e89cb1;
--pink-deep: #d27e96;
--cream: #efe7d4;
--cream-2: #e6dcc4;
--ink: #1a1a17;
--serif: "Source Serif 4", "Source Serif Pro", Georgia, serif;
--mono: "JetBrains Mono", ui-monospace, Menlo, monospace;
}
html, body { margin: 0; padding: 0; background: var(--ink); }
deck-stage { font-family: var(--serif); color: var(--ink); }
section {
box-sizing: border-box;
width: 1920px;
height: 1080px;
padding: 96px 120px;
background: var(--cream);
color: var(--ink);
position: relative;
overflow: hidden;
}
.label {
font-family: var(--mono);
font-size: 26px;
letter-spacing: 0.18em;
text-transform: uppercase;
font-weight: 500;
}
/* ============ 01 — Cover ============ */
.cover {
background: var(--green);
color: var(--pink);
padding: 100px 140px;
}
.cover .topbar {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--pink);
}
.cover .topbar .label { color: var(--pink); }
.cover .mark {
width: 130px;
height: 130px;
border-radius: 50%;
border: 2px solid var(--pink);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--mono);
font-size: 28px;
letter-spacing: 0.1em;
font-weight: 500;
}
.cover h1 {
font-family: var(--serif);
font-weight: 500;
font-size: 220px;
line-height: 0.92;
letter-spacing: -0.02em;
margin: 40px 0 0 0;
}
.cover .footline {
position: absolute;
bottom: 80px;
left: 140px;
right: 140px;
display: flex;
justify-content: space-between;
font-family: var(--mono);
font-size: 28px;
color: var(--pink);
letter-spacing: 0.12em;
text-transform: uppercase;
font-weight: 500;
}
/* ============ 02 — Agenda (tiles) ============ */
.agenda {
background: var(--cream);
padding: 90px 120px;
display: grid;
grid-template-rows: auto 1fr;
gap: 36px;
}
.agenda .head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 40px;
}
.agenda h2 {
font-family: var(--serif);
font-weight: 500;
font-size: 96px;
line-height: 0.96;
letter-spacing: -0.02em;
color: var(--green);
margin: 0;
white-space: nowrap;
}
.agenda .head .label { color: var(--green); white-space: nowrap; }
.agenda-grid {
display: grid;
grid-template-columns: 1.5fr 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 24px;
}
.topic {
border-radius: 6px;
position: relative;
padding: 40px 40px 36px;
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 0;
}
.topic.t-green { background: var(--green); color: var(--pink); grid-row: span 2; }
.topic.t-pink { background: var(--pink); color: var(--green-deep); }
.topic.t-greenLite { background: var(--green-lite); color: var(--pink); }
.topic.t-cream { background: var(--cream-2); color: var(--green); border: 2px solid var(--green); }
.topic .num {
font-family: var(--mono);
font-size: 26px;
letter-spacing: 0.14em;
font-weight: 500;
}
.topic h3 {
font-family: var(--serif);
font-weight: 500;
font-size: 56px;
line-height: 0.98;
letter-spacing: -0.01em;
margin: 28px 0 0 0;
}
.topic.t-green h3 { font-size: 84px; }
.topic p {
font-family: var(--serif);
font-size: 26px;
line-height: 1.32;
margin: 16px 0 0 0;
max-width: 100%;
font-weight: 400;
}
.topic .foot {
font-family: var(--mono);
font-size: 24px;
letter-spacing: 0.12em;
text-transform: uppercase;
font-weight: 500;
margin-top: 20px;
}
/* ============ 03 — Statement (big idea) ============ */
.statement {
background: var(--pink);
color: var(--green-deep);
padding: 130px 160px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.statement .label { color: var(--green-deep); }
.statement blockquote {
margin: 48px 0 0 0;
font-family: var(--serif);
font-weight: 500;
font-size: 140px;
line-height: 1.02;
letter-spacing: -0.02em;
max-width: 1560px;
}
.statement .attrib {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-top: 60px;
font-family: var(--mono);
font-size: 26px;
text-transform: uppercase;
letter-spacing: 0.14em;
font-weight: 500;
gap: 60px;
}
.statement .attrib > div { display: flex; flex-direction: column; gap: 14px; white-space: nowrap; }
.statement .attrib .name {
font-family: var(--serif);
font-size: 44px;
text-transform: none;
letter-spacing: 0;
font-weight: 600;
line-height: 1;
}
/* ============ 04 — Two-column with figure ============ */
.two-col {
background: var(--cream);
display: grid;
grid-template-columns: 880px 1fr;
gap: 100px;
align-items: stretch;
padding: 110px 120px;
}
.two-col .col-img {
background: var(--green);
color: var(--pink);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.two-col .col-img .figure {
font-family: var(--serif);
font-size: 56px;
color: var(--pink);
font-weight: 500;
text-align: center;
padding: 0 40px;
line-height: 1.05;
}
.two-col .col-img .caption {
position: absolute;
bottom: 36px;
left: 36px;
right: 36px;
display: flex;
justify-content: space-between;
font-family: var(--mono);
font-size: 26px;
letter-spacing: 0.14em;
text-transform: uppercase;
font-weight: 500;
color: var(--pink);
}
.two-col .col-text { display: flex; flex-direction: column; padding-top: 8px; }
.two-col .eyebrow {
font-family: var(--mono);
font-size: 26px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--green);
font-weight: 500;
margin-bottom: 32px;
}
.two-col h2 {
font-family: var(--serif);
font-weight: 500;
font-size: 96px;
line-height: 0.96;
letter-spacing: -0.02em;
color: var(--green);
margin: 0 0 40px 0;
}
.two-col p {
font-family: var(--serif);
font-size: 30px;
line-height: 1.38;
color: var(--ink);
margin: 0 0 24px 0;
max-width: 760px;
font-weight: 400;
text-wrap: pretty;
}
.two-col .meta {
margin-top: auto;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 36px;
border-top: 2px solid var(--green);
padding-top: 32px;
}
.two-col .meta dt {
font-family: var(--mono);
font-size: 24px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--green);
font-weight: 500;
margin-bottom: 8px;
}
.two-col .meta dd {
font-family: var(--serif);
font-size: 32px;
color: var(--ink);
margin: 0;
font-weight: 500;
}
/* ============ 05 — Data / chart ============ */
.data { background: var(--green); color: var(--cream); padding: 100px 120px; }
.data .head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 60px;
}
.data .head .label { color: var(--pink); }
.data h2 {
font-family: var(--serif);
font-weight: 500;
font-size: 84px;
line-height: 1.0;
letter-spacing: -0.02em;
color: var(--cream);
margin: 16px 0 0 0;
max-width: 1200px;
}
.data .legend {
display: flex;
gap: 36px;
margin-top: 18px;
font-family: var(--mono);
font-size: 26px;
letter-spacing: 0.1em;
color: var(--cream);
text-transform: uppercase;
font-weight: 500;
white-space: nowrap;
}
.data .legend .sw {
display: inline-block;
width: 26px;
height: 26px;
margin-right: 12px;
vertical-align: -4px;
border-radius: 2px;
}
.chart {
margin-top: 110px;
height: 520px;
display: grid;
grid-template-columns: 80px 1fr;
grid-template-rows: 1fr 60px;
column-gap: 24px;
}
.chart .yaxis {
display: flex;
flex-direction: column;
justify-content: space-between;
font-family: var(--mono);
font-size: 26px;
color: var(--cream);
text-align: right;
padding-right: 8px;
font-weight: 500;
}
.chart .plot {
position: relative;
border-left: 2px solid var(--cream);
border-bottom: 2px solid var(--cream);
padding: 0 30px;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 30px;
}
.chart .grid-lines {
position: absolute;
inset: 0;
background-image: repeating-linear-gradient(to top, transparent 0 calc(25% - 1px), rgba(239,231,212,0.18) calc(25% - 1px) 25%);
pointer-events: none;
}
.bar-group { display: flex; flex-direction: column; align-items: center; flex: 1; height: 100%; justify-content: flex-end; gap: 8px; }
.bar-pair { display: flex; align-items: flex-end; gap: 12px; height: 100%; width: 100%; justify-content: center; }
.bar { width: 56px; border-radius: 3px 3px 0 0; position: relative; }
.bar.a { background: var(--pink); }
.bar.b { background: var(--cream); }
.bar .val {
position: absolute;
top: -38px;
left: 50%;
transform: translateX(-50%);
font-family: var(--mono);
font-size: 24px;
color: var(--cream);
font-weight: 500;
}
.chart .xlabel-row {
grid-column: 2;
display: flex;
justify-content: space-between;
padding: 18px 30px 0;
font-family: var(--mono);
font-size: 26px;
color: var(--cream);
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 500;
}
.chart .xlabel { flex: 1; text-align: center; }
.data .footnote {
position: absolute;
bottom: 60px;
left: 120px;
right: 120px;
display: flex;
justify-content: space-between;
font-family: var(--mono);
font-size: 26px;
color: var(--pink);
letter-spacing: 0.12em;
text-transform: uppercase;
font-weight: 500;
}
/* ============ 06 — Framework / 4 steps ============ */
.framework { background: var(--cream); padding: 100px 120px; }
.framework .head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 40px;
}
.framework .head .label { color: var(--green); white-space: nowrap; }
.framework h2 {
font-family: var(--serif);
font-weight: 500;
font-size: 96px;
line-height: 0.96;
letter-spacing: -0.02em;
color: var(--green);
margin: 24px 0 0 0;
max-width: 1500px;
}
.flow {
margin-top: 70px;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 28px;
align-items: stretch;
}
.step {
border-radius: 8px;
padding: 40px 32px 32px;
display: flex;
flex-direction: column;
min-height: 470px;
border: 2.5px solid var(--green);
background: var(--cream);
color: var(--green);
}
.step.fill { background: var(--green); color: var(--pink); border-color: var(--green); }
.step.pinkfill { background: var(--pink); color: var(--green-deep); border-color: var(--pink-deep); }
.step .num {
font-family: var(--mono);
font-size: 26px;
letter-spacing: 0.16em;
font-weight: 500;
}
.step h3 {
font-family: var(--serif);
font-size: 68px;
line-height: 0.96;
letter-spacing: -0.01em;
font-weight: 500;
margin: 24px 0 24px 0;
}
.step p {
font-family: var(--serif);
font-size: 26px;
line-height: 1.34;
margin: 0;
font-weight: 400;
}
.step .marker {
margin-top: auto;
display: flex;
justify-content: space-between;
align-items: baseline;
padding-top: 24px;
border-top: 2px solid currentColor;
font-family: var(--mono);
font-size: 24px;
letter-spacing: 0.12em;
text-transform: uppercase;
font-weight: 500;
}
/* ============ 07 — Big stats / KPI callouts ============ */
.stats { background: var(--green); color: var(--cream); padding: 100px 120px; }
.stats .head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 40px;
}
.stats .head .label { color: var(--pink); white-space: nowrap; }
.stats h2 {
font-family: var(--serif);
font-weight: 500;
font-size: 80px;
line-height: 0.98;
letter-spacing: -0.02em;
color: var(--cream);
margin: 18px 0 0 0;
max-width: 1400px;
}
.kpi-row {
margin-top: 80px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 60px;
border-top: 2px solid var(--pink);
padding-top: 56px;
}
.kpi { display: flex; flex-direction: column; gap: 16px; }
.kpi .tag {
font-family: var(--mono);
font-size: 24px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--pink);
font-weight: 500;
}
.kpi .big {
font-family: var(--serif);
font-size: 220px;
line-height: 0.92;
letter-spacing: -0.03em;
color: var(--pink);
font-weight: 500;
}
.kpi .big .unit {
font-size: 110px;
color: var(--cream);
margin-left: 4px;
}
.kpi .desc {
font-family: var(--serif);
font-size: 30px;
line-height: 1.32;
color: var(--cream);
margin: 8px 0 0 0;
font-weight: 400;
}
/* ============ 08 — Summary / closing ============ */
.summary { background: var(--green); color: var(--cream); padding: 110px 140px; }
.summary .topbar {
display: flex;
justify-content: space-between;
align-items: center;
}
.summary .topbar .label { color: var(--pink); }
.summary h2 {
font-family: var(--serif);
font-weight: 500;
font-size: 220px;
line-height: 0.94;
letter-spacing: -0.02em;
color: var(--pink);
margin: 50px 0 0 0;
}
.summary .grid-cols {
margin-top: 70px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 60px;
border-top: 2px solid var(--pink);
padding-top: 36px;
}
.summary .grid-cols h4 {
font-family: var(--mono);
font-size: 26px;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--pink);
margin: 0 0 18px 0;
font-weight: 500;
}
.summary .grid-cols p {
font-family: var(--serif);
font-size: 32px;
line-height: 1.32;
margin: 0;
color: var(--cream);
font-weight: 400;
}
/* Motion discipline: author-side slides are static by design; this guard
keeps any future light-DOM transition/animation quiet for users who
prefer reduced motion. */
@media (prefers-reduced-motion: reduce) {
deck-stage section *,
deck-stage section {
animation-duration: 0.001s !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001s !important;
}
}
</style>
</head>
<body>
<deck-stage aspect="1920/1080" no-rail>
<!-- ============ 01 — Cover ============ -->
<section class="cover" data-screen-label="01 Cover">
<div class="topbar">
<span class="label">Design Craft &middot; Art Direction Teardown</span>
<div class="mark">MV</div>
</div>
<h1>Art-Direct<br/>the Annual Report.</h1>
<div class="footline">
<span>Maison Vireo &middot; FY2025 Annual Report</span>
<span>Creative Direction Review &middot; Internal</span>
</div>
</section>
<!-- ============ 02 — Agenda (tile grid) ============ -->
<section class="agenda" data-screen-label="02 Agenda">
<div class="head">
<h2>Agenda.</h2>
<span class="label">Five moves &middot; one editorial system</span>
</div>
<div class="agenda-grid">
<div class="topic t-green">
<div>
<div class="num">01</div>
<h3>Where it reads<br/>corporate,<br/>not couture.</h3>
</div>
<div class="foot">Diagnose</div>
</div>
<div class="topic t-pink">
<div>
<div class="num">02</div>
<h3>The three rules that rescue it.</h3>
</div>
<div class="foot">Principles</div>
</div>
<div class="topic t-cream">
<div>
<div class="num">03</div>
<h3>A grid built for photography.</h3>
</div>
<div class="foot">Page system</div>
</div>
<div class="topic t-greenLite">
<div>
<div class="num">04</div>
<h3>Spread 12, rebuilt in proof.</h3>
</div>
<div class="foot">Proof rebuild</div>
</div>
<div class="topic t-pink">
<div>
<div class="num">05</div>
<h3>What ships to the printer.</h3>
</div>
<div class="foot">Checklist</div>
</div>
</div>
</section>
<!-- ============ 03 — Big statement ============ -->
<section class="statement" data-screen-label="03 Statement">
<div>
<span class="label">Where it went wrong</span>
<blockquote>Forty-eight pages of stock-photo confidence and centered Helvetica &mdash; a fashion house's annual report that could belong to any insurer.</blockquote>
</div>
<div class="attrib">
<div>
<div class="name">Creative direction memo</div>
<div style="margin-top: 8px;">First read, art department</div>
</div>
<div>Section 01 &middot; Diagnose</div>
</div>
</section>
<!-- ============ 04 — Two-column with figure ============ -->
<section class="two-col" data-screen-label="04 Two Column">
<div class="col-img">
<svg width="430" height="560" viewBox="0 0 400 520" fill="none" role="img" aria-label="Fern sprig illustration">
<g stroke="#e89cb1" stroke-width="5" fill="none" stroke-linecap="round">
<path d="M200 500 C 196 360 196 200 200 30"/>
<path d="M200 120 C 160 110 120 120 96 150"/>
<path d="M200 120 C 240 110 280 120 304 150"/>
<path d="M200 200 C 152 190 104 202 78 238"/>
<path d="M200 200 C 248 190 296 202 322 238"/>
<path d="M200 290 C 148 282 96 296 70 336"/>
<path d="M200 290 C 252 282 304 296 330 336"/>
<path d="M200 385 C 156 378 112 392 88 430"/>
<path d="M200 385 C 244 378 288 392 312 430"/>
</g>
</svg>
<div class="caption">
<span>Plate 01</span>
<span>Grid study, Vireo type system</span>
</div>
</div>
<div class="col-text">
<div class="eyebrow">The rescue principles</div>
<h2>Type carries; photography breathes.</h2>
<p>The old report tried to make every page do the same job &mdash; caption, chart, and photograph fighting for the same centered column. The fix starts by giving each element one job: the baseline grid carries type, the photograph sets the pace, and nothing sits centered without a reason.</p>
<p>Two typefaces, not five. One accent color, held in reserve for a single fact per spread. White space is not empty space &mdash; it is the pause between a runway image and the number that explains it.</p>
<dl class="meta">
<div><dt>Grid</dt><dd>12-col, 12mm gutter</dd></div>
<div><dt>Type</dt><dd>Canela + GT America</dd></div>
<div><dt>Palette</dt><dd>Ink, bone, oxblood</dd></div>
</dl>
</div>
</section>
<!-- ============ 05 — Data chart ============ -->
<section class="data" data-screen-label="05 Data">
<div class="head">
<div>
<div class="label">The page system</div>
<h2>Photography-to-text ratio,<br/>spread by spread.</h2>
</div>
<div class="legend">
<span><span class="sw" style="background: var(--pink);"></span>Photography</span>
<span><span class="sw" style="background: var(--cream);"></span>Copy</span>
</div>
</div>
<div class="chart">
<div class="yaxis">
<span>100</span><span>75</span><span>50</span><span>25</span><span>0</span>
</div>
<div class="plot">
<div class="grid-lines"></div>
<div class="bar-group"><div class="bar-pair">
<div class="bar a" style="height: 58%;"><span class="val">58</span></div>
<div class="bar b" style="height: 44%;"><span class="val">44</span></div>
</div></div>
<div class="bar-group"><div class="bar-pair">
<div class="bar a" style="height: 66%;"><span class="val">66</span></div>
<div class="bar b" style="height: 51%;"><span class="val">51</span></div>
</div></div>
<div class="bar-group"><div class="bar-pair">
<div class="bar a" style="height: 79%;"><span class="val">79</span></div>
<div class="bar b" style="height: 60%;"><span class="val">60</span></div>
</div></div>
<div class="bar-group"><div class="bar-pair">
<div class="bar a" style="height: 87%;"><span class="val">87</span></div>
<div class="bar b" style="height: 68%;"><span class="val">68</span></div>
</div></div>
<div class="bar-group"><div class="bar-pair">
<div class="bar a" style="height: 91%;"><span class="val">91</span></div>
<div class="bar b" style="height: 74%;"><span class="val">74</span></div>
</div></div>
</div>
<div class="xlabel-row">
<span class="xlabel">Letter</span>
<span class="xlabel">Runway</span>
<span class="xlabel">Craft</span>
<span class="xlabel">Impact</span>
<span class="xlabel">Close</span>
</div>
</div>
<div class="footnote">
<span>Source &middot; Maison Vireo design system audit</span>
<span>Sampled &middot; 48-page proof, June draft</span>
</div>
</section>
<!-- ============ 06 — Framework / 4 steps ============ -->
<section class="framework" data-screen-label="06 Framework">
<div class="head">
<span class="label">Proof rebuild</span>
<span class="label">Spread 12</span>
</div>
<h2>From flat stock photo<br/>to a considered spread.</h2>
<div class="flow">
<div class="step">
<div class="num">Step 01</div>
<h3>Strip</h3>
<p>Pull every element off the old template &mdash; caption, chart, folio &mdash; and start again from the photograph's actual crop.</p>
<div class="marker"><span>Before</span><span>Studio</span></div>
</div>
<div class="step pinkfill">
<div class="num">Step 02</div>
<h3>Grid</h3>
<p>Lock the photograph to the 12-column baseline grid so caption and folio land on the same line on every spread.</p>
<div class="marker"><span>Grid</span><span>Studio</span></div>
</div>
<div class="step fill">
<div class="num">Step 03</div>
<h3>Set</h3>
<p>Run the revenue table through the mono numeral set so the data spread reads like the fashion pages, not an appendix.</p>
<div class="marker"><span>Data</span><span>Studio</span></div>
</div>
<div class="step">
<div class="num">Step 04</div>
<h3>Proof</h3>
<p>Print a full-bleed proof at trim size and check the gutter margin before the file leaves the studio.</p>
<div class="marker"><span>Proof</span><span>Print</span></div>
</div>
</div>
</section>
<!-- ============ 07 — Big stats / KPI callouts ============ -->
<section class="stats" data-screen-label="07 Stats">
<div class="head">
<span class="label">What the rebuild proved</span>
<span class="label">Spread 12 &rarr; 14</span>
</div>
<h2>Three numbers the<br/>board actually noticed.</h2>
<div class="kpi-row">
<div class="kpi">
<span class="tag">Read time</span>
<span class="big">54<span class="unit">s</span></span>
<p class="desc">average time a board member spent per spread in the walkthrough, down from ninety seconds on the first draft.</p>
</div>
<div class="kpi">
<span class="tag">Revisions</span>
<span class="big">2<span class="unit">rds</span></span>
<p class="desc">rounds to final sign-off, down from five the previous reporting cycle.</p>
</div>
<div class="kpi">
<span class="tag">Photo pages</span>
<span class="big">31<span class="unit">/48</span></span>
<p class="desc">pages now led by a photograph, rather than a stock image or a bare data table.</p>
</div>
</div>
</section>
<!-- ============ 08 — Summary / closing ============ -->
<section class="summary" data-screen-label="08 Summary">
<div class="topbar">
<span class="label">Before it ships</span>
<span class="label">Export checklist</span>
</div>
<h2>Check the plate.<br/>Then print.</h2>
<div class="grid-cols">
<div>
<h4>Trim</h4>
<p>Bleed is 5mm on every spread; confirm folios sit 12mm from the trim edge before the proof goes out.</p>
</div>
<div>
<h4>Color</h4>
<p>Convert to CMYK against the printer's ICC profile &mdash; oxblood shifts warm on coated stock, so proof it physically.</p>
</div>
<div>
<h4>Type</h4>
<p>Embed Canela and GT America in the export; no system-font fallback reaches the printer's RIP.</p>
</div>
</div>
</section>
</deck-stage>
<script>
// === HASH ROUTING SUPPLEMENT ===
// deck-stage restores #<n> on load and writes it back via history.replaceState;
// this listener completes the loop so editing the hash by hand also navigates.
window.addEventListener('hashchange', () => {
const deck = document.querySelector('deck-stage');
const m = (location.hash || '').match(/^#(\d+)$/);
if (deck && m && typeof deck.goTo === 'function') {
deck.goTo(parseInt(m[1], 10) - 1);
}
});
</script>
</body>
</html>