fix(slideshow): present media controls (#1601)

* fix(slideshow): harden media controls in present decks

* refactor(slideshow): clear Fallow audit findings

Decompose flagged high-CRAP functions and extract production-code
duplications so the audit gate clears.

- core/runtime/bridge.ts handler — replace the 14-branch if-chain with a
  CONTROL_HANDLERS dispatch table; flash-elements payload handling moves
  to its own helper. Behavior preserved (all existing bridge.test.ts
  cases hit the same dispatchers via the public installRuntimeControlBridge
  API).

- player/slideshow/SlideshowController syncTo — split into
  isValidSyncTarget / isCrossSlide / rerootStackTo helpers. The
  stopSlideMedia decision and the stack re-rooting are now individually
  named; the public method is a 4-line orchestrator.

- cli/commands/validate.ts run — extract emitJsonReport / emitTextReport
  so the orchestrator no longer carries the dual JSON/text branches.
  Cuts the cyclomatic complexity flagged by fallow after the
  shouldIgnoreRequestFailure signature expansion shifted the fingerprint.

- player/hyperframes-player.ts — _setIframeMediaMuted and _stopIframeMedia
  shared a `try { iframeDoc = contentDocument } catch { return }` preamble
  (clone group 15). Extract _getSameOriginIframeDocument(): Document | null
  and have both call sites consume it.

- studio/panels/SlideshowPanel.tsx — the notes controller's debounce-tail
  and explicit flush() shared the pending-drain pattern (clone group 16).
  Extract a drainPending() closure both call.

- player/hyperframes-player.test.ts — collapse the new stopMedia / muted
  tests' repeated Object.defineProperty(iframe, "contentDocument", { get })
  shape behind a stubIframeContentDocument helper.

No behavior changes — refactor only. Existing tests cover the affected
paths unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(validate): split run further; ignore test dup parity

Second Fallow pass surfaced two minor follow-ups after the first cut:

- packages/cli/src/commands/validate.ts run + emitTextReport still
  carried minor CRAP findings (43.1 / 37.1, threshold 30). Extract
  printValidationResult / formatConsoleEntry / formatTotals /
  emitFailureReport so run becomes a try/catch + delegation, well
  below the threshold; emitTextReport drops the inline format loops.

- .fallowrc.jsonc duplicates.ignore: add hyperframes-player.test.ts
  alongside the existing SlideshowPanel.test.ts entry. Same reasoning
  documented there — parallel arrange/act/assert test cases are
  intentionally self-contained for readability; collapsing them under
  shared fixtures would couple unrelated scenarios (same-origin vs
  realm media, audio-locked permutations, seek bridge variants).

No behavior changes — refactor + config-policy parity only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-06-19 17:17:39 -07:00
committed by GitHub
co-authored by Claude Opus 4.7
parent cd832f01ac
commit f0c4dee705
23 changed files with 959 additions and 371 deletions
@@ -57,7 +57,6 @@ export function safeParseManifest(html: string): SlideshowManifest {
import {
toggleMainLineSlide,
reorderMainLineSlide,
reorderBranchSlide,
setSlideNotes,
addFragment,
removeFragment,
@@ -94,19 +93,24 @@ export function makeSlideshowNotesController(): NotesController {
let pending: Pending | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
// Atomically swap the pending entry out and fire its persist (if any).
// Used by both the debounce timer's tail and the explicit flush() path.
const drainPending = (): void => {
const p = pending;
if (p === null) return;
pending = null;
p.persist(p.manifest).catch((err: unknown) => {
console.error("[slideshow] notes persist failed:", err);
});
};
return {
schedule(manifest, persist, delayMs) {
if (timer !== null) clearTimeout(timer);
pending = { manifest, persist };
timer = setTimeout(() => {
timer = null;
const p = pending;
if (p !== null) {
pending = null;
p.persist(p.manifest).catch((err: unknown) => {
console.error("[slideshow] notes persist failed:", err);
});
}
drainPending();
}, delayMs);
return timer;
},
@@ -116,13 +120,7 @@ export function makeSlideshowNotesController(): NotesController {
clearTimeout(timer);
timer = null;
}
const p = pending;
if (p !== null) {
pending = null;
p.persist(p.manifest).catch((err: unknown) => {
console.error("[slideshow] notes persist failed:", err);
});
}
drainPending();
},
cancel() {
@@ -293,15 +291,6 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP
[applyManifest],
);
const handleReorderBranchSlide = useCallback(
(sequenceId: string, sceneId: string, dir: "up" | "down") => {
applyManifest(reorderBranchSlide(manifestRef.current, sequenceId, sceneId, dir)).catch(
() => {},
);
},
[applyManifest],
);
const handleSetNotes = useCallback(
(notes: string) => {
if (!selectedSceneId) return;
@@ -459,7 +448,6 @@ export function SlideshowPanel({ scenes, onPersist, onPersistNotes }: SlideshowP
selectedSceneId={selectedSceneId}
selectedSequenceId={selectedSequenceId}
onSelectBranchSlide={handleSelectBranchSlide}
onReorderBranchSlide={handleReorderBranchSlide}
/>
)}
@@ -216,7 +216,6 @@ export interface BranchTreeProps {
selectedSceneId: string | null;
selectedSequenceId: string | null;
onSelectBranchSlide: (sequenceId: string, sceneId: string) => void;
onReorderBranchSlide: (sequenceId: string, sceneId: string, dir: "up" | "down") => void;
}
export function BranchTree({
@@ -229,7 +228,6 @@ export function BranchTree({
selectedSceneId,
selectedSequenceId,
onSelectBranchSlide,
onReorderBranchSlide,
}: BranchTreeProps) {
const [newLabel, setNewLabel] = useState("");
const inputId = useId();
@@ -280,7 +278,6 @@ export function BranchTree({
selectedSceneId={selectedSceneId}
selectedSequenceId={selectedSequenceId}
onSelectBranchSlide={onSelectBranchSlide}
onReorderBranchSlide={onReorderBranchSlide}
/>
))}
</div>
@@ -298,7 +295,6 @@ interface BranchItemProps {
selectedSceneId: string | null;
selectedSequenceId: string | null;
onSelectBranchSlide: (sequenceId: string, sceneId: string) => void;
onReorderBranchSlide: (sequenceId: string, sceneId: string, dir: "up" | "down") => void;
}
function BranchItem({
@@ -310,7 +306,6 @@ function BranchItem({
selectedSceneId,
selectedSequenceId,
onSelectBranchSlide,
onReorderBranchSlide,
}: BranchItemProps) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(seq.label);
@@ -362,8 +357,7 @@ function BranchItem({
</div>
<div className="flex flex-col gap-px pl-2">
{scenes.map((scene) => {
const branchPos = seq.slides.findIndex((s) => s.sceneId === scene.id);
const assigned = branchPos !== -1;
const assigned = seq.slides.some((s) => s.sceneId === scene.id);
const isSelected = selectedSequenceId === seq.id && selectedSceneId === scene.id;
return (
<div
@@ -378,39 +372,16 @@ function BranchItem({
className="accent-studio-accent flex-shrink-0"
/>
{assigned ? (
<>
<button
type="button"
aria-pressed={isSelected}
className={`flex-1 text-left truncate transition-colors hover:text-neutral-200 ${
isSelected ? "text-white" : "text-neutral-400"
}`}
onClick={() => onSelectBranchSlide(seq.id, scene.id)}
>
{scene.label || scene.id}
</button>
<span className="text-[9px] text-neutral-600 tabular-nums flex-shrink-0">
{branchPos + 1}/{seq.slides.length}
</span>
<button
type="button"
aria-label={`Move ${scene.label || scene.id} earlier in branch ${seq.label}`}
disabled={branchPos <= 0}
className="text-[10px] text-neutral-500 hover:text-neutral-200 disabled:opacity-30 disabled:cursor-default px-0.5"
onClick={() => onReorderBranchSlide(seq.id, scene.id, "up")}
>
</button>
<button
type="button"
aria-label={`Move ${scene.label || scene.id} later in branch ${seq.label}`}
disabled={branchPos >= seq.slides.length - 1}
className="text-[10px] text-neutral-500 hover:text-neutral-200 disabled:opacity-30 disabled:cursor-default px-0.5"
onClick={() => onReorderBranchSlide(seq.id, scene.id, "down")}
>
</button>
</>
<button
type="button"
aria-pressed={isSelected}
className={`flex-1 text-left truncate transition-colors hover:text-neutral-200 ${
isSelected ? "text-white" : "text-neutral-400"
}`}
onClick={() => onSelectBranchSlide(seq.id, scene.id)}
>
{scene.label || scene.id}
</button>
) : (
<span className="flex-1 truncate">{scene.label || scene.id}</span>
)}