fix(slideshow): finish remaining split-PR review findings (#1594)

* 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).
This commit is contained in:
Vance Ingalls
2026-06-19 04:31:12 -07:00
committed by GitHub
parent cc2220e59e
commit f05b3f9c7c
16 changed files with 240 additions and 54 deletions
@@ -37,6 +37,11 @@ describe("parseSlideshowManifest", () => {
expect(() => parseSlideshowManifest(html)).toThrow();
});
it("rejects a non-object manifest (e.g. a JSON array)", () => {
const html = `<script type="application/hyperframes-slideshow+json">[42, null]</script>`;
expect(() => parseSlideshowManifest(html)).toThrow();
});
it("throws when a slide entry is malformed (sceneId not a string)", () => {
const html = `<script type="application/hyperframes-slideshow+json">
{ "slides": [{ "sceneId": 42 }] }
@@ -74,6 +79,18 @@ describe("resolveSlideshow", () => {
expect(errors.some((e) => e.includes("missing"))).toBe(true);
});
it("flags duplicate slideSequence ids instead of silently overwriting", () => {
const m: import("./slideshow.types").SlideshowManifest = {
slides: [{ sceneId: "a" }],
slideSequences: [
{ id: "dup", label: "First", slides: [{ sceneId: "c" }] },
{ id: "dup", label: "Second", slides: [{ sceneId: "c" }] },
],
};
const { errors } = resolveSlideshow(m, SCENES);
expect(errors.some((e) => e.includes("duplicate slideSequence id"))).toBe(true);
});
it("reports an error for a fragment outside the slide range", () => {
const m: import("./slideshow.types").SlideshowManifest = {
slides: [{ sceneId: "a", fragments: [99] }],
@@ -69,7 +69,7 @@ function isSlideSequence(v: unknown): boolean {
}
function isManifest(v: unknown): v is SlideshowManifest {
if (typeof v !== "object" || v === null) return false;
if (typeof v !== "object" || v === null || Array.isArray(v)) return false;
const o = v as Record<string, unknown>;
if (!Array.isArray(o["slides"]) || !o["slides"].every(isSlideRef)) return false;
if (o["slideSequences"] !== undefined) {
@@ -158,6 +158,10 @@ export function resolveSlideshow(
const sequences: Record<string, ResolvedSlideSequence> = {};
for (const seq of manifest.slideSequences ?? []) {
// Flag duplicate sequence ids rather than silently overwriting the earlier one.
if (Object.prototype.hasOwnProperty.call(sequences, seq.id)) {
errors.push(`duplicate slideSequence id "${seq.id}" — only the last definition is kept`);
}
sequences[seq.id] = {
id: seq.id,
label: seq.label,
+15
View File
@@ -0,0 +1,15 @@
// packages/core/src/slideshow/sceneId.ts
/**
* Whether a composition id names a "scene-like" composition — i.e. a real slide
* scene, not the root timeline (`main`) or a non-scene overlay (captions, ambient
* layers). Shared by the runtime scene-window computation and the slideshow lint
* rule so the two can never drift.
*/
export function isSceneLikeCompositionId(compositionId: string): boolean {
const normalized = compositionId.trim().toLowerCase();
if (!normalized || normalized === "main") return false;
if (normalized.includes("caption")) return false;
if (normalized.includes("ambient")) return false;
return true;
}