Files
hyperframes/packages/studio/src/components/ui/VideoFrameThumbnail.tsx
T
Vance Ingalls cd1adcb581 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)
2026-07-06 16:07:44 -07:00

74 lines
2.0 KiB
TypeScript

import { useState, useEffect } from "react";
/**
* Extracts a representative JPEG frame from a video URL using a hidden
* 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,
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;
video.preload = "metadata";
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const cleanup = () => {
video.src = "";
video.load();
};
video.addEventListener("loadedmetadata", () => {
video.currentTime = Math.min(2, video.duration * 0.1 || 2);
});
video.addEventListener("seeked", () => {
if (!ctx) return;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0);
setFrame(canvas.toDataURL("image/jpeg", 0.7));
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 motion-reduce:animate-none" />
);
}
return <img src={frame} alt="" draggable={false} className="w-full h-full object-contain" />;
}