From 64eaad7d69b3c11b9e95b912f2c0b5070994078b Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Wed, 24 Jun 2026 21:31:39 -0700 Subject: [PATCH] feat(slideshow): auto-set interactive on inner player (#1712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(slideshow): auto-set interactive on inner player The slideshow now sets the `interactive` attribute on its inner instances at mount time, so pointer events reach the composition iframe automatically. Removes the agent-compliance burden of having to remember to add `interactive` on every player tag inside a slideshow. Idempotent: an author-supplied `interactive` attribute (any value, including `interactive="false"`) is preserved. A MutationObserver also picks up players inserted dynamically after the initial mount. Standalone player usage outside a slideshow still requires the explicit attribute — that surface is unchanged. Skill guidance at skills/slideshow/SKILL.md updated to reflect the automatic behavior. * docs(slideshow): clarify interactive attribute semantics Per Rames R1 review feedback: the test comment implied `interactive="false"` is an author opt-out, but `:host([interactive])` is presence-matching per HTML boolean-attribute convention — so any value (including "false") enables pointer events at runtime. The slideshow's mechanical wire-up preserves any author-supplied value verbatim for DOM hygiene, not as a runtime opt-out. --- .../slideshow/hyperframes-slideshow.test.ts | 73 +++++++++++++++++++ .../src/slideshow/hyperframes-slideshow.ts | 41 ++++++++++- skills/slideshow/SKILL.md | 4 +- 3 files changed, 115 insertions(+), 3 deletions(-) diff --git a/packages/player/src/slideshow/hyperframes-slideshow.test.ts b/packages/player/src/slideshow/hyperframes-slideshow.test.ts index f86b862e2..ebf8f8d26 100644 --- a/packages/player/src/slideshow/hyperframes-slideshow.test.ts +++ b/packages/player/src/slideshow/hyperframes-slideshow.test.ts @@ -2179,3 +2179,76 @@ describe(" Fix 4 — back affordance (postMessage only; c el.remove(); }); }); + +// --------------------------------------------------------------------------- +// Mechanical `interactive` attribute on inner +// --------------------------------------------------------------------------- +// The slideshow auto-applies the `interactive` attribute to every inner +// , so clickable controls, links, native media controls, +// and custom players inside the composition iframe receive pointer events +// without the author having to remember the attribute. The player's default +// is `pointer-events: none` on the iframe; `interactive` flips it to `auto` +// via the `:host([interactive])` rule in player styles. +// --------------------------------------------------------------------------- +describe(" auto-sets `interactive` on inner ", () => { + beforeEach(async () => { + await import("./hyperframes-slideshow.js"); + }); + + const tick = () => new Promise((r) => setTimeout(r, 0)); + + it("inner gets `interactive` attribute after mount", async () => { + const el = document.createElement("hyperframes-slideshow"); + const player = document.createElement("hyperframes-player"); + el.appendChild(player); + document.body.appendChild(el); + + // Allow the deferred initTimer macrotask to run. + await tick(); + + expect(player.hasAttribute("interactive")).toBe(true); + expect(player.getAttribute("interactive")).toBe(""); + + el.remove(); + }); + + it("preserves any author-supplied `interactive` attribute value verbatim", async () => { + const el = document.createElement("hyperframes-slideshow"); + const player = document.createElement("hyperframes-player"); + // Preserve any author-supplied `interactive` value verbatim. Note: the + // CSS rule `:host([interactive])` is presence-based per HTML + // boolean-attribute convention, so the runtime behavior is identical + // regardless of the value — the attribute always enables pointer + // events. The preservation guarantee here is about DOM hygiene + // (idempotent mechanical wire-up, no clobber on re-runs), not a + // runtime opt-out — `interactive="false"` is NOT an opt-out. + player.setAttribute("interactive", "false"); + el.appendChild(player); + document.body.appendChild(el); + + await tick(); + + expect(player.getAttribute("interactive")).toBe("false"); + + el.remove(); + }); + + it("dynamically-inserted children also get `interactive`", async () => { + const el = document.createElement("hyperframes-slideshow"); + document.body.appendChild(el); + + await tick(); + + // Late insertion — picked up by the MutationObserver. + const player = document.createElement("hyperframes-player"); + el.appendChild(player); + + // MutationObserver callbacks deliver on a microtask; flush twice to be safe. + await tick(); + await tick(); + + expect(player.hasAttribute("interactive")).toBe(true); + + el.remove(); + }); +}); diff --git a/packages/player/src/slideshow/hyperframes-slideshow.ts b/packages/player/src/slideshow/hyperframes-slideshow.ts index 167a2d321..46f8f6092 100644 --- a/packages/player/src/slideshow/hyperframes-slideshow.ts +++ b/packages/player/src/slideshow/hyperframes-slideshow.ts @@ -167,6 +167,7 @@ export class HyperframesSlideshow extends HTMLElement { private initGeneration = 0; private _muted = false; private mediaWireInterval: ReturnType | null = null; + private playerObserver: MutationObserver | null = null; private applyingRemoteMedia = false; private lastMediaTimeBroadcastMs = 0; private audienceMutedPlaybackKeys = new Set(); @@ -215,6 +216,7 @@ export class HyperframesSlideshow extends HTMLElement { window.addEventListener("message", this.onMessage); document.addEventListener("fullscreenchange", this.onFsChange); this.initChannel(); + this.observeInteractivePlayers(); // Defer player-dependent init to a macrotask so that child elements are // parsed before we query for . This matters when the // bundle is loaded synchronously (e.g.