fix(studio): storyboard polish — a11y, preview, and edit-race fixes (#1544)

Batched non-blocking review nits from the storyboard stack (#1528–#1532):

- StoryboardGrid: responsive auto-fill grid instead of fixed-360 tiles
- FramePoster: reset failed state when the poster target changes (stale-error fix)
- StoryboardFrameTile: status-chip aria-label
- StoryboardSourceEditor: marked({async:false}); save() in-flight guard;
  immediate first preview paint; [&_img] prose; scoped link-hardening
  (rel=noopener noreferrer + target=_blank) in the sanitizer
- StoryboardLoaded: memoize sourceFiles on data.script.path/.exists, not the object ref
- StoryboardFrameFocus: applyEdit in-flight guard; aria-pressed on status buttons;
  ←/→/Esc keyboard navigation
- ViewModeContext: correct the popstate/replaceState doc-drift

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-06-17 16:05:12 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent c8fd16f2d3
commit e662fcdea2
7 changed files with 64 additions and 14 deletions
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail";
export interface FramePosterProps {
@@ -20,6 +20,9 @@ export interface FramePosterProps {
*/
export function FramePoster({ projectId, src, seconds, title, fit = "cover" }: FramePosterProps) {
const [failed, setFailed] = useState(false);
// The <img> is reused (no key) when a tile/hero swaps to a different frame, so a
// prior load error would stick. Reset when the poster target changes.
useEffect(() => setFailed(false), [src, seconds]);
if (failed) {
return (
<div className="flex h-full w-full items-center justify-center text-[11px] text-neutral-600">
@@ -1,4 +1,4 @@
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { setFrameStatus, setFrameVoiceover, type FrameStatus } from "@hyperframes/core/storyboard";
import type { StoryboardFrameView } from "../../hooks/useStoryboard";
import { useFileManagerContext } from "../../contexts/FileManagerContext";
@@ -47,6 +47,7 @@ export function StoryboardFrameFocus({
const applyEdit = useCallback(
async (edit: (source: string) => string) => {
if (busy) return; // one read-modify-write at a time; avoids a lost update
setBusy(true);
setError(null);
try {
@@ -59,7 +60,7 @@ export function StoryboardFrameFocus({
setBusy(false);
}
},
[readProjectFile, writeProjectFile, storyboardPath, onSaved],
[readProjectFile, writeProjectFile, storyboardPath, onSaved, busy],
);
const title = frame.title ?? `Frame ${frame.index}`;
@@ -75,6 +76,20 @@ export function StoryboardFrameFocus({
if (confirmLeave()) onNavigate(delta);
};
// ←/→ navigate frames, Esc returns to the Board — but never while typing in a field.
useEffect(() => {
// fallow-ignore-next-line complexity
const onKey = (e: KeyboardEvent) => {
const el = document.activeElement;
if (el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement) return;
if (e.key === "Escape") handleBack();
else if (e.key === "ArrowLeft" && frame.index > 1) handleNavigate(-1);
else if (e.key === "ArrowRight" && frame.index < frameCount) handleNavigate(1);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
const openInPreview = () => {
if (frame.src) onSelectComposition(frame.src);
setViewMode("timeline");
@@ -229,6 +244,7 @@ function StatusRow({
key={option}
type="button"
disabled={busy}
aria-pressed={status === option}
title={FRAME_STATUS_META[option].tooltip}
onClick={() => onSet(option)}
className={`rounded px-2.5 py-1 text-xs font-medium transition-colors disabled:opacity-50 ${
@@ -9,8 +9,6 @@ export interface StoryboardFrameTileProps {
onOpen: (index: number) => void;
}
const TILE_WIDTH = 360;
function firstLine(text: string): string {
return (
text
@@ -35,7 +33,7 @@ export function StoryboardFrameTile({ projectId, frame, onOpen }: StoryboardFram
const sceneLine = frame.scene ?? firstLine(frame.narrative);
return (
<article style={{ width: TILE_WIDTH }}>
<article className="min-w-0">
<button
type="button"
onClick={() => onOpen(frame.index)}
@@ -60,6 +58,7 @@ export function StoryboardFrameTile({ projectId, frame, onOpen }: StoryboardFram
<h3 className="truncate text-sm font-medium text-neutral-200">{title}</h3>
<span
title={meta.tooltip}
aria-label={`Status: ${meta.label}${meta.tooltip}`}
className={`shrink-0 cursor-default rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide ${meta.chipClass}`}
>
{meta.label}
@@ -19,7 +19,7 @@ export function StoryboardGrid({ projectId, frames, onOpenFrame }: StoryboardGri
}
return (
<div className="mt-8 flex flex-wrap gap-x-6 gap-y-8">
<div className="mt-8 grid gap-x-6 gap-y-8 [grid-template-columns:repeat(auto-fill,minmax(300px,1fr))]">
{frames.map((frame) => (
<StoryboardFrameTile
key={frame.index}
@@ -37,7 +37,9 @@ export function StoryboardLoaded({
const files: SourceFile[] = [{ path: data.path, label: data.path }];
if (data.script?.exists) files.push({ path: data.script.path, label: data.script.path });
return files;
}, [data.path, data.script]);
// Depend on the stable fields, not the `data.script` object — every reload()
// produces a fresh object and would needlessly re-create this array.
}, [data.path, data.script?.path, data.script?.exists]);
// Leaving the source editor drops its in-memory buffer; confirm when it's dirty.
// fallow-ignore-next-line complexity
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { marked } from "marked";
import DOMPurify from "dompurify";
import { SourceEditor } from "../editor/SourceEditor";
@@ -61,6 +61,7 @@ function useEditableFile(path: string, onSaved: () => void): EditableFile {
}, [path, readProjectFile]);
const save = useCallback(() => {
if (saving) return; // coalesce a fast double Cmd+S into one PUT
setSaving(true);
setError(null);
writeProjectFile(path, content)
@@ -70,22 +71,45 @@ function useEditableFile(path: string, onSaved: () => void): EditableFile {
})
.catch((err: unknown) => setError(err instanceof Error ? err.message : "failed to save"))
.finally(() => setSaving(false));
}, [writeProjectFile, path, content, onSaved]);
}, [writeProjectFile, path, content, onSaved, saving]);
return { content, setContent, dirty: content !== saved, loading, saving, error, save };
}
/** Preview links open in a new tab with the `window.opener` back-channel severed. */
function hardenLinks(node: Element): void {
if (node.tagName === "A" && node.hasAttribute("href")) {
node.setAttribute("target", "_blank");
node.setAttribute("rel", "noopener noreferrer");
}
}
/** Render markdown to sanitized HTML, debounced so we don't re-parse on every keystroke. */
function useMarkdownPreview(source: string): string {
const [debounced, setDebounced] = useState(source);
const primed = useRef(false);
useEffect(() => {
// Paint the first non-empty content immediately (no 200ms blank window after a file
// loads), exactly once, then debounce all subsequent keystrokes.
if (!primed.current && source !== "") {
primed.current = true;
setDebounced(source);
return;
}
const id = window.setTimeout(() => setDebounced(source), 200);
return () => window.clearTimeout(id);
}, [source]);
return useMemo(() => {
const raw = marked.parse(debounced);
const html = typeof raw === "string" ? raw : "";
return DOMPurify.sanitize(html);
// `{ async: false }` pins the synchronous string return (no Promise union to narrow).
const html = marked.parse(debounced, { async: false });
// Scope the link-hardening hook to this call; `finally` guarantees removal even if
// `sanitize` throws, so the hook can never leak into other DOMPurify consumers.
DOMPurify.addHook("afterSanitizeAttributes", hardenLinks);
try {
return DOMPurify.sanitize(html);
} finally {
DOMPurify.removeHook("afterSanitizeAttributes");
}
}, [debounced]);
}
@@ -104,6 +128,7 @@ const PREVIEW_PROSE =
"[&_pre]:my-3 [&_pre]:overflow-auto [&_pre]:rounded [&_pre]:bg-neutral-900 [&_pre]:p-3 " +
"[&_pre_code]:bg-transparent [&_pre_code]:p-0 " +
"[&_hr]:my-4 [&_hr]:border-neutral-800 [&_a]:text-sky-400 [&_strong]:text-neutral-100 " +
"[&_img]:my-2 [&_img]:max-w-full [&_img]:h-auto [&_img]:rounded " +
"[&_table]:my-3 [&_th]:border [&_th]:border-neutral-800 [&_th]:px-2 [&_th]:py-1 " +
"[&_td]:border [&_td]:border-neutral-800 [&_td]:px-2 [&_td]:py-1";
@@ -53,7 +53,12 @@ export function useViewModeState(enabled: boolean): ViewModeValue {
enabled ? readViewModeFromUrl() : "timeline",
);
// Reflect back/forward navigation and agent-driven URL changes.
// Reflect genuine browser back/forward between history entries with a different
// `?view=`. Note: our own writes use `replaceState` (below), which does NOT fire
// `popstate`, so this listener never sees them — `setViewMode` updates state directly.
// An agent deep-links by doing a full navigation to `?view=storyboard` (picked up by
// the mount-time read); a scripted `pushState`/`replaceState` to `?view=` would not be
// reflected here, by design.
useEffect(() => {
if (!enabled) return;
const onPopState = () => setMode(readViewModeFromUrl());