feat(player): <hyperframes-slideshow> web component + presenter#1590
Conversation
miga-heygen
left a comment
There was a problem hiding this comment.
PR Review: <hyperframes-slideshow> Web Component + Presenter
Solid piece of work — the custom element is well-structured, tests are thorough (1650 lines covering lifecycle, XSS, presenter mode, swipe, keyboard, form-control guards, epoch cancellation, phantom slide filtering), and the presenter/audience channel split via BroadcastChannel is clean. A few things worth discussing before merging:
1. innerHTML re-rendering on every state change
render() sets this.chrome.innerHTML = ... on every call, which tears down and rebuilds the entire chrome DOM tree + re-attaches all event listeners (hotspots, prev/next/mute buttons). For a slideshow deck with many hotspots or frequent onChange callbacks, this creates unnecessary GC pressure and can cause visual flicker.
Consider either:
- Targeted DOM updates (update counter text, toggle button visibility) for the hot path, full rebuild only when slide identity changes.
- Or at minimum, a dirty-check: skip
render()if the serialized state hasn't changed.
The 1-second setInterval in presenter mode also calls render() just to update the elapsed timer — that's a full innerHTML rebuild every second for a single text node change.
2. Inline styles via template strings — CSP and maintenance
All styling is inline (style="...") with hover effects via onmouseover/onmouseout inline handlers. This has two concerns:
- CSP: Sites with
Content-Security-Policythat forbidunsafe-inlinewill break the hover interactions. The inlinestyleattributes may also be blocked under strict CSP. - Maintenance: The style strings are 200+ chars each and duplicated across prev/next/mute buttons. Extracting to a shared
<style>block (or the already-usedinjectKeyframesOncepattern) would be cleaner.
3. Window-level event listeners
The component registers keydown and message listeners on window. If multiple <hyperframes-slideshow> elements exist on the same page, ALL of them will respond to the same keypress or postMessage. The onKey handler doesn't check if this element is the intended target — the last-connected element wins on prev()/next() calls from all of them.
Worth adding either:
- A guard that only the focused/active slideshow responds.
- Or documenting that only one
<hyperframes-slideshow>per page is supported.
4. extractScenes type assertion
In runtime-message-handler.ts:
return (raw as SceneRecord[]).filter(
(s) => typeof s.id === "string" && typeof s.start === "number" && typeof s.duration === "number",
);The as SceneRecord[] cast happens before the filter, so if raw contains non-object items (e.g. [null, 42, "string"]), accessing .id, .start, .duration on them will silently produce undefined comparisons rather than throwing. This works by accident (the typeof checks catch it), but a more defensive approach would be:
return (raw as unknown[]).filter(
(s): s is SceneRecord =>
s != null && typeof s === "object" &&
typeof (s as SceneRecord).id === "string" && ...
);5. BroadcastChannel — no channel name namespacing
The channel name is hardcoded to "hf-slideshow". If two different slideshow decks are open in different tabs, the audience window of deck A could receive presenter commands from deck B. Consider including a deck/session identifier in the channel name (e.g. hf-slideshow:${deckId}).
6. dropInvalidSlides — sequences type reconstruction
const sequences: ResolvedSlideshow["sequences"] = {};
for (const [id, seq] of Object.entries(show.sequences)) {
sequences[id] = { ...seq, slides: seq.slides.filter(validSlide) };
}This spreads seq but only overrides slides. If ResolvedSlideshow["sequences"] has nested mutable state, this is a shallow copy — mutations to the returned object's inner fields would affect the original. The test verifies the top-level slides array isn't mutated, but not the sequences. Minor, but worth noting since the function's docstring says "does not mutate the input."
7. present() URL construction
const sep = location.search ? "&" : "?";
window.open(location.href + sep + "mode=audience", "_blank");If the current URL already has mode=audience (e.g. someone clicks present on an audience window), this appends a second mode=audience param. A URLSearchParams-based approach would be more robust.
8. // fallow-ignore-next-line complexity
There are several fallow-ignore directives. Are these from a linter? If the complexity warnings are firing, it might be worth considering whether the flagged methods (init, render, onKey, onMessage, handleRuntimeMessage) can be decomposed. The render() method in particular is ~110 lines of mixed logic + template strings.
9. Mute toggle accessibility
The mute button re-renders via full innerHTML rebuild, which means screen readers lose focus tracking when the button is clicked. After clicking mute, the button element is destroyed and recreated — focus moves to the document body. Consider preserving focus on the mute button after toggle, or using setAttribute to update aria-pressed in place rather than rebuilding.
10. Missing observedAttributes / attributeChangedCallback
The component reads mode and sound attributes but doesn't declare static get observedAttributes(). This means if these attributes are changed dynamically after initial render (e.g. el.setAttribute("sound", "")), the component won't react. Currently this works because __setControllerForTest triggers a re-render, but in production use, setting sound after mount would have no visible effect until the next slide change.
What's good
- XSS escaping (
escHtml) on all user-supplied strings in innerHTML — tested with injection payloads. - Epoch generation counter to cancel stale async init — solves the disconnect-during-waitForScenes race cleanly.
dropInvalidSlidesas a pure function with immutable semantics — good seam for testing.- Presenter/audience split via
BroadcastChannelis a pragmatic V1 approach. - Touch gesture filtering (dominant-axis check) prevents accidental navigation during vertical scroll.
- Form-control guard on keyboard navigation prevents arrow keys from hijacking input fields.
prefers-reduced-motionmedia query for the hotspot pulse animation.- The test coverage is genuinely thorough — lifecycle, XSS, race conditions, form controls, presenter mode, phantom slides.
Review by Miga
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Review — feat(player): <hyperframes-slideshow> web component + presenter
Reviewed at HEAD 86eea2ba18d16c7e90fbeb8e052d647601e0a32f. Verdict: clean component layer, layered findings on top of Miga.
Part B of the 2-PR split of #1581. Reviewed alongside #1589 (controller) at Vance's request.
Split verification — coverage vs #1581
This PR adds the DOM + presenter layer on top of #1589's SlideshowController. Per-file addition counts match #1581 exactly:
| File | #1590 adds | #1581 adds | Δ |
|---|---|---|---|
slideshow/hyperframes-slideshow.ts |
546 | 546 | 0 |
slideshow/hyperframes-slideshow.test.ts |
1484 | 1484 | 0 |
slideshow/slideshowPresenter.ts |
93 | 93 | 0 |
hyperframes-player.ts |
9 | 9 | 0 |
runtime-message-handler.ts |
12 | 12 | 0 |
runtime-message-handler.test.ts |
1 | 1 | 0 |
tsup.config.ts |
1 | 1 | 0 |
Combined with #1589, the split exactly reproduces #1581 (3247 LOC). Non-lossy.
Boundary check
Imports from #1589 are tight:
import { SlideshowController, type PlayerPort } from "./SlideshowController"— type-onlyPlayerPort, instance-onlySlideshowController.- No re-declaration of controller types.
dropInvalidSlidescorrectly lives in this PR (hyperframes-slideshow.ts), not leaked to the controller layer.runtime-message-handler.tsdoes NOT import fromslideshow/— clean. Only the test file importsdropInvalidSlidesfrom the slideshow path, which is fine for happy-dom integration tests.
Findings (layered on Miga's pass)
Miga (review at 07:28Z) covered: innerHTML rebuild perf, CSP/inline-handler concern, window-level keydown multi-instance hazard, extractScenes cast-before-filter, BroadcastChannel namespace, dropInvalidSlides shallow-copy semantics, present() URL building, mute toggle focus loss, missing observedAttributes. Adding:
1. tsup.config.ts now bundles slideshow/hyperframes-slideshow.ts — this fixes the export hazard from #1589. Note in #1589's review: package.json adds a ./slideshow export pointing at ./dist/slideshow/… that doesn't get built until this PR's tsup change. Cumulative-diff is fine; the per-PR window is the problem. If you can hoist the package.json exports change here (and out of #1589), the split becomes self-consistent at every revision. Worth coordinating.
2. MessageHandlerCallbacks.setScenes is required, not optional. runtime-message-handler.ts adds setScenes: (scenes: SceneRecord[]) => void (no ?) to the callbacks interface. Any existing consumer of handleRuntimeMessage outside this file/PR who didn't supply setScenes will fail typecheck. Grep the repo / app for other callers — if there are none, ignore; if there are, either default-implement here or make it optional with ? so existing call sites don't have to touch.
3. ControllerLike interface has too many ? optional methods. Miga noticed this. To name them concretely: goToSlide?, enterBranch?, back?, backToMain?, dispose?. The component then null-checks every site with ?.(). Since the real SlideshowController (from #1589) implements all of these unconditionally, the optional shape is purely for test stubs. Cleaner: require them and provide a NoopController (3-line helper) for test stubs. Eliminates ~6-8 ?.() call sites and the silent-no-op risk.
4. Window-level listener concern (Miga's #3) compounds with shadowDOM absence. The component doesn't use Shadow DOM, so light-DOM styles can leak in/out AND the window keydown listener captures globally. If you ever embed two <hyperframes-slideshow> elements on one page (a docs-site comparison view, or a teacher-monitoring layout), both respond to every arrow key. A currentActive/document.activeElement.closest guard or moving the listener to this.addEventListener('keydown', …) (with tabIndex=0 already set) would scope it correctly.
5. present() security: child window inherits noopener? window.open(location.href + sep + "mode=audience", "_blank") — no noopener,noreferrer rel features. The audience window gets a reference back via window.opener, and any third-party JS on the deck URL could navigate the presenter window via that reference. Pass "noopener,noreferrer" as the third arg, or windowFeatures = "noopener". Cheap.
6. BroadcastChannel name "hf-slideshow" (Miga's #5) — concrete fix. Namespacing by deck identity is the standard pattern. Suggest:
const channelName = `hf-slideshow:${this.id || this.dataset.deckId || "default"}`;This way two decks open in separate tabs don't cross-talk. Trivial change, prevents the "presenter for deck A drives audience of deck B" footgun.
7. extractScenes: extra failure mode beyond Miga's call-out. The function silently drops the entire list if raw isn't an array, but doesn't log/warn. If a manifest evolves to send {scenes: {…}} (object-shape) instead of an array, scenes go silently empty and the slideshow has nothing to drive. Add a one-line console.warn or expose via the callbacks so the host can detect malformed-manifest cases.
Test coverage spot-check
hyperframes-slideshow.test.ts is 1484 LOC. Lifecycle, XSS, phantom-slide filter, presenter sync, mute, keyboard, swipe, form-control guard. Genuinely thorough. The __setControllerForTest seam is pragmatic — better than fully exposing controller as public.
Review by Rames D Jusso
Addresses review feedback on the split stack: - CSP: replace the 8 inline onmouseover/onmouseout handlers on the nav buttons with a [data-hf-nav-cluster] button:hover CSS rule (injected once per document). No inline event handlers → works under strict CSP. - IDs: studio sequence/hotspot id generation used Date.now() (sub-ms collision on rapid clicks) — now crypto.randomUUID(). - Versioning: stamp version on the persisted manifest island (preserving an existing one); add the optional version field + SLIDESHOW_MANIFEST_VERSION to the core schema so future schema changes can migrate older islands. These live on the review-fixes tip (consistent with the stack's fixup-on-tip model); the touched code belongs to ss-player-b (#1590), ss-studio-a/b (#1591/#1592), and ss-core (#1580). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stack-split from the original ss-player PR (#1581): the SlideshowController state machine (stack-based slide/fragment/branch navigation) and its tests. The <hyperframes-slideshow> web component follows in the next PR.
Stack-split from the original ss-player PR (#1581): the <hyperframes-slideshow> custom element (wraps <hyperframes-player>, drives the controller), presenter/audience BroadcastChannel sync, nav chrome, and the player scenes hook.
Addresses review feedback on the split stack: - CSP: replace the 8 inline onmouseover/onmouseout handlers on the nav buttons with a [data-hf-nav-cluster] button:hover CSS rule (injected once per document). No inline event handlers → works under strict CSP. - IDs: studio sequence/hotspot id generation used Date.now() (sub-ms collision on rapid clicks) — now crypto.randomUUID(). - Versioning: stamp version on the persisted manifest island (preserving an existing one); add the optional version field + SLIDESHOW_MANIFEST_VERSION to the core schema so future schema changes can migrate older islands. These live on the review-fixes tip (consistent with the stack's fixup-on-tip model); the touched code belongs to ss-player-b (#1590), ss-studio-a/b (#1591/#1592), and ss-core (#1580). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
86eea2b to
b2acf34
Compare
aa9036b to
3f881c8
Compare
The base branch was changed.
Addresses review feedback on the split stack: - CSP: replace the 8 inline onmouseover/onmouseout handlers on the nav buttons with a [data-hf-nav-cluster] button:hover CSS rule (injected once per document). No inline event handlers → works under strict CSP. - IDs: studio sequence/hotspot id generation used Date.now() (sub-ms collision on rapid clicks) — now crypto.randomUUID(). - Versioning: stamp version on the persisted manifest island (preserving an existing one); add the optional version field + SLIDESHOW_MANIFEST_VERSION to the core schema so future schema changes can migrate older islands. These live on the review-fixes tip (consistent with the stack's fixup-on-tip model); the touched code belongs to ss-player-b (#1590), ss-studio-a/b (#1591/#1592), and ss-core (#1580). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(slideshow): address code-review findings #1580-1584 - player: bundle @hyperframes/core into the IIFE/global build (noExternal) - player: resolve audience mode from ?mode=audience URL query, not just attr - player: event-driven waitForScenes + loud failure when no slides resolve - player: scope window keydown so Space/Backspace don't hijack the host page - player: audience mirrors full position (branch + fragment) via syncTo - player: next() reveals remaining fragments even at slide end; enterBranch ignores empty sequences - core: harden extractScenes against null/non-object scene entries - core: strict manifest validation; error on inverted ranges & empty hotspot targets; dedup fragments - core/lint: accept data-end/timeline-derived scene durations (match runtime) - core+studio: share ISLAND_TYPE + island regex from @hyperframes/core/slideshow - studio: SlideList reflects manifest slide order; branch-slide authoring (notes/fragments/hotspots) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(player): slideshow fullscreen + presenter-view rework - fullscreen toggle in the nav chrome (button + 'F' key); standard Fullscreen API on the <hyperframes-slideshow> element, icon reflects state - presenter console: live slide on top, speaker-notes panel below, with the nav controls shown in-view; Present button hides once presenting (harness) - audience (viewer) window: chrome reduced to a fullscreen-only control, no nav - fix: audience / back() / backToMain() mirror stayed frozen on the first frame — a bare paused seek does not repaint some compositions. resumeSlide now plays a brief render-nudge (RENDER_NUDGE) past the target so the composition paints, then onTime pauses at the hold - refactor: extract reusable buildNavCluster() + wireChromeButtons(); rework buildPresenterLayout into the bottom notes panel - example: airbnb-deck presenter-test.html harness (Present button + 'F') Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(player): slideshow no auto-progress + presenter slide fits/pins - navigation jumps to a static frame instead of auto-playing the timeline: playTo() seeks to the hold (+ a brief RENDER_NUDGE to repaint) rather than sustaining playback, so slides hold until the user advances - presenter view: pin the live slide to the top and confine the player to the region above the notes panel, so the player CONTAINS the composition — the full slide stays visible (letterboxed) at any width and re-fits on resize; its bottom is no longer cut off by the notes panel - tests: seek targets updated for the render-nudge offset Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(slideshow): presenter nav flash, slide-1 boundary, branch buttons Three presenter-mode fixes from testing the airbnb deck: (1) navigation flash — seek to the exact target then play forward to repaint, instead of seeking backward (t-0.2) which painted the previous scene at boundaries; split hold into holdTarget (logical) and holdAt (target+nudge, clamped to slide.end). (2) slide-1 boundary — no-fragment slides rest at the slide midpoint, not slide.end. (3) presenter branch buttons — surface hotspots as buttons in the presenter console (the on-slide pill is lost in the letterboxed view). Also extract paintChrome() to dedupe the three chrome-render sites. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(slideshow): stop presenter nav buttons flickering / dropping clicks The presenter elapsed clock called render() every second, which rebuilt the entire chrome (innerHTML) including the nav buttons — they flickered and any click landing mid-rebuild was lost. The 1s tick now updates only the elapsed text node; the nav buttons are rebuilt only on actual navigation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(slideshow): CSP-safe nav hover, UUID editor ids, manifest version Addresses review feedback on the split stack: - CSP: replace the 8 inline onmouseover/onmouseout handlers on the nav buttons with a [data-hf-nav-cluster] button:hover CSS rule (injected once per document). No inline event handlers → works under strict CSP. - IDs: studio sequence/hotspot id generation used Date.now() (sub-ms collision on rapid clicks) — now crypto.randomUUID(). - Versioning: stamp version on the persisted manifest island (preserving an existing one); add the optional version field + SLIDESHOW_MANIFEST_VERSION to the core schema so future schema changes can migrate older islands. These live on the review-fixes tip (consistent with the stack's fixup-on-tip model); the touched code belongs to ss-player-b (#1590), ss-studio-a/b (#1591/#1592), and ss-core (#1580). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(ci): fix format + fallow gates for slideshow stack - .prettierignore: exclude generated demo compositions (registry/examples/**/*.html) from oxfmt — large video-pipeline output (GSAP/Three/WebGL), not hand-authored source. Was failing 'Format' repo-wide (pre-existing on main via #1584). - .fallowrc: exempt SlideshowPanel.tsx (health/complexity — section fan-out) and the slideshowPanelHelpers.ts / SlideshowPanel.test.ts parallel-structure clones (duplicates.ignore). File-level config, not inline comments — inline shifts line numbers and breaks fallow's inherited-finding fingerprint (per existing rc note). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(slideshow): address PR review + CodeQL findings - CodeQL #638 (parseSlideshow): complete the regex metachar escape in slideshowIslandRegex (was missing backslash); add JSDoc on the factory + lastIndex caveat (reviewer 5a/16). - CodeQL #639/#640 + review items 13/17: remove registry/examples/airbnb-deck/ presenter-test.html — a generated test harness (postMessage w/o origin check, proto-pollution) that was scope-creep into a fix PR and a 3rd duplicate island. Regenerate locally via the scratchpad script when testing. - Review item 15 (docs drift in skills/slideshow/SKILL.md): lint resolves scenes by data-composition-id only (not .clip[id]); fragments are valid INCLUSIVE of [start,end], not 'strictly inside'. IIFE bundles core confirmed (0 external @hyperframes/core refs in the slideshow global build). format/lint/fallow green. * feat(cli): add 'present' command — serve a deck in presenter mode hyperframes present [dir] starts a lightweight HTTP server, wraps the composition in <hyperframes-slideshow> with its island inlined, and opens the browser. A real HTTP origin is required for presenter mode: present() opens the audience window via window.open(?mode=audience) and the two sync over BroadcastChannel — neither works from file://. - New utils/compositionServer.ts factors the server scaffolding shared with 'play' (resolve runtime/player/slideshow bundles, inject runtime, asset content-types, bind to a free port); play.ts now uses it too. - Errors clearly if the deck has no slideshow island. - .fallowrc: exempt the play/present command entrypoints (validation + server wiring) and the per-command startup/logging block from the complexity / duplication gates. Verified end-to-end against registry/examples/airbnb-deck: server serves the wrapper + assets, the component binds and renders (counter 1 / 11). * fix(cli): present renders the deck (player sizing + self-driving serve) Two bugs caused a black slide area: - The <hyperframes-player> had no positioning, so its iframe collapsed to zero size — the (absolutely-positioned) chrome showed but the composition didn't. Add position:absolute; inset:0 (matches demo.html). - The composition was served with the engine runtime injected, which leaves its timelines engine-paused (blank). Slideshow decks self-drive their own timelines (like demo.html / the standalone harness), so serve them raw. Verified end-to-end on registry/examples/airbnb-deck: cover renders, Next advances 1/11 -> 2/11 and slide 2 paints. * fix(cli): present plays slideshow sound effects The composition (in the player's sandboxed iframe) posts { type: 'hf-sfx', name } to the parent on nav, but the iframe is autoplay-blocked — audio must play in the parent that owns the user gesture. Add the parent-side hf-sfx handler (the 4 standard clips advance/fragment/ branch-enter/back, served from the deck's sfx/ under /composition/sfx/), gesture-unlocked and mute-aware, in both presenter and audience windows. Verified: sfx serve 200 (audio/mpeg) and Next delivers [advance, fragment] to the parent handler. * feat(examples): softer mellow slideshow sfx for airbnb-deck Replace the aggressive percussive pops with gentle sine-tone cues (warm pitches C5/G4/E5/F4, 12ms attack + exponential decay, lowpassed) — advance/ fragment/branch-enter/back. Much lighter; fragment is the most subtle. * feat(examples): whoosh + sparkle slideshow sfx for airbnb-deck Replace the sine-tone cues with airy, designed sounds: - advance: a soft whoosh (band-limited pink noise, bell-shaped swell) - back: that whoosh reversed and darkened - fragment: a light sparkle (staggered high chime blips) - branch-enter: whoosh + a trailing sparkle (magical entry) * feat(examples): directional whoosh + richer branch-enter cue (airbnb-deck) - Going backward a slide now plays the reverse whoosh (back), not advance — the sfx logic detects nav direction by scene order instead of firing advance for every scene change. - branch-enter is now a more interesting magical cue: a faint whoosh + an ascending C5-E5-G5-C6 chime arpeggio + a trailing sparkle. Verified: next then prev fires [advance, fragment, back]; no page errors. * fix(cli): harden present sfx handler + mute-hover affordance (R2 review) Addresses Rames R2 items 19-21: - 20: the present audio handler reintroduced the CodeQL classes removed with presenter-test.html — add an origin check (same-origin composition iframe) and an own-property guard so a 'name' like __proto__ can't resolve to and mutate Object.prototype. - 21: assetContentType used a bare index lookup (ext='__proto__' -> prototype); guard with Object.hasOwn. - 19: the CSP hover rule erased the speaker button's muted color; add a higher-specificity [data-hf-muted] [data-hf-mute]:hover override. Verified: hf-sfx origin matches location.origin (guard passes), advance/fragment still fire, deck renders + advances. Items 14/18/22 deferred (minor, pre-existing). * fix(slideshow): address remaining R2 items (14/18/22) + re-remove harness - 14: resumeSlide now mirrors enterSlide — a no-fragment slide resumes at its midpoint (visible-at-rest), not frame-0; fragmented slides still resume to the saved fragment or slide.start. Added a dedicated test naming the heuristic. - 18: fullscreenchange swaps only the fullscreen glyph + aria (hoisted SVGs to module consts) instead of re-rendering the whole chrome. - 22: .prettierignore lists the specific generated demo compositions instead of blanket registry/examples/**/*.html, so hand-authored example HTML still formats. - presenter-test.html: a stray 54a4460 git add -A had re-added the deleted harness (reviving CodeQL #639/#640); remove it again. 106 slideshow tests pass; tsc/lint/fallow/format clean; deck still renders. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
miga-heygen
left a comment
There was a problem hiding this comment.
Re-review — checking prior findings against current HEAD
HEAD is b2acf349d339 — no new commits since the initial reviews (my pass + Rames'). None of the findings have been addressed yet; the code is unchanged. Tracking status below for clarity.
Still open
1. innerHTML full rebuild on every render() — this.chrome.innerHTML = hotspotsHtml + navClusterHtml still runs on every call. The 1-second setInterval in presenter mode still rebuilds the entire chrome DOM just to update the elapsed timer text node. This tears down and re-creates all event listeners, causes GC pressure, and breaks accessibility focus tracking (screen readers lose position when the mute button they just clicked is destroyed and recreated).
2. Inline onmouseover/onmouseout handlers — All nav buttons (prev, next, mute) still use inline event handler attributes for hover effects. Blocked by Content-Security-Policy without 'unsafe-inline'. The injectKeyframesOnce pattern already exists for shared styles — the hover states could move into that same injected <style> block using :hover selectors on [data-hf-prev], [data-hf-next], [data-hf-mute].
3. Window-level keydown/message listeners — multi-instance conflict — Listeners are registered on window with no guard for which element is the intended target. Two <hyperframes-slideshow> elements on the same page will both respond to every arrow key and every postMessage. The onKey handler could check document.activeElement?.closest('hyperframes-slideshow') === this or move the listener to this (with tabIndex=0 already set).
4. BroadcastChannel name "hf-slideshow" unhardened — Still hardcoded in slideshowPresenter.ts. Two different decks open in different tabs will cross-talk (presenter of deck A drives audience of deck B). Namespacing by deck identity (e.g. hf-slideshow:${this.id || this.dataset.deckId || 'default'}) would prevent this.
5. observedAttributes not declared — The component reads mode and sound attributes but does not declare static get observedAttributes() or implement attributeChangedCallback. Dynamic attribute changes after initial render (e.g. el.setAttribute('sound', '')) won't trigger re-renders until the next controller-driven render() call.
6. present() lacks noopener,noreferrer (Rames' finding) — window.open(location.href + sep + "mode=audience", "_blank") still has no third argument. The audience window gets window.opener reference, allowing any third-party script on the deck URL to navigate the presenter window. One-line fix: pass "noopener,noreferrer" as the third arg.
Severity assessment
Items 2, 3, 6 are the most actionable — small, targeted fixes with clear security or correctness impact. Items 1 and 5 are quality/robustness concerns that could ship as follow-ups. Item 4 is a V1 limitation worth documenting even if not fixed now.
No new issues found in this pass beyond what was already flagged.
Re-review by Miga
* fix(slideshow): address split-PR review findings on #1585 Genuinely-open findings from the #1580/#1590/#1591/#1592 reviews (the rest were already fixed on this branch: CSP handlers, manifest version, UUID ids, float keys, presenter 1s-timer): core (#1580): - isManifest rejects a non-object/array manifest (e.g. [42,null]) explicitly - resolveSlideshow flags duplicate slideSequence ids instead of silent overwrite player (#1590): - present() window.open uses noopener,noreferrer (audience syncs via channel) - BroadcastChannel name is per-deck (keyed on pathname) to avoid same-origin cross-talk between decks - add observedAttributes + attributeChangedCallback so runtime sound/mode toggles re-render studio (#1591/#1592): - persistSlideshowManifest no-op gate (skip write when HTML is unchanged) - surface persist failures (console.error) instead of silent .catch(()=>{}) - confirm before deleting a branch sequence (data-loss + dangling hotspots) + tests for the collision + non-object-manifest rejection. 20 core / 106 player / 53 studio pass; tsc/lint/fmt/fallow clean; deck still renders. * fix(slideshow): finish remaining split-PR review findings The larger items from the #1580/#1590/#1591/#1592 reviews (the rest landed in #1585): core (#1580): - dedup isSceneLikeCompositionId — shared slideshow/sceneId.ts, used by both the lint rule and the runtime scene-window computation (no more mirror-and-drift) player (#1590 / #1592): - onKey: when multiple decks share a page, drop the unfocused-convenience so a key drives only the focused deck - slow-iframe recovery: if the scene timeline posts after the wait times out (empty scenes), re-init once so sceneId slides resolve instead of being dropped studio (#1591): - persistSlideshowManifest validates the built island round-trips before writing - reorderBranchSlide helper + BranchTree up/down controls (parallel to main-line reorder), with a branch-position indicator + tests for reorderBranchSlide. core 228 / player 106 / studio (panel) 46 pass; tsc/lint/fmt/fallow clean. * fix(player,cli): use fileURLToPath for path resolution (Windows CI) new URL(...).pathname yields a leading-slash drive path ("/D:/...") on Windows, which broke: - packages/player/vitest.config.ts — the @hyperframes/core/slideshow alias resolved to a nonexistent path, failing the player slideshow tests on the Windows render-verification CI (passed on macOS/Linux where pathname is clean) - packages/cli/src/utils/compositionServer.ts helperDir — same bug in the play/present bundle-path resolution fileURLToPath converts file:// URLs to correct OS paths on all platforms. Player slideshow tests pass; present serves + resolves bundles. * fix(producer): fileURLToPath for the renders dir (Windows) DEFAULT_RENDERS_DIR used new URL(import.meta.url).pathname, which is "/D:/..." on Windows and resolves to a bogus path — affects the Windows render pipeline. Last of the .pathname -> fileURLToPath fixes (repo-wide src sweep now clean).

What
Brief description of the change.
Why
Why is this change needed?
How
How was this implemented? Any notable design decisions?
Test plan
How was this tested?