Problem: pressing T in audience window only swapped the host page's
theme CSS link. Preview iframes in the presenter window stayed on the
theme that was active when the popup was opened (or the HTML default).
Fix: 3-hop theme propagation via message types
1. audience cycleTheme() → BroadcastChannel 'theme' message
2. presenter window receives BC msg → postMessage 'preview-theme'
to both iframes
3. iframe preview mode listens for 'preview-theme' → swaps its own
<link id='theme-link'> href
Also:
- Audience window listens for 'theme' BC events → applies theme
(so pressing T in one window cycles theme in BOTH)
- Presenter window captures audience's current theme at open time
(data-theme attr) and forwards it to each iframe on 'preview-ready'
so previews match audience from frame 1, even if audience had
already cycled theme before opening presenter
- preview mode auto-detects theme base path from existing
theme-link href (same logic as audience cycleTheme)
Verified in headless Chrome: BC msg {type:theme, name:dracula} →
both audience and preview iframes show data-theme=dracula with
matching colors.
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'>.