Skip to content

feat(studio): slideshow branching editor panel UI#1592

Merged
vanceingalls merged 4 commits into
mainfrom
ss-studio-b
Jun 19, 2026
Merged

feat(studio): slideshow branching editor panel UI#1592
vanceingalls merged 4 commits into
mainfrom
ss-studio-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.

Slideshow Branching Editor Panel — Review

Overall this is a solid, well-structured PR. The architecture is clean: pure manifest transforms tested independently, a well-designed notes debounce controller, and presentational sub-panels that receive callbacks. A few things to address before merging.


Issues to address

1. Duplicate import block in SlideshowPanel.tsx

The slideshowPanelHelpers functions are imported twice — once via export { ... } from (lines 18–30) and again via a regular import { ... } from (lines 56–67, after safeParseManifest). The file will work because bundlers deduplicate, but it's confusing to read and suggests a copy-paste accident. Remove the second import block and keep only the re-export block (the named imports are already in scope from the export { ... } from syntax).

2. slideshowScenes useMemo dep array suppresses exhaustive-deps lint

// eslint-disable-next-line react-hooks/exhaustive-deps
}, [previewIframeRef, rightPanelTab, refreshKey]);

The disable comment hides a real concern: previewIframeRef is a ref object whose identity never changes, so listing it does nothing — meanwhile the actual trigger for re-deriving scenes is the iframe's contentWindow.__clipManifest changing, which none of these deps track. The refreshKey dependency is doing the heavy lifting here, but the comment makes it look accidental. Consider either:

  • Removing previewIframeRef from the dep array (it's stable) and adding a brief inline comment explaining that refreshKey is the proxy signal for manifest changes, OR
  • Replacing the lint suppression with a targeted // refreshKey is the proxy for iframe content changes explanation.

3. No confirmation on destructive branch operations

deleteSequence removes the branch AND strips all hotspots targeting it — a potentially significant data loss. There's no confirmation dialog. For a v1 this might be acceptable, but consider at minimum a window.confirm or noting this as a follow-up.

4. Date.now() for sequence/hotspot IDs

const id = `seq-${Date.now()}`;
// and
const id = `hotspot-${elementKey}-${Date.now()}`;

Two rapid clicks within the same millisecond produce duplicate IDs. crypto.randomUUID() or a counter would be more robust. Low risk in practice (UI debounce makes sub-ms clicks unlikely), but worth a defensive fix.

5. Fragment key={t} uses floating point values as keys

{fragments.map((t) => (
  <span key={t} ...>

If two fragments have the same time value (shouldn't happen given dedup, but defensively), React gets confused. A stable index or ${t} string would be equivalent but more explicit.


Observations (non-blocking)

Architecture is strong. The makeSlideshowNotesController is a genuinely good pattern — extracting the debounce + flush-attribution logic into a pure, testable controller that the component just wires up. The stale-closure tests (typing in comp A, switching to comp B, flushing to A) prove a subtle invariant that would otherwise be a bug magnet. Well done.

Test coverage is thorough. 460 lines of pure-logic tests covering every manifest transform and the notes controller's concurrency invariants. The tests don't need React rendering, which keeps them fast and focused.

Accessibility is decent. aria-expanded, aria-pressed, aria-label on interactive elements, keyboard handlers on role="button" divs. The SectionHeader correctly uses a <button> with aria-expanded. The slide list items handle Enter/Space keyboard events. A few gaps:

  • The BranchItem rename span has role="button" + tabIndex={0} + keyboard handler — good.
  • The SlideList scene rows use role="button" with aria-pressed — technically aria-pressed is for toggle buttons. Since clicking selects (not toggles), aria-selected within a role="listbox" would be more semantically correct. Non-blocking.

Styling is consistent. Uses the same text-[11px], bg-neutral-800, border-neutral-700, text-neutral-400/500 tokens as the existing studio panels. The studio-accent color variable for active states matches the design system.

Performance is fine for v1. The slideshowScenes useMemo and the scenes.map() in BranchTree are O(n) where n = scene count — negligible for typical slideshow sizes (tens of scenes, not thousands). The slides.some() lookup inside each scene row is O(n*m) but again trivially small. No concerns at realistic scale.

The compHtml effect has a subtle ordering concern — it calls flush() synchronously then sets state. The flush fires persist(manifest) which is async, so in theory the new compHtml's parse could race with the old comp's persist callback. The useSlideshowPersist hook (from a prior PR in the stack) presumably handles this via the save queue's coalesce key, so it's fine in practice, but worth a comment.


Summary

Good PR. The duplicate import is a definite cleanup; the Date.now() ID generation is a minor robustness gap. Everything else is solid. I'd approve after the duplicate import is removed.

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.

HEAD reviewed: 3f9554059cfeb40399e8363f856b5da7b84a1138

Verdict: Clean upper-half of the #1582 split — UI is well-decomposed into SubPanels with proper a11y on most interactions. Layering Miga's review with one correction and a few additional findings. Posted in full on the monolith (#1582) and persistence (#1591) too so you have the cross-cut.

Split verification — coverage of #1582

Same byte-identical split confirmation as #1591#1591 (488 LOC, 6 files: persistence + helpers + studioHelpers tab type) + #1592 (1448 LOC, 5 files: App + StudioRightPanel + SlideshowPanel + SlideshowSubPanels + SlideshowPanel.test) = #1582 (1936 LOC, 11 files) exactly. Per-file diff hashes match. No file in both halves; no file missing. Mechanical, non-lossy.

Blockers

None.

Correction to Miga's finding #1 ("duplicate import block")

Miga says the import { ... } from "./slideshowPanelHelpers" at SlideshowPanel.tsx:56-67 is a copy-paste accident and should be removed because the export { ... } from "./slideshowPanelHelpers" re-export at lines 32-44 already brings the names in scope. It does not — please do NOT remove the local import.

export { X } from "./Y" is a re-export only. It forwards X to consumers of this module but does NOT introduce X as a local binding inside the module itself. The helpers are used as values 11 times locally: toggleMainLineSlide/reorderMainLineSlide/... at SlideshowPanel.tsx:263, 270, 278, 285, 291, 299, 306, 313, 320, 329, 336. Removing the local import would break the file at runtime with ReferenceError: toggleMainLineSlide is not defined. (TS would also error in strict mode; bundlers don't dedupe local references to non-existent bindings.)

The re-export exists so SlideshowPanel.test.ts can do import { ... } from "./SlideshowPanel" and have one import for panel + helpers (verified: the test file at lines 2-16 relies on this). The local import exists because the component itself uses the helpers. Both are load-bearing. Awkward but correct.

If you want to flatten in a follow-up, the right shape is import * as helpers from "./slideshowPanelHelpers" + every call site becomes helpers.toggleMainLineSlide(...). Not worth doing for this PR.

Concerns

2. slideshowScenes useMemo can race the iframe's __clipManifestStudioRightPanel.tsx:177-190

const slideshowScenes = useMemo<SceneInfo[]>(() => {
  const win = previewIframeRef.current?.contentWindow as IframeWindow | null;
  return (win?.__clipManifest?.scenes ?? []).map(...);
}, [previewIframeRef, rightPanelTab, refreshKey]);

Miga right that previewIframeRef as a dep is a no-op (ref identity is stable). The real reactivity hangs entirely on refreshKey from useStudioPlaybackContext(). If the user opens the Slideshow tab before the iframe's player has hydrated __clipManifest, the panel shows scenes.length === 0 and won't re-derive until the next refreshKey bump (which happens on player refresh, not on first-time hydration as far as I can tell). The Slides list will show empty with no indication that the iframe just hasn't loaded yet.

Suggest: at minimum, an empty-state line when scenes.length === 0 distinguishing "no clips in the composition" from "preview still loading." Better: subscribe to whatever signal fires when __clipManifest populates (looks like the player store would have one — usePlayerStore could expose a manifestLoaded flag, or refreshKey could bump on first hydration).

3. Date.now() ID collisions silently drop the second click — SlideshowPanel.tsx:298, SlideshowSubPanels.tsx:385 (concur Miga with sharper consequence)

Miga raised the millisecond-collision risk. The user-experience hit is worse than her framing implies:

  • handleCreateSequencecreateSequence (helper at slideshowPanelHelpers.ts:80) silently returns the manifest unchanged when an id already exists. No throw, no warning.
  • handleMakeHotspotaddHotspot (helper at slideshowPanelHelpers.ts:158) does the same.

A double-tap within 1ms — programmatic burst (autosave + user click landing in the same tick), fast trackpad — silently drops the second action. The .catch(() => {}) on the call site doesn't surface anything because the helper returns the unchanged manifest by design.

Fixes (any one):

  • crypto.randomUUID() (Studio is a browser env — supported in current targets).
  • Counter on a ref: const counterRef = useRef(0); const id = \seq-${Date.now()}-${counterRef.current++}``.
  • At minimum: log a console.warn in the helper when it hits the duplicate-ID branch so the silent failure is observable in dev.

4. Fragment key={t} uses float values as React keys — SlideshowSubPanels.tsx

Miga noted this. Concur. The fragments array is deduped via Set (which uses strict equality on floats — same float-equality concern from #1582/#1591), but if two near-identical floats slip past dedup, React renders both with the same key and reconciliation gets confused. ${scene.id}-${t} or array index would both be safer. Same-shape fix as the helper float-equality concern.

5. No confirmation on handleDeleteSequence — destructive cascade — SlideshowPanel.tsx:313

deleteSequence cascades to remove every hotspot targeting the deleted sequence from both main-line and other sequences' slides. A misclick deletes the branch AND silently drops N hotspot bindings across the manifest. Undo recovers it, but a window.confirm("Delete branch '<label>'? This will also remove N hotspots targeting it.") (with the live count from manifest.slides.flatMap(s => s.hotspots ?? []).filter(h => h.target === id).length + sequences.flatMap(seq => seq.slides.flatMap(s => s.hotspots ?? [])).filter(h => h.target === id).length) would be the friendlier shape.

Concur Miga; called out specifically here because the destructive action surface lives in this PR.

6. Persist failure path — what does the user see?

Every discrete-action handler does applyManifest(...).catch(() => {}) (SlideshowPanel.tsx:263-336). A failed writeProjectFile (disk full, OPFS quota, FS permission) is silently swallowed; the in-memory manifest state shows the edit, disk doesn't. The user reloads the comp later and the edit is gone with no signal it failed.

Same concern on applyNotesManifest → the controller's persist(p.manifest).catch(() => {}) at SlideshowPanel.tsx:107, 122 swallows the notes failure equally silently.

Question: is there an app-level toast wiring elsewhere that surfaces write failures generically (AppToast is in studioHelpers.ts:7-10), or does the panel need explicit error UI here?

Nits

7. Aria role mismatch on SlideList scene rows — SlideshowSubPanels.tsx:61-66

role="button" aria-pressed={isSelected}aria-pressed is toggle-button semantics. The row's role here is "selected item in a list"; the correct shape is parent role="listbox" + child role="option" aria-selected={isSelected}. Doesn't affect mouse users; screen readers will say "pressed" instead of "selected." Miga also flagged.

8. // fallow-ignore-next-line complexity on HotspotTool and handleMakeHotspotSlideshowSubPanels.tsx:365, 383

The component is straightforward; the handler is 5 lines. Looks like the lint rule is over-flagging. Worth tuning if it keeps tripping clean code, or annotating WHY the rule misfires here so the next reader doesn't assume there's complexity to refactor.

9. PR description is the default template

For the cross-compare with #1591 + #1582 to be fast, even a one-liner per section ("UI half of the #1582 split; persistence/helpers in #1591; ss-studio-a base") would help reviewers. The Graphite stack comment helps but doesn't say what each half contains.

Questions

10. compHtml effect ordering — is the OLD-comp flush guaranteed to write to the OLD callback?

SlideshowPanel.tsx:188-202: on compHtml change, notesCtrlRef.current.flush() fires before setManifest(parsed). The flush fires p.persist(p.manifest) where p.persist was captured when schedule() was called. So pending notes for COMP-A should write to COMP-A's onPersistNotes callback even after editingFile switches to COMP-B. But the onPersistNotes callback closes over activeCompPath from useSlideshowPersist's useCallback. If activeCompPath updates synchronously with editingFile.content, the persist closure points at COMP-B's path before the flush runs.

Miga noted the same ordering concern. Is activeCompPath derived from a different signal than editingFile.content, or do they update in the same React commit? If the latter, the flush could mis-route. Worth a comment + a unit test that explicitly covers "type in A → switch to B → expect A's file to have the notes, B's file untouched."

What I didn't verify

  • The full compHtml-effect ordering interaction with the React commit phase (see Q10).
  • Whether the editingFile.content lifecycle re-renders the panel cleanly when compHtml is mid-stream (e.g. a partial save reads partial bytes). safeParseManifest swallows this silently, returning { slides: [] } — same concern as #1591 about the user not seeing the parse-failure signal.
  • Performance of slideshowScenes.map + scenes.map + BranchTree re-render on a slideshow with hundreds of sequences/hotspots. For v1 targets this is fine (Miga right that 10s of scenes is realistic); flagged in case the canvas-renderer ever feeds a high-cardinality clip-list back through this path.

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.
Stack-split from the original ss-studio PR (#1582): the data layer —
setSlideshowManifest, the useSlideshowPersist hook, and panel helpers.
The editor panel UI follows in the next PR.
Stack-split from the original ss-studio PR (#1582): the SlideshowPanel /
SlideshowSubPanels editor UI, right-panel wiring, and app integration.
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-studio-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 04a775b into main Jun 19, 2026
24 of 31 checks passed
@vanceingalls vanceingalls deleted the ss-studio-b branch June 19, 2026 08:35
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 — ss-studio-b @ 5121992

Re-reviewed after the rebase. Checking prior findings from both myself and Rames against current HEAD.


Status of prior findings

1. "Duplicate import" in SlideshowPanel.tsxRETRACTED (my error from the first review, corrected by Rames). The export { ... } from re-export and the import { ... } from are both load-bearing. Not re-flagging.

2. eslint-disable-next-line react-hooks/exhaustive-deps on slideshowScenes useMemoStill present (StudioRightPanel.tsx). previewIframeRef is a stable ref identity that contributes nothing to the dep array; refreshKey is the real trigger. The suppress comment hides this reasoning. A brief inline comment explaining the proxy-signal pattern would be cleaner than the lint suppression.

3. No confirmation on handleDeleteSequenceStill present (SlideshowPanel.tsx). deleteSequence cascades to strip all hotspots targeting the deleted branch across the entire manifest — no window.confirm or equivalent guards the operation. Acceptable for v1 if intentional, but worth noting as a follow-up.

4. Date.now() for sequence/hotspot IDsStill present in two places:

  • SlideshowPanel.tsx: `seq-${Date.now()}`
  • SlideshowSubPanels.tsx: `hotspot-${elementKey}-${Date.now()}`

As Rames sharpened: createSequence and addHotspot helpers silently return the manifest unchanged on duplicate ID — a sub-ms double-tap drops the second action with no signal. crypto.randomUUID() would eliminate the class of bug entirely.

5. key={t} uses float values as React keysStill present (SlideshowSubPanels.tsx). {fragments.map((t) => <span key={t} ...>)} — if two fragments ever share a float value (the Set-based dedup uses strict equality on floats), React reconciliation breaks silently. key={\${sceneId}-frag-${i}`}` with the array index would be safer.

6. slideshowScenes race — empty panel before iframe hydrates __clipManifest (Rames's finding) — Still present (StudioRightPanel.tsx). If the user opens the Slideshow tab before the iframe player has hydrated __clipManifest, scenes resolve to [] and the panel shows nothing until the next refreshKey bump. No empty-state distinction between "no clips in composition" and "preview still loading."

7. Persist failure path — .catch(() => {}) swallows all write errors (Rames's finding) — Still present. 11 call sites use applyManifest(...).catch(() => {}). A failed writeProjectFile (disk full, OPFS quota) is silently swallowed; the in-memory manifest shows the edit, but disk doesn't. User reloads later and the edit is gone with no signal. Is AppToast wired up elsewhere to surface these, or does the panel need explicit error handling?


Summary

None of the prior findings from my review or Rames's review have been addressed in this rebase. The PR is functionally identical to the previously-reviewed 3f95540 — the new commits appear to be a clean rebase with no content changes.

Blocking: None (same as first round — these are all improvable but not ship-blockers for a v1 behind a feature gate).

Recommended before merge (same as before):

  • crypto.randomUUID() over Date.now() for IDs (#4) — silent data loss on collision is a real risk
  • Inline comment replacing the exhaustive-deps suppression (#2)

Recommended follow-ups:

  • Delete confirmation dialog (#3)
  • Float-key safety (#5)
  • Loading vs. empty-state distinction (#6)
  • Persist error surfacing (#7)

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