Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,21 @@
// rest pose). Each asserts a distinct keyframe shape; collapsing the shared
// parse/keyframes-extraction scaffold would obscure what each case verifies.
"packages/core/src/parsers/gsapParserAcorn.motionEval.test.ts",
// Studio UX-review sweep (148 findings fixed across ~90 studio files):
// heavily-edited files re-flag line-shifted inherited complexity, and the
// new small handlers (a11y keydown/menu/dialog/error-state paths, mostly
// 5-6 cyclomatic) trip the CRAP threshold absent coverage data. Reviewed
// individually in the studio-ux PR stack rather than refactored here.
"packages/studio/src/player/components/TimelineClipDiamonds.test.tsx",
"packages/studio/src/hooks/useFileManager.ts",
"packages/studio/src/captions/hooks/useCaptionSync.ts",
"packages/studio/vite.adapter.ts",
"packages/studio/src/components/sidebar/AudioRow.tsx",
"packages/studio/src/components/sidebar/AssetsTab.tsx",
"packages/studio/src/components/editor/propertyPanelFill.tsx",
"packages/studio/src/captions/store.ts",
"packages/studio/src/captions/components/CaptionOverlay.tsx",
"packages/cli/src/server/studioServer.ts",
],
},
"health": {
Expand Down Expand Up @@ -484,6 +499,54 @@
// This is the correct shape for a discriminating type guard; refactoring
// into smaller guards would obscure the unified validation contract.
"packages/core/src/figma/manifest.ts",
// Studio UX-review sweep (148 findings fixed across ~90 studio files):
// heavily-edited files re-flag line-shifted inherited complexity, and the
// new small handlers (a11y keydown/menu/dialog/error-state paths, mostly
// 5-6 cyclomatic) trip the CRAP threshold absent coverage data. Reviewed
// individually in the studio-ux PR stack rather than refactored here.
"packages/studio/src/App.tsx",
"packages/studio/src/captions/components/CaptionAnimationPanel.tsx",
"packages/studio/src/captions/components/CaptionOverlay.tsx",
"packages/studio/src/captions/components/CaptionOverlayUtils.ts",
"packages/studio/src/captions/components/CaptionPropertyPanel.tsx",
"packages/studio/src/captions/components/shared.tsx",
"packages/studio/src/captions/hooks/useCaptionSync.ts",
"packages/studio/src/components/AskAgentModal.tsx",
"packages/studio/src/components/editor/BlockParamsPanel.tsx",
"packages/studio/src/components/editor/DomEditOverlay.tsx",
"packages/studio/src/components/editor/FileTree.tsx",
"packages/studio/src/components/editor/FileTreeNodes.tsx",
"packages/studio/src/components/editor/LayersPanel.tsx",
"packages/studio/src/components/editor/MotionPathNode.tsx",
"packages/studio/src/components/editor/PropertyPanel.tsx",
"packages/studio/src/components/editor/propertyPanelFont.tsx",
"packages/studio/src/components/editor/propertyPanelMediaSection.tsx",
"packages/studio/src/components/editor/propertyPanelStyleSections.tsx",
"packages/studio/src/components/editor/Transform3DCube.tsx",
"packages/studio/src/components/LintModal.tsx",
"packages/studio/src/components/MediaPreview.tsx",
"packages/studio/src/components/nle/NLELayout.tsx",
"packages/studio/src/components/nle/NLEPreview.tsx",
"packages/studio/src/components/sidebar/AudioRow.tsx",
"packages/studio/src/components/sidebar/BlocksTab.tsx",
"packages/studio/src/components/sidebar/CompositionsTab.tsx",
"packages/studio/src/components/sidebar/LeftSidebar.tsx",
"packages/studio/src/components/storyboard/StoryboardLoaded.tsx",
"packages/studio/src/components/StudioPreviewArea.tsx",
"packages/studio/src/components/StudioRightPanel.tsx",
"packages/studio/src/components/StudioToast.tsx",
"packages/studio/src/components/ui/Tooltip.tsx",
"packages/studio/src/components/ui/useDialogBehavior.ts",
"packages/studio/src/hooks/useAppHotkeys.ts",
"packages/studio/src/hooks/useCaptionDetection.ts",
"packages/studio/src/hooks/useFileManager.ts",
"packages/studio/src/hooks/useFrameCapture.ts",
"packages/studio/src/hooks/usePanelLayout.ts",
"packages/studio/src/player/components/menuKeyboardNav.ts",
"packages/studio/src/player/components/Timeline.tsx",
"packages/studio/src/player/components/TimelineCanvas.tsx",
"packages/studio/src/player/components/TimelineClip.tsx",
"packages/studio/src/player/components/TimelineClipDiamonds.tsx",
],
},
}
31 changes: 20 additions & 11 deletions packages/studio/src/components/ui/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(" "),
};

Expand All @@ -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>(
(
{
Expand All @@ -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,
Expand Down Expand Up @@ -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 (
Expand All @@ -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,
Expand Down
11 changes: 9 additions & 2 deletions packages/studio/src/components/ui/HyperframesLoader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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})` }}
Expand All @@ -95,6 +101,7 @@ export function HyperframesLoader({
);
}

// fallow-ignore-next-line unused-export
export function StatusFrame(props: HyperframesLoaderProps) {
return (
<div className="hf-frame">
Expand Down
53 changes: 53 additions & 0 deletions packages/studio/src/components/ui/SearchInput.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
58 changes: 52 additions & 6 deletions packages/studio/src/components/ui/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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);
Expand All @@ -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 &&
Expand All @@ -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>,
Expand Down
29 changes: 26 additions & 3 deletions packages/studio/src/components/ui/VideoFrameThumbnail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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" />;
Expand Down
Loading
Loading