Problem: Switching slides reset iframe.src each time, causing visible
flicker (white flash → load → render) on every navigation.
Solution: load each preview iframe ONCE, then use postMessage for all
subsequent slide changes. The iframe stays mounted; only .is-active
class toggles between slides inside it.
Changes:
- runtime.js preview mode: now exposes a showSlide() function and
listens for window.message events of type 'preview-goto'. Also
posts 'preview-ready' to parent on init so the presenter window
knows when each iframe is loaded.
- presenter window: tracks iframeReady state per iframe; first frame
renders via iframe.src = ?preview=N (one-time), all subsequent
navigation goes through postPreviewGoto() → postMessage. Notes,
meta, and timer update directly without touching iframes.
- Init no longer calls update(idx) which used to reset iframe.src;
instead inits notes/meta/count once and lets the load→ready flow
populate previews.
Docs synced to match new architecture:
- SKILL.md: describes 4 magnetic cards, draggable/resizable, with
the iframe ?preview=N pattern explained for AI consumers
- references/presenter-mode.md: updated ASCII diagram to show 4
cards, removed old 58%/35% layout description, added explanation
for why previews are pixel-perfect (iframe loads same HTML) and
why navigation is flicker-free (postMessage, not reload)
- presenter-mode-reveal README: updated 4-card description
Complete rewrite of presenter mode. Two-part solution:
1. Single-slide preview mode in runtime.js
- Detect ?preview=N query param on page load
- When present: show only slide N, hide all chrome (progress-bar,
notes-overlay, overview), skip key handlers, skip hash/broadcast
- This lets the presenter window load the deck file in iframes with
specific slides — CSS/theme/colors 100% identical to audience view
2. Magnetic-card presenter window
- 4 independent cards (CURRENT / NEXT / SCRIPT / TIMER)
- Each card is position:absolute, draggable by header, resizable by
bottom-right corner handle
- Cards have colored indicator dots matching their role
- Dragging shows blue outline; resizing shows green
- Layout persists to localStorage per-deck, with 'Reset layout' button
- Default layout: CURRENT big top-left, NEXT top-right, SCRIPT
bottom-right, TIMER bottom-left
Previews use iframe.src = deckUrl + '?preview=N':
- iframe loads THE SAME HTML file as the audience
- runtime.js detects ?preview=N and renders only that slide
- CSS transform:scale() fits 1920x1080 into card body, centered
- Zero style injection / zero HTML escaping — can't break
Sync: BroadcastChannel keeps audience ↔ presenter in lockstep.
Verified with headless Chrome: tokyo-night theme, gradient headlines,
speaker avatars, agenda rows — all render identically between windows.
Two user-reported issues fixed:
1. Slide layout broken in preview (content stuck to top, not centered)
Root cause: iframe CSS was forcing .slide { display:block !important }
which overrode base.css's .slide { display:flex; justify-content:center }
Fix: Do NOT override .slide or .deck styling in iframe. Let host CSS
(base.css + theme + scoped) handle flex centering, padding, etc.
Only override .is-active visibility and hide notes/chrome.
2. Users can now resize preview regions
- Horizontal splitter between current (left) and right column
- Vertical splitter between next preview (top) and notes (bottom)
- Splitters show blue hover state, drag updates flex sizes
- reScale() called on every drag frame to keep iframe 1:1 accurate
- Min sizes enforced (200px width, 80-100px height) to prevent collapse
Verified with headless Chrome: cover slide now correctly vertically
centered in the preview, matching audience view pixel-perfect.
Previous iframe srcdoc approach completely broke because .replace(/"/g, '"')
mangled the stylesheet <link href="..."> into <link href="...">,
causing browser to treat the entire "file://..." as the attribute
value literal. All CSS failed to load → blank/unstyled preview.
New approach:
- iframe.contentDocument.write() takes raw HTML string, NO escaping needed
- All quotes (double and single) pass through untouched
- stylesheet href attributes remain valid
- body class and html attrs pass through cleanly
- Added initWhenReady() polling to ensure iframe contentDocument is available
before first render (avoids race condition where document.write fires
before iframe is fully initialized)
Verified with headless Chrome render — current/next slides now show correct
colors, fonts, layout matching audience view pixel-for-pixel.
Root cause: CSS viewport units (vw/vh), clamp(), and percentage-based
layouts resolve against the actual viewport, not a fixed-size div.
DOM clones in a 1920x1080 container still render differently from the
audience view because the iframe-less context has the wrong viewport.
Fix: replace DOM clone approach with <iframe srcdoc='...'> for both
current and next slide previews. Each iframe:
- Is sized 1920x1080 (matching the design resolution)
- Creates its own viewport context (100vw=1920px, 100vh=1080px)
- Loads the full page CSS (absolute URLs) + body classes + html attrs
- Wraps the single slide in a .deck container
- CSS transform:scale() shrinks it to fit the preview area
- Result: pixel-perfect match with audience view
Also: srcdoc template carries the host page's body classes (e.g.
.tpl-presenter-mode-reveal) and html attributes (data-themes, lang)
so scoped CSS and theme variables work correctly inside the iframe.
3 bugs fixed:
1. T key theme path error (ERR_FILE_NOT_FOUND)
- cycleTheme now auto-detects theme base path from existing
<link id='theme-link'> href instead of hardcoding 'assets/themes/'
- Works correctly from any subdirectory depth (e.g. templates/full-decks/xxx/)
2. Presenter popup colors wrong (gray/missing styles)
- Popup <body> now inherits host page's body classes (e.g. .tpl-presenter-mode-reveal)
so scoped CSS selectors like .tpl-xxx .slide work correctly
- Popup <html> inherits all host attributes (lang, data-themes, data-theme, etc.)
- Added .pv-body class with !important overrides to ensure presenter layout
wins over any host CSS that might affect body
3. Presenter popup slide layout mismatch
- Slides are now wrapped in a proper <div class='deck'> container at
exactly 1920×1080px, matching the audience window's DOM structure
- .pv-stage-inner .deck .slide forced to 1920×1080 with !important
- Removed all manual style.xxx = overrides in renderSlide(); layout comes
purely from CSS now, matching the audience view pixel-for-pixel
- Hidden runtime chrome (.progress-bar, .notes-overlay, .overview, etc.)
inside preview clones
BREAKING: S key no longer overlays on the same page. It now opens a
separate browser window (window.open) for the presenter.
Audience window (original page):
- Stays completely untouched — normal slide view
- S key opens the presenter popup
- ← → / T / F / O all work as before
- Navigation syncs to presenter via BroadcastChannel
Presenter window (popup):
- Current slide rendered at 1920×1080 then CSS transform:scale() to fit
- Next slide preview also at 1920×1080 scale() — layout never breaks
- Large-font speaker script (18px, scrollable)
- Elapsed timer + page counter + current slide title
- ← → navigates (syncs back to audience window)
- R resets timer, Esc closes popup
Technical:
- runtime.js builds presenter HTML dynamically (buildPresenterHTML)
- Collects all slide outerHTML + notes + all stylesheets from host page
- Injects into popup via document.write()
- BroadcastChannel keyed by pathname for multi-deck isolation
- base.css: removed all old inline .pv-* / .presenter-view styles
(presenter styles now self-contained in popup HTML)
Docs updated:
- SKILL.md: describes popup behavior, separate keyboard sections
- references/presenter-mode.md: new dual-window diagram, updated flow
- presenter-mode-reveal/README.md: updated S key and dual-screen guide
Runtime (assets/runtime.js):
- S key now opens a split-view presenter mode: current slide (left 55%) +
next-slide preview + large-font speaker script (150-300 words, scrollable)
+ elapsed timer + page counter
- Legacy bottom notes drawer moved to N key (keeps backward compat)
- R key resets timer (only active inside presenter view)
- Esc closes all overlays (overview / notes / presenter)
- Auto-picks up <aside class="notes">, <div class="notes">, or .speaker-notes
Styles (assets/base.css):
- New .presenter-view grid layout with pv-main / pv-side / pv-notes / pv-bar
- body.presenter-mode hides deck chrome from the operator's eye
- Timer styled with JetBrains Mono green (#3fb950)
New template (templates/full-decks/presenter-mode-reveal/):
- 6-slide demo deck designed around the S key presenter flow
- 5 presets via T key: tokyo-night, dracula, catppuccin-mocha, nord, corporate-clean
- Every slide ships a complete 150-300 word <aside class="notes"> example
written in the "3 rules of speaker script" style
- Scoped .tpl-presenter-mode-reveal CSS with rule-row, feature-row, code-block
- README explains the full authoring flow
Docs:
- New references/presenter-mode.md — when to use, 3 rules of \u9010\u5b57\u7a3f writing
(\u63d0\u793a\u4fe1\u53f7 / 150-300 \u5b57 / \u53e3\u8bed), required HTML structure, common mistakes,
standard AI prompt for generating speaker scripts
- SKILL.md: upgraded template count 14 -> 15, added Presenter Mode section
in 'When to use' with trigger keywords
- full-decks.md: row 15 added with \ud83c\udfa4 marker
- full-decks-index.html gallery now lists presenter-mode-reveal
- base.css: add .notes{display:none!important} so speaker notes are never
visible on the rendered slide (only in S overlay)
- SKILL.md: add NEVER-put-presenter-text rule in authoring rules
- authoring-guide.md: add same rule in What to NOT do section
Fixes issue where AI would put small descriptive/presenter-only text
directly on slide markup instead of inside <div class='notes'>.
- hero.gif (~2.4 MB): intro-deck cover page captured live showing the
3 preview strips all running (full decks / canvas FX / cycling layouts)
- layouts-live.gif (~570 KB): the 31-layout auto-rotator cycling through
kpi-grid -> chart-line -> timeline in real templates/single-page files
- README: hero GIF moved to the very top as the opening visual,
layouts-live.gif inserted below the 31-layout section next to layouts.png