Skip to content

feat(player): <hyperframes-slideshow> web component + presenter#1590

Merged
vanceingalls merged 2 commits into
mainfrom
ss-player-b
Jun 19, 2026
Merged

feat(player): <hyperframes-slideshow> web component + presenter#1590
vanceingalls merged 2 commits into
mainfrom
ss-player-b

Conversation

@vanceingalls

Copy link
Copy Markdown
Collaborator

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?

  • Unit tests added/updated
  • Manual testing performed
  • Documentation updated (if applicable)

@miga-heygen miga-heygen left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-Policy that forbid unsafe-inline will break the hover interactions. The inline style attributes 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-used injectKeyframesOnce pattern) 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.
  • dropInvalidSlides as a pure function with immutable semantics — good seam for testing.
  • Presenter/audience split via BroadcastChannel is 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-motion media 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

miguel-heygen
miguel-heygen previously approved these changes Jun 19, 2026

@miguel-heygen miguel-heygen left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-only PlayerPort, instance-only SlideshowController.
  • No re-declaration of controller types.
  • dropInvalidSlides correctly lives in this PR (hyperframes-slideshow.ts), not leaked to the controller layer.
  • runtime-message-handler.ts does NOT import from slideshow/ — clean. Only the test file imports dropInvalidSlides from 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

vanceingalls added a commit that referenced this pull request Jun 19, 2026
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.
vanceingalls added a commit that referenced this pull request Jun 19, 2026
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>
Base automatically changed from ss-player-a to main June 19, 2026 08:34
@vanceingalls vanceingalls dismissed miguel-heygen’s stale review June 19, 2026 08:34

The base branch was changed.

@vanceingalls vanceingalls merged commit 075302c into main Jun 19, 2026
29 of 35 checks passed
@vanceingalls vanceingalls deleted the ss-player-b branch June 19, 2026 08:34
vanceingalls added a commit that referenced this pull request Jun 19, 2026
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>
vanceingalls added a commit that referenced this pull request Jun 19, 2026
* 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 miga-heygen left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

vanceingalls added a commit that referenced this pull request Jun 19, 2026
* 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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants