initFxIn/stopFxIn use root.querySelectorAll('[data-fx]'), which never
matches the root element itself — so data-fx placed directly on a
<section class="slide"> (the documented way to give a cover slide a
canvas background) was silently ignored: no canvas, no handle in
__hpxActive.
Add fxEls() that includes the root when it carries data-fx, and use it
in both init and stop so lifecycle stays symmetric.
Verified: a cover slide with data-fx="starfield" now mounts its canvas
on activation and stops on leave; descendant [data-fx] behavior unchanged.
Co-authored-by: Ynchnnn <17665010888@163.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
templates/single-page/cta.html hardcoded `color:#0b1024` on top of
`background:var(--accent)`. Under minimal-white the accent is #111216, so the
button's label rendered near-black on near-black: measured 1.01:1. The button
still looked like a button — correct shape, correct shadow, no text.
No literal ink can work here: accents across the 36 themes run from #ffffff
(blueprint) to #000000 (sharp-mono). So the ink becomes a token.
assets/base.css :root default --accent-ink
assets/themes/*.css all 36, each set to whichever of white /
near-black clears WCAG AA against its own accent
templates/full-decks/*/ the 6 decks that redefine --accent get their own
Five more sites had the same defect and are switched over with cta.html —
they hardcoded `color:#fff`, which fails in the opposite direction on the
light-accent themes:
single-page/process-steps.html .step .num
single-page/mindmap.html .mm .root
single-page/roadmap.html .col.now .tag
full-decks/course-module .mcq.correct .letter
full-decks/xhs-post .num-circle
themes/news-broadcast.css .kicker (same value, keeps the invariant)
Verified two independent ways.
Computed from source, every file that defines --accent must pair it with an
--accent-ink clearing 4.5:1 — 43 files (36 themes, 6 deck stylesheets,
base.css), 0 failures, tightest y2k-chrome at 4.54:1.
Measured in headless Chrome, reading computed color against computed
background rather than the stylesheet: 292 rendered elements, 0 failures.
That is 8 per theme across the four single-page layouts (2 .cta .btn,
4 .step .num, 1 .mm .root, 1 .rm .col.now .tag) x 36 themes, plus 4 in the two
decks that carry their own accent.
Three of the six components are normal text and must clear 4.5:1 rather than
the 3:1 large-text threshold — the 11px/400 roadmap tag, the 20px/600 mindmap
root (20px counts as large only at weight 700+), and the 13px/700 course-module
answer letter. The floor across all 292, at either threshold, is 4.54:1.
The :root fallback is #000000, not #ffffff. Against the default --accent
#3b6cff, white measures 4.40:1 — below AA for normal text, and tighter than
any of the 36 themes — while black measures 4.77:1. That fallback is what a
deck linking no theme gets, which is 8 of the 15 bundled full-decks; none of
them currently paints text on an accent fill, so this changes no rendered
pixel in the repo, but it makes the documented pattern safe to follow.
--accent itself is unchanged.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The deck is 810x1080, but that size lived only in style.css. runtime.js reads
the canvas from `data-w`/`data-h` on `.deck` and fell back to 1920x1080, so the
fit scale was min(vw/1920, vh/1080) — correct for a 16:9 deck, far too small
for a 3:4 one.
Measured rendered width of .deck (getBoundingClientRect):
window before after
810x1080 342px 42.2% 745px 91.9% (fills 100% of the height)
1080x1440 456px 42.2% 1015px 94.0%
1920x1080 745px 38.8% 745px 38.8% (unchanged: 3:4 in 16:9 is height-bound)
Declare the size once as `--deck-w`/`--deck-h` custom properties, which
base.css already consumes, and mirror it on `data-w`/`data-h` for the runtime.
A JS-less deck still gets an 810x1080 canvas, as before.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`@media print .slide{position:relative;…;page-break-after:always;height:100vh}`
is specificity (0,1,0). Every full-deck template scopes its rules as
`.tpl-name .slide` — (0,2,0) — so it won. Only `opacity` and `transform` on
that rule carried !important, so the three declarations that decide pagination
lost: the defence was per-property, not per-rule.
Measured, Chrome --print-to-pdf, pages counted from /Type /Page objects
(identical with and without --print-to-pdf-no-margins, so margins were never
the cause):
deck slides before after
dir-key-nav-minimal 8 1 8 .tpl-… .slide{position:absolute;inset:0}
xhs-post 9 2 9 …{position:absolute;inset:0;height:1080px}
presenter-mode-reveal 6 7 6 overflow on the last slide
other 12 decks + deck.html ok ok
Every layout declaration in the print reset is now !important. The one pair
left unflagged is `page-break-after`/`break-after`, so that
`.slide:last-child` can still opt out of the trailing blank page — no template
or theme stylesheet in the repo declares either property, so nothing competes
for them. Also adds `inset:auto` (a template's `inset:0` outlived
`position:relative`) and caps `.slide` at `max-height:100vh`.
Verified: all 15 bundled decks plus templates/deck.html produce exactly one
PDF page per slide, at default margins and at zero margins.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
runtime.js read speaker notes from `.notes, aside.notes, .speaker-notes` in
three places (preview-mode hiding, the N drawer, the presenter popup), but
base.css only hid `.notes`. Computed `display` for a `.speaker-notes` block:
audience view (what the room sees) block
?preview=1 (what the author sees) none
So the defect is invisible in the only view the author looks at while writing.
- base.css hides `.notes, .speaker-notes`
- the three selector lists in runtime.js collapse into one `NOTE_SEL`
constant, so the runtime's idea of "a note" can no longer drift from the
stylesheet's; both sides carry a comment pointing at the other
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Two independent defects, both of which hand the user a wrong artefact while
reporting success.
1. `render.sh deck.html 3 out/png` wrote nothing and still printed three ✔.
`mkdir -p "$OUT"` only ran in the branch that *derives* an output directory,
so an explicitly-passed directory was never created. Chrome exits 0 when it
cannot write the screenshot, and `render_one()`'s ✔ was unconditional — the
run ended with "done: rendered 3 slide(s)" and zero files on disk.
2. `render.sh deck.html all` under-rendered 13 of the 15 bundled decks.
`grep -c 'class="slide"'` is an exact-string match, but real slides carry
extra classes (`slide is-active`, `slide dark`, `slide t-violet`). Across
templates/full-decks that counted 95 of 119 slides; dir-key-nav-minimal
counted 0, fell through to the `COUNT=1` fallback, and exported a single PNG
for an 8-slide deck without a warning.
Changes:
- create the output directory on every path that has one (accepting either a
directory, as documented, or an explicit *.png file path)
- verify each screenshot exists and is non-empty before printing ✔; otherwise
print ✗ to stderr and exit non-zero
- count slides by matching `slide` as a whole class token on `<section>`,
which yields 119/119 and does not match `class="slide-number"`
- warn on stderr when the fallback count of 1 is used
- single-slide renders with an out-dir now write <out-dir>/<stem>.png instead
of treating the directory as the file name
- reject a non-numeric slide count instead of passing it to seq
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The check enumerated references by matching `assets/` in the path, so it only
ever saw the references that already looked like asset paths. Across the 15
bundled decks it verified 53 of 68 local references. The 15 it missed were one
`<link rel="stylesheet" href="style.css">` per deck — the reference that breaks
when a deck moves, which is the bug this check exists to catch (#19).
Enumerate by *reference*, not by path shape: every local href / src / url(),
minus absolute URLs, protocol-relative, data: and mailto:, then drop the empty
strings that a fragment-only reference leaves behind.
Blank lines are dropped in their own sed stage rather than as a `^$` branch of
the protocol grep's alternation. Under ugrep — which ships as `grep` on some
machines — `^$` inside an ERE alternation does not reliably match empty lines;
measured on a 5-empty-line input, 4 survived. A surviving empty string resolves
to the deck directory itself, which exists, so it was silently counted as a
verified reference: `url(#arrow)` in knowledge-arch-blueprint and `url(#arrow-r)`
in product-launch each inflated that deck's count by one. Protocol filtering was
unaffected.
Verified: scaffolding all 15 full-deck templates reports 68 references verified
(was 53), a default scaffold (no -t) still reports 5, and a deck whose style.css
link is renamed to a non-existent file is rejected with exit 1 — the old check
passed it.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Two inconsistencies, both of which sent a reader to a file that does not do
what the docs say:
1. Twelve occurrences across four files still said 14 full-deck templates,
while SKILL.md:29/:255, README.md:4 and the disk all said 15 —
SKILL.md:88/:280/:281, README.md:149/:224/:302/:318/:319,
montage-templates.html (title, headline, subtitle) and
full-decks-index.html's own cover headline.
2. SKILL.md's "pick a layout by how many images you have" table introduced
five layouts above it and then listed six rows. The sixth, image-grid.html,
contains no <img> at all — it is a gradient-placeholder wall. A reader
following the table would drop real photos into a layout with nowhere to
put them. Removed that row and named both placeholder layouts
(image-grid, image-hero) explicitly for what they are.
Verified on disk: 15 full-decks, 36 single-page layouts, 36 themes, and
image-grid.html / image-hero.html contain zero <img> elements while the five
layouts left in the table contain 1, 1, 1, 6 and 2 respectively.
README.zh-CN.md was already correct on both counts.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The .deck-logo element is position:absolute inside .deck. @media print sets
.deck{transform:none;height:auto} and stacks the slides, which removes the
transform-created containing block and leaves the element anchoring ONCE
against the whole stacked document. Measured on a 3-slide deck rendered with
--print-to-pdf: bottom-right printed only on the last page, and top-right
(the default) printed on no page at all.
Hide the element in print and paint the logo per slide instead — in print one
.slide is exactly one page. runtime.js mirrors the image URL and the corner
onto .deck as --logo-print / data-logo-print. The URL must be absolute
(el.src, not getAttribute('src')): a relative url() inside a custom property
resolves against the stylesheet that *uses* the var(), i.e. assets/base.css,
not against the deck.
Verified by rasterising the PDF and counting logo pixels per page: all four
corners now print on every page at the correct corner, and data-no-logo slides
are skipped in print as they are on screen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Presentations often need a company or product logo on every slide. Until now
that meant pasting an <img> into each .slide by hand — tedious, and easy to get
out of sync when slides are reordered or copied between decks.
Declare it once instead:
<body data-logo="logo.svg"
data-logo-position="bottom-right"
data-logo-size="40px">
- position: top-left / top-right / bottom-left / bottom-right (default top-right)
- size sets the height, width follows the aspect ratio
- data-logo-opacity / data-logo-alt for the rest
- per-slide opt-out with <section class="slide" data-no-logo> (covers usually
carry their own branding)
- --logo-inset-x / --logo-inset-y on .deck-logo to fine-tune the margin
Two paths into the same CSS: runtime.js creates the element from the attribute,
or the author writes <img class="deck-logo" data-pos="…"> inside .deck by hand.
The hand-written path needs no JS at all, since the styling lives in base.css.
The element sits inside .deck, so it scales with the deck. The logo is
initialised before the preview-mode branch on purpose — the presenter's
pixel-perfect preview is the audience view and must carry it too — and it is
deliberately NOT in the @media print hide list: a logo is the one piece of
chrome you do want on an exported PDF.
Verified with headless Chrome at 1920x1080, measuring rendered pixels:
- all four positions land in their own corner, zero red pixels in the other
three; data-logo-size="60px" renders exactly 60px tall
- data-no-logo slide: 0 logo pixels; neighbouring slides unaffected
- ?preview=N renders pixel-identical to the audience view (4766 px both)
- hand-authored <img class="deck-logo"> with no runtime.js: renders correctly
- deck with no data-logo: zero change
Before this, `grep -l '<img' templates/single-page/*.html` returned nothing:
image-hero and image-grid were gradient placeholders, so the skill had no
actual answer to "which layout shows 1 or more images".
Adds one framing primitive in base.css and five layouts built on it:
image-single one screenshot/diagram, letterboxed, never cropped
image-full-bleed one photo filling the slide, scrim keeps the title legible
image-text-split 50/50 image + argument, `flip` swaps sides
image-gallery 3-6 images, uniform grid, one caption each
image-compare before/after, both sides identical size
.img-frame owns the aspect ratio (--img-ratio) and the crop; the <img> just
fills it. That is what lets a user drop in a portrait, square or ultrawide
source without touching the layout. `.contain` letterboxes instead of cropping
for screenshots and diagrams, where a crop loses information.
Each layout takes its height from what is left after the title (flex:1;
min-height:0) rather than from a fixed aspect ratio, so it survives viewports
shorter than 16:9 instead of pushing captions off the page.
Demo artwork lives in assets/demo-images/ as hand-written SVG (~1 KB each,
~5 KB total, zero external references) so the layouts keep the offline
guarantee.
Verified with a DOM probe at window heights 1167/1080/900/733 (viewports
1080/993/813/646), measured after entry animations settle: all five report
broken=0 overflow=NONE at every height.
Phones and tablets have no arrow keys, so the deck was unnavigable on
them. Swipe left for the next slide, right for the previous one.
A gesture only counts as a swipe if it is single-finger, at least 50px,
more horizontal than vertical by 1.5x, and completed within 800ms.
Everything else falls through untouched — taps, vertical scrolling,
pinch-zoom (a second finger abandons the gesture), and anything inside
the overview grid, the notes drawer, or an author's own links, buttons
and form controls. `[data-no-swipe]` opts any element out.
All three listeners are passive and never call preventDefault, so the
browser stays free to scroll and zoom while we are still deciding.
No capability sniffing: `ontouchstart in window` and maxTouchPoints both
misreport on touchscreen laptops and some tablets, and a guard there
would silently remove the feature on exactly the devices that need it.
Touch listeners cost nothing on a device that never fires them.
Verified in headless Chrome with synthetic touch sequences dispatched on
the deck element: left advances, right goes back, and each of
too-short / mostly-vertical / too-slow / two-finger / started-on-overview
is correctly ignored. Identical results with and without Chrome's touch
emulation.
The READMEs only offered `npx skills add <url>`, which needs network on
the target machine. Document the three alternatives, in decreasing order
of network dependence:
1. `npx skills add ./html-ppt-skill` — point the CLI at a local copy
moved over by git/zip/USB.
2. Copy the folder into the agent's skills directory by hand — no Node,
no CLI. A skill is just a folder with SKILL.md at its root. Table of
project and global paths for Claude Code, Codex, Cursor, OpenCode,
Gemini CLI and Windsurf.
3. Skip the agent entirely — the templates are plain static files.
Also answers the question behind the question: whether it runs without a
network. It does. The only remote dependency is assets/fonts.css, which
@imports Google Fonts; offline those imports fail and the browser falls
back to the system stack already declared in assets/base.css.
Verified rather than assumed: a copy containing only SKILL.md, assets/,
templates/, references/ and scripts/, with assets/fonts.css emptied,
scaffolds and renders correctly in headless Chrome — same layout, system
typefaces.
English in README.md, Chinese in README.zh-CN.md, short pointer in
SKILL.md's Install section.
Reported as "S opens the presenter but there are no cards, only the hint
bar at the bottom". Reproduced: the four cards were positioned ONLY from
JavaScript. `.pcard` is `position:absolute` with no left/top/width/height
in CSS, and `applyLayout(readLayout())` was the LAST statement of the
presenter init — after every listener registration. So the cards' entire
visibility rode on that one line being reached, while `.hint-bar` is
`position:fixed` in CSS and always renders. Anything that stopped the
script produced precisely the reported screenshot.
At least three independent things do stop it, so this fixes the class
rather than one trigger:
1. **CSS default geometry.** `#card-cur/nxt/notes/timer` now carry the
same 2x2 arrangement in percentages. The presenter is laid out with
zero JavaScript; applyLayout() overrides with px when it runs.
2. **Layout is applied first,** right after the element lookups, instead
of after ~40 lines of wiring. Function declarations hoist.
3. **Saved layouts are sanitised.** readLayout() trusted localStorage
verbatim. A layout saved while presenting on a 4K display and
replayed on a laptop puts every card off-screen — and it is sticky,
so reopening never recovers. Coordinates are now clamped into the
viewport and malformed entries fall back to the default.
4. **`</script>` in speaker notes can no longer kill the init.** The
slide metadata is interpolated into an inline <script>; "<" is now
escaped to < in every embedded JSON value.
5. The remaining init is wrapped in try/catch that reports to the
console instead of blanking the cards.
6. defaultLayout() floors the window size, so a popup reporting 0x0
(opened minimised or backgrounded) no longer yields negative widths.
Verified by rendering the generated presenter document in headless
Chrome. Stale 4K layout replayed at 1280x820: before = empty except the
hint bar (matches the report), after = four cards clamped into view.
Script removed entirely: before = same empty state, after = full 2x2
layout. Literal </script> in notes: before = dead init, after = presenter
works and the notes render. Happy path unchanged at 1280x820 and 1024x700.
new-deck.sh hardcoded a single depth assumption. It forced the output
under the skill root (`$HERE/$PARENT/$NAME`) and rewrote the template's
`../assets/` to `../../assets/` unconditionally, which is only correct
when the deck lands exactly two levels down. `new-deck.sh my-talk .`
put index.html one level down and still emitted `../../assets/` — the
extra level in the report.
- Output dir: an absolute parent is honoured as-is, a relative one
resolves against the caller's cwd. Only the default stays
`<skill>/examples`.
- Asset prefix is computed: relpath() from the deck directory back to
the skill root, so any depth works, inside the tree or outside it.
- The rewrite now collapses any run of `../` before `assets/`, so it is
template-agnostic — deck.html (`../`), single-page (`../../`) and
full-decks (`../../../`) all land correctly.
- `-t/--template` accepts a full-deck name, a single-page layout name or
a path, so full-deck templates can be scaffolded instead of copied by
hand at the wrong depth (SKILL.md step 5 used to instruct exactly that).
- Self-check: every emitted assets/ reference must resolve before the
script reports success. It joins paths lexically, the way a browser
resolves them against a file:// URL — the shell's own `..` follows
symlinks (/tmp -> /private/tmp) and disagrees.
Docs: SKILL.md, references/authoring-guide.md and both READMEs no longer
teach a fixed `../` depth; they state that each template family's prefix
is relative to its own location and that the scaffold computes it.
Verified at depths 1/2/3, outside the skill tree, and for the base,
full-deck and single-page templates. An out-of-tree deck renders
pixel-identical (RMSE 0) to the in-tree one in headless Chrome.
The deck box was `width:100vw; height:100vh` while every type size,
gap and offset inside it is authored in absolute px against a
1920x1080 design. So the box reflowed with the window but its contents
did not: a 1512x850 browser window, the presenter preview iframe and
the headless 1920x1080 PNG each produced a different layout.
Render onto a fixed design canvas instead and scale it to fit:
- `.deck` is now `var(--deck-w, 1920px)` x `var(--deck-h, 1080px)` with
`transform: scale(var(--deck-scale, 1))`; `body` centres it and
letterboxes the remainder.
- `initCanvasFit()` reads `data-w` / `data-h` off the deck, computes
`min(innerWidth/w, innerHeight/h)` and re-fits on resize,
visualViewport resize and `document.fonts.ready`.
- `body[data-fit="fluid"]` opts a deck out and restores the old
100vw/100vh behaviour for decks authored fluid.
- `@media print` drops the transform so PDF export is unaffected.
Verified numerically: RMSE between a 1920x1080 render and a 1512x850
render of examples/demo-deck went from 14.29% to 1.98% (the remainder
is resampling noise). 5:4 (1280x1024) and mobile (390x844) letterbox
with no clipping.
README.md:
- Update subtitle to mention 15 full-decks (was 14) and presenter mode
- Add 中文文档 link pointing to README.zh-CN.md
- New '🎤 Presenter Mode (new!)' section near top with the 4-card screenshot
explaining magnetic cards, pixel-perfect iframe previews, no-flicker
postMessage navigation, and the 3 golden rules of 逐字稿
- Skills table now includes presenter mode as first row
- Full-decks list updated from 14 to 15 with presenter-mode-reveal entry
- Keyboard cheat sheet: S=presenter window, N=notes drawer, R=timer reset,
?preview=N URL param
README.zh-CN.md (new):
- Full Chinese translation parallel to README.md
- Emphasizes the presenter mode launch as the headline feature
- All 36 themes / 15 decks / 47 animations / 31 layouts described
- Same structure and visual assets as the English version
docs/readme/presenter-mode.png:
- 2536×1614 screenshot of the actual presenter window showing all 4 cards
(CURRENT/NEXT/SPEAKER SCRIPT/TIMER) in action with a real slide loaded
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
- Replace text-only title blocks with scaled-down clones of the actual slides in the overview grid (O key).
- Force a strict 16:9 aspect ratio (1920x1080 base) for the mini-slides to prevent layout breakage when the browser window is resized to non-standard proportions.
- Add a gradient overlay to ensure slide numbers and titles remain legible on top of the thumbnail content.
Made-with: Cursor
- 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