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
+20 -11
View File
@@ -30,22 +30,24 @@ const variantStyles: Record<ButtonVariant, string> = {
primary: [
"bg-white text-neutral-950 font-medium",
"shadow-btn-primary",
"hover:bg-neutral-200",
"active:scale-[0.97]",
"enabled:hover:bg-neutral-200",
"enabled:active:scale-[0.97]",
].join(" "),
secondary: [
"bg-transparent text-neutral-300 font-medium",
"border border-border",
"hover:bg-surface-hover hover:text-white hover:border-border-strong",
"active:scale-[0.98]",
"enabled:hover:bg-surface-hover enabled:hover:text-white enabled:hover:border-border-strong",
"enabled:active:scale-[0.98]",
].join(" "),
danger: [
"bg-accent-red text-white font-medium",
"enabled:hover:bg-red-600",
"enabled:active:scale-[0.97]",
].join(" "),
danger: ["bg-accent-red text-white font-medium", "hover:bg-red-600", "active:scale-[0.97]"].join(
" ",
),
ghost: [
"bg-transparent text-neutral-400",
"hover:bg-surface-hover hover:text-white",
"active:scale-[0.98]",
"enabled:hover:bg-surface-hover enabled:hover:text-white",
"enabled:active:scale-[0.98]",
].join(" "),
};
@@ -55,6 +57,8 @@ const sizeStyles: Record<ButtonSize, string> = {
lg: "h-9 px-4 text-base gap-2 rounded-button",
};
// Imported by the shell/renders PRs later in this stack.
// fallow-ignore-next-line unused-export
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
@@ -76,8 +80,11 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
className={[
"inline-flex items-center justify-center",
"transition-all duration-press ease-standard",
"disabled:opacity-40 disabled:pointer-events-none",
// No pointer-events-none: disabled buttons must still receive hover
// so a wrapping Tooltip can explain WHY they're disabled (A5).
"disabled:opacity-40 disabled:cursor-not-allowed",
"select-none cursor-pointer",
"outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-studio-accent",
variantStyles[variant],
sizeStyles[size],
className,
@@ -126,6 +133,7 @@ const iconSizeStyles: Record<ButtonSize, string> = {
lg: "min-w-9 min-h-9 rounded-button", // 36px
};
// fallow-ignore-next-line unused-export
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
({ icon, size = "md", variant = "ghost", className = "", ...props }, ref) => {
return (
@@ -134,8 +142,9 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
className={[
"inline-flex items-center justify-center",
"transition-all duration-press ease-standard",
"disabled:opacity-40 disabled:pointer-events-none",
"disabled:opacity-40 disabled:cursor-not-allowed",
"select-none cursor-pointer",
"outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-studio-accent",
variantStyles[variant],
iconSizeStyles[size],
className,
@@ -25,7 +25,7 @@ export function HyperframesLoader({
const markFrameSize = Math.round(size * 1.16);
return (
<div className="hf-loader" draggable={false}>
<div className="hf-loader" role="status" draggable={false}>
<div
className="hf-loader-mark-frame"
style={{ width: markFrameSize, height: markFrameSize }}
@@ -83,7 +83,13 @@ export function HyperframesLoader({
<div className="hf-loader-title">{title}</div>
{detail && <div className="hf-loader-detail">{detail}</div>}
{boundedProgress !== undefined && (
<div className="hf-loader-progress" aria-hidden="true">
<div
className="hf-loader-progress"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(boundedProgress * 100)}
>
<div
className="hf-loader-progress__fill"
style={{ transform: `scaleX(${boundedProgress})` }}
@@ -95,6 +101,7 @@ export function HyperframesLoader({
);
}
// fallow-ignore-next-line unused-export
export function StatusFrame(props: HyperframesLoaderProps) {
return (
<div className="hf-frame">
@@ -0,0 +1,53 @@
// fallow-ignore-file unused-file
// (consumers land in the sidebar/panels PR later in this stack)
import { type InputHTMLAttributes } from "react";
interface SearchInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
/** Accessible name — placeholder alone is not one. */
"aria-label": string;
}
/**
* Shared search input — one visual system (panel-input tokens) for every
* panel search box, with a required accessible name.
*/
export function SearchInput({ className = "", ...props }: SearchInputProps) {
return (
<div
className={`flex items-center gap-1.5 rounded-md bg-panel-input px-2.5 py-[5px] ${className}`}
>
<svg
width="12"
height="12"
viewBox="0 0 256 256"
fill="none"
className="flex-shrink-0"
aria-hidden="true"
>
<circle
cx="116"
cy="116"
r="76"
stroke="currentColor"
strokeWidth="22"
className="text-panel-text-5"
/>
<line
x1="170"
y1="170"
x2="232"
y2="232"
stroke="currentColor"
strokeWidth="22"
strokeLinecap="round"
className="text-panel-text-5"
/>
</svg>
<input
type="text"
className="min-w-0 w-full bg-transparent text-[11px] text-panel-text-1 outline-none placeholder:text-panel-text-5"
{...props}
/>
</div>
);
}
+52 -6
View File
@@ -1,4 +1,4 @@
import { useState, useRef, useCallback, type ReactNode } from "react";
import { useState, useRef, useCallback, useEffect, useId, type ReactNode } from "react";
import { createPortal } from "react-dom";
interface TooltipProps {
@@ -8,22 +8,46 @@ interface TooltipProps {
side?: "top" | "bottom";
}
// Rough bubble height (padding + one text line) used to decide flipping
// before the bubble has rendered; exact height isn't needed for the guard.
const APPROX_BUBBLE_H = 28;
const VIEWPORT_MARGIN = 8;
export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipProps) {
const [visible, setVisible] = useState(false);
const [pos, setPos] = useState({ x: 0, y: 0 });
const [resolvedSide, setResolvedSide] = useState<"top" | "bottom">(side);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const triggerRef = useRef<HTMLSpanElement>(null);
// WCAG 4.1.2: programmatically associate the bubble with its trigger.
const tooltipId = useId();
const show = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
const el = triggerRef.current;
if (!el) return;
const child = el.firstElementChild as HTMLElement | null;
const rect = (child ?? el).getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) return;
// Flip when the preferred side would clip the viewport edge.
let nextSide = side;
if (side === "top" && rect.top - APPROX_BUBBLE_H - 6 < VIEWPORT_MARGIN) {
nextSide = "bottom";
} else if (
side === "bottom" &&
rect.bottom + APPROX_BUBBLE_H + 6 > window.innerHeight - VIEWPORT_MARGIN
) {
nextSide = "top";
}
const x = Math.min(
Math.max(rect.left + rect.width / 2, VIEWPORT_MARGIN),
window.innerWidth - VIEWPORT_MARGIN,
);
setResolvedSide(nextSide);
setPos({
x: rect.left + rect.width / 2,
y: side === "top" ? rect.top - 6 : rect.bottom + 6,
x,
y: nextSide === "top" ? rect.top - 6 : rect.bottom + 6,
});
setVisible(true);
}, delay);
@@ -37,9 +61,27 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
setVisible(false);
}, []);
// WCAG 1.4.13: tooltip content must be dismissible with Escape.
useEffect(() => {
if (!visible) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") hide();
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [visible, hide]);
return (
<>
<span ref={triggerRef} onPointerEnter={show} onPointerLeave={hide} className="contents">
<span
ref={triggerRef}
onPointerEnter={show}
onPointerLeave={hide}
onFocus={show}
onBlur={hide}
aria-describedby={visible ? tooltipId : undefined}
className="contents"
>
{children}
</span>
{visible &&
@@ -49,10 +91,14 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
style={{
left: pos.x,
top: pos.y,
transform: side === "top" ? "translate(-50%, -100%)" : "translate(-50%, 0)",
transform: resolvedSide === "top" ? "translate(-50%, -100%)" : "translate(-50%, 0)",
}}
>
<div className="px-2 py-1 rounded-md bg-neutral-800 border border-neutral-700/50 text-[10px] font-medium text-neutral-200 whitespace-nowrap shadow-lg">
<div
role="tooltip"
id={tooltipId}
className="px-2 py-1 rounded-md bg-neutral-800 border border-neutral-700/50 text-[10px] font-medium text-neutral-200 whitespace-nowrap shadow-lg"
>
{label}
</div>
</div>,
@@ -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" />;
@@ -0,0 +1,83 @@
// fallow-ignore-file unused-file
// (consumers land in the shell/sidebar PRs later in this stack)
import { useEffect, useCallback, useRef, type RefObject } from "react";
const FOCUSABLE =
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), summary, [contenteditable="true"], [tabindex]:not([tabindex="-1"])';
interface DialogBehaviorOptions {
open: boolean;
onClose: () => void;
containerRef: RefObject<HTMLElement | null>;
/**
* Return false to veto a close triggered by Escape/backdrop (e.g. a dirty
* draft the user hasn't submitted). Direct onClose calls are not guarded.
*/
canClose?: () => boolean;
}
/**
* Shared dialog contract for the studio's custom modals: document-level
* Escape, Tab focus trap, focus-first-control on open, focus restore on close.
* The consumer still renders its own markup and should set role="dialog" and
* aria-modal="true" on the container.
*/
export function useDialogBehavior({
open,
onClose,
containerRef,
canClose,
}: DialogBehaviorOptions) {
const restoreRef = useRef<HTMLElement | null>(null);
const canCloseRef = useRef(canClose);
canCloseRef.current = canClose;
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
const requestClose = useCallback(() => {
const guard = canCloseRef.current;
if (guard && !guard()) return;
onCloseRef.current();
}, []);
useEffect(() => {
if (!open) return;
const previouslyFocused = document.activeElement;
restoreRef.current = previouslyFocused instanceof HTMLElement ? previouslyFocused : null;
const container = containerRef.current;
const first = container?.querySelector<HTMLElement>(FOCUSABLE);
(first ?? container)?.focus();
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
requestClose();
return;
}
if (e.key !== "Tab") return;
const el = containerRef.current;
if (!el) return;
const focusables = Array.from(el.querySelectorAll<HTMLElement>(FOCUSABLE));
if (focusables.length === 0) return;
const firstEl = focusables[0];
const lastEl = focusables[focusables.length - 1];
const active = document.activeElement;
if (e.shiftKey && (active === firstEl || !el.contains(active))) {
e.preventDefault();
lastEl.focus();
} else if (!e.shiftKey && (active === lastEl || !el.contains(active))) {
e.preventDefault();
firstEl.focus();
}
};
document.addEventListener("keydown", onKeyDown, true);
return () => {
document.removeEventListener("keydown", onKeyDown, true);
restoreRef.current?.focus();
restoreRef.current = null;
};
}, [open, containerRef, requestClose]);
return { requestClose };
}
+61
View File
@@ -251,3 +251,64 @@ body {
background: linear-gradient(90deg, #06e3fa, #4fdb5e);
transition: transform 160ms ease;
}
/* Toast enter/exit (StudioToast) */
@keyframes hf-toast-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes hf-toast-out {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(8px);
}
}
.hf-toast-enter {
animation: hf-toast-in 160ms ease-out;
}
.hf-toast-exit {
animation: hf-toast-out 160ms ease-in forwards;
}
@media (prefers-reduced-motion: reduce) {
.hf-toast-enter,
.hf-toast-exit {
animation: none;
}
.hf-toast-exit {
opacity: 0;
}
}
/* Overlay/backdrop entrance — the heaviest visual changes shouldn't pop in */
@keyframes hf-backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.hf-backdrop-in {
animation: hf-backdrop-in 150ms ease-out;
}
@media (prefers-reduced-motion: reduce) {
.hf-backdrop-in {
animation: none;
}
}