feat(studio): harden ui primitives — focus rings, keyboard tooltips, dialog hook (#1962)

## Summary

Base of the studio UX-review stack (148 findings audited across the studio; 13 critical). This PR hardens the shared `components/ui` primitives that every later PR in the stack builds on.

## Changes

- **Button / IconButton**: visible `focus-visible` outline (studio accent); `disabled:pointer-events-none` removed (replaced with `disabled:cursor-not-allowed`, hover/active gated behind `enabled:`) so disabled buttons can host explain-why tooltips.
- **Tooltip**: keyboard support (`onFocus`/`onBlur` triggers), `role="tooltip"`, Escape-to-hide, viewport flip (top↔bottom) + horizontal clamping. API unchanged — all ~28 call sites unaffected.
- **HyperframesLoader**: `role="status"` on the loader; determinate track is a real `role="progressbar"` with `aria-valuenow/min/max` (was `aria-hidden`).
- **VideoFrameThumbnail**: error event resolves to a static fallback-label tile instead of an infinite shimmer; `motion-reduce` guard.
- **NEW `useDialogBehavior`**: shared modal contract — document-level Escape, Tab focus trap, focus-first-on-open, focus-restore-on-close, `canClose()` veto for dirty-draft guards. Adopted by every modal later in the stack.
- **NEW `SearchInput`**: shared search primitive with required `aria-label`, panel-input token style (kills the two-divergent-search-styles inconsistency in the sidebar).
- **studio.css**: `hf-toast-in/out` + `hf-backdrop-in` keyframes with `prefers-reduced-motion` guards (the previous `animate-in fade-in` classes were dead — no tailwindcss-animate plugin exists).

## Verification

- oxlint 0 errors, oxfmt clean, `tsc --noEmit` clean at stack top
- Full studio suite at stack top: 1189 tests pass

## Stack

PR 1/7 of the studio UX-review fixes. Merges bottom-up; the stack top is fully green (tsc + 1189 tests). Some shared-file edits span PRs, so intermediate branches may not typecheck in isolation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Vance Ingalls
2026-07-06 16:07:44 -07:00
committed by GitHub
parent e04f6dda37
commit cd1adcb581
8 changed files with 367 additions and 22 deletions
@@ -5,10 +5,19 @@ import { useState, useEffect } from "react";
* video + canvas. Seeks to ~10% of duration to avoid black opening frames.
* Used by AssetThumbnail (assets tab) and RenderQueueItem (renders tab).
*/
export function VideoFrameThumbnail({ src }: { src: string }) {
export function VideoFrameThumbnail({
src,
fallbackLabel,
}: {
src: string;
/** Shown instead of an endless shimmer when the video can't be decoded. */
fallbackLabel?: string;
}) {
const [frame, setFrame] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
setFailed(false);
const video = document.createElement("video");
video.crossOrigin = "anonymous";
video.muted = true;
@@ -35,15 +44,29 @@ export function VideoFrameThumbnail({ src }: { src: string }) {
cleanup();
});
video.addEventListener("error", cleanup);
video.addEventListener("error", () => {
// Resolve the loading state — a permanent shimmer reads as "still loading".
setFailed(true);
cleanup();
});
video.src = src;
video.load();
return cleanup;
}, [src]);
if (failed && !frame) {
return (
<div className="w-full h-full bg-neutral-800 flex items-center justify-center">
<span className="text-[9px] font-medium text-neutral-600">{fallbackLabel ?? "VIDEO"}</span>
</div>
);
}
if (!frame) {
return <div className="w-full h-full bg-neutral-800 animate-pulse" />;
return (
<div className="w-full h-full bg-neutral-800 animate-pulse motion-reduce:animate-none" />
);
}
return <img src={frame} alt="" draggable={false} className="w-full h-full object-contain" />;