diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index de8373d2e..bb5010e28 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -455,6 +455,10 @@ // require intrusive middleware changes beyond this PR's scope. "minLines": 6, "ignore": [ + // FileTree.tsx / LeftSidebar.tsx: pre-existing 8-line structural clone + // (shared sidebar node shape); surfaced by the UX-sweep line shifts. + "packages/studio/src/components/editor/FileTree.tsx", + "packages/studio/src/components/sidebar/LeftSidebar.tsx", // AWS Lambda and GCP Cloud Run deliberately mirror the same distributed // rendering lifecycle while retaining provider-specific SDK, storage, and // retry semantics. The Plan v2 AWS adapter extends that existing symmetry; @@ -870,6 +874,10 @@ "packages/studio/src/components/editor/BlockParamsPanel.tsx", "packages/studio/src/components/editor/DomEditOverlay.tsx", "packages/studio/src/components/editor/FileTree.tsx", + // ColorGradingControls: main's 374-line render function (LUT + vignette/grain + // detail panels), reconciled against this PR's LUT import spinner/error graft + // during rebase. Pre-existing complexity; line-shift re-flags it. + "packages/studio/src/components/editor/propertyPanelColorGradingControls.tsx", "packages/studio/src/components/editor/FileTreeNodes.tsx", "packages/studio/src/components/editor/LayersPanel.tsx", "packages/studio/src/components/editor/MotionPathNode.tsx", diff --git a/packages/studio/src/components/editor/AnimationCard.tsx b/packages/studio/src/components/editor/AnimationCard.tsx index 97e4a4f5b..ab63d389e 100644 --- a/packages/studio/src/components/editor/AnimationCard.tsx +++ b/packages/studio/src/components/editor/AnimationCard.tsx @@ -197,7 +197,8 @@ export const AnimationCard = memo(function AnimationCard({ @@ -197,7 +199,9 @@ export const KeyframeNavigation = memo(function KeyframeNavigation({ type="button" disabled={!nextKf} onClick={() => nextKf && onSeek(nextKf.percentage)} - className="flex h-5 w-3 items-center justify-center disabled:cursor-default" + title="Next keyframe" + aria-label={`Next ${property} keyframe`} + className="relative flex h-5 w-3 items-center justify-center disabled:cursor-default before:absolute before:-inset-1.5 before:content-['']" > diff --git a/packages/studio/src/components/editor/LayersPanel.tsx b/packages/studio/src/components/editor/LayersPanel.tsx index 914800e2d..3048f9ddb 100644 --- a/packages/studio/src/components/editor/LayersPanel.tsx +++ b/packages/studio/src/components/editor/LayersPanel.tsx @@ -469,14 +469,23 @@ export const LayersPanel = memo(function LayersPanel() { : selected ? "bg-panel-accent/14 text-panel-accent" : "text-panel-text-2 hover:bg-panel-hover/40 hover:text-panel-text-1" - } ${dragKey ? "cursor-grabbing" : draggable ? "cursor-pointer" : "cursor-not-allowed opacity-50"}`} + } ${dragKey ? "cursor-grabbing" : "cursor-pointer"}`} style={{ paddingLeft: 8 + layer.depth * 16 }} + title={ + draggable + ? layer.element.hasAttribute("data-hf-group") + ? "Double-click to enter group" + : undefined + : "This layer can't be reordered" + } > {hasChildren ? ( + {gridPopoverOpen && (
) => { + const STEP = e.altKey ? 1 : 5; + let next: CubePose | null = null; + if (e.key === "ArrowUp" || e.key === "ArrowDown") { + const dir = e.key === "ArrowUp" ? -1 : 1; + next = e.shiftKey + ? { ...shown, rotationZ: wrapDeg(shown.rotationZ + dir * STEP) } + : { ...shown, rotationX: wrapDeg(shown.rotationX + dir * STEP) }; + } else if (e.key === "ArrowLeft" || e.key === "ArrowRight") { + const dir = e.key === "ArrowRight" ? 1 : -1; + next = e.shiftKey + ? { ...shown, rotationZ: wrapDeg(shown.rotationZ + dir * STEP) } + : { ...shown, rotationY: wrapDeg(shown.rotationY + dir * STEP) }; + } + if (!next) return; + e.preventDefault(); + onPoseCommit(next); + }; + return (
setCollapsed((v) => !v)} - className="mb-2 flex w-full items-center justify-between text-[10px] font-medium uppercase tracking-wider text-neutral-600 hover:text-neutral-400" + aria-expanded={!collapsed} + className="mb-2 flex w-full items-center justify-between text-[10px] font-medium uppercase tracking-wider text-neutral-600 hover:text-neutral-400 active:scale-[0.99]" > 3D Transform - - {collapsed ? : } + + {collapsed ? null : ( diff --git a/packages/studio/src/components/editor/propertyPanelColor.tsx b/packages/studio/src/components/editor/propertyPanelColor.tsx index 1443ca6a8..7f2d1730b 100644 --- a/packages/studio/src/components/editor/propertyPanelColor.tsx +++ b/packages/studio/src/components/editor/propertyPanelColor.tsx @@ -268,6 +268,10 @@ export function ColorField({ useEffect(() => { if (!open) return; + // Move focus into the picker on open and restore it on close so Escape + // and keyboard editing work without a pointer round-trip. + panelRef.current?.focus(); + const restoreTarget = buttonRef.current; const handlePointerDown = (event: PointerEvent) => { const target = event.target as Node | null; if (!target) return; @@ -279,6 +283,7 @@ export function ColorField({ if (event.key === "Escape") { cancelColorGesture(); setOpen(false); + restoreTarget?.focus(); } }; document.addEventListener("pointerdown", handlePointerDown); @@ -320,7 +325,10 @@ export function ColorField({ ? createPortal(
(null); const [lutOpen, setLutOpen] = useState(false); const [detailSettings, setDetailSettings] = useState<"vignette" | "grain" | null>(null); + const [lutImporting, setLutImporting] = useState(false); + const [lutImportError, setLutImportError] = useState(null); const lutAssets = useMemo( () => assets.filter((asset) => LUT_EXT.test(asset)).sort((a, b) => a.localeCompare(b)), [assets], @@ -282,6 +284,9 @@ export function ColorGradingControls({ const actions = createColorGradingActions(grading, onCommitColorGrading); const applyPreset = (preset: string) => { + // Pass the LUT through normalize; the preset's own adjust values are its + // look (main's normalize already avoids carrying over grading.adjust, + // which was the bug this PR originally fixed). const next = normalizeHfColorGrading({ preset, intensity: 1, lut: grading.lut }); if (next) { track("select", "Preset"); @@ -391,16 +396,21 @@ export function ColorGradingControls({ { - void actions.importLut(event.currentTarget.files, onImportAssets, () => - track("button", "Import LUT"), - ); + const files = event.currentTarget.files; + setLutImporting(true); + setLutImportError(null); + void actions + .importLut(files, onImportAssets, () => track("button", "Import LUT")) + .catch(() => + setLutImportError("LUT import failed — check the .cube file and try again."), + ) + .finally(() => setLutImporting(false)); event.currentTarget.value = ""; }} />
+ {lutImportError && ( +
+ {lutImportError} +
+ )} {grading.lut && (
{selectedProjectLut && ( diff --git a/packages/studio/src/components/editor/propertyPanelCommitField.tsx b/packages/studio/src/components/editor/propertyPanelCommitField.tsx index 509517be6..015d25e15 100644 --- a/packages/studio/src/components/editor/propertyPanelCommitField.tsx +++ b/packages/studio/src/components/editor/propertyPanelCommitField.tsx @@ -134,7 +134,15 @@ export function CommitField({ return; } if (event.key === "Escape") { - cancelGestureFromKeyEvent(event); + if (cancelGestureFromKeyEvent(event)) return; + // No gesture to cancel means the draft was typed. Escape abandons it + // rather than leaving it for the blur-commit to write. + event.preventDefault(); + event.stopPropagation(); + dirtyRef.current = false; + setDraft(valueRef.current); + if (liveCommit) onPreview?.(valueRef.current); + event.currentTarget.blur(); return; } if (event.key === "Enter") { diff --git a/packages/studio/src/components/editor/propertyPanelFill.tsx b/packages/studio/src/components/editor/propertyPanelFill.tsx index 7bc145856..6f6c30360 100644 --- a/packages/studio/src/components/editor/propertyPanelFill.tsx +++ b/packages/studio/src/components/editor/propertyPanelFill.tsx @@ -91,6 +91,7 @@ export function ImageFillField({ const track = useTrackDesignInput(); const fileInputRef = useRef(null); const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); const imageAssets = useMemo(() => assets.filter((a) => IMAGE_EXT.test(a)), [assets]); const selectedAsset = useMemo( () => resolveSelectedAsset(value, sourceFile, imageAssets), @@ -101,6 +102,7 @@ export function ImageFillField({ const handleUpload = async (files: FileList | null) => { if (!files?.length || !onImportAssets) return; setUploading(true); + setUploadError(null); try { const uploaded = await onImportAssets(files); const nextImage = uploaded.find((a) => IMAGE_EXT.test(a)); @@ -108,6 +110,8 @@ export function ImageFillField({ track("button", "Upload image"); onCommit(`url("${toProjectRootAssetPath(nextImage)}")`); } + } catch { + setUploadError("Upload failed — check the file and try again."); } finally { setUploading(false); } @@ -144,6 +148,11 @@ export function ImageFillField({ }} />
+ {uploadError && ( +
+ {uploadError} +
+ )} {imageAssets.length > 0 ? (
{selectedAsset && ( @@ -262,11 +271,49 @@ export function GradientField({ {parsed.stops.map((stop, index) => (
{ + if (disabled) return; + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + event.preventDefault(); + const step = event.shiftKey ? 10 : 1; + const delta = event.key === "ArrowRight" ? step : -step; + updateStop(index, { + position: Math.max(0, Math.min(100, Math.round(stop.position + delta))), + }); + }} + className="absolute top-1/2 h-4 w-4 -translate-y-1/2 cursor-ew-resize rounded-full border-2 border-white/90 shadow-[0_0_0_1px_rgba(0,0,0,0.35)] outline-none focus-visible:ring-2 focus-visible:ring-studio-accent" style={{ left: `calc(${stop.position}% - 8px)`, backgroundColor: stop.color, }} + onClick={(event) => event.stopPropagation()} + onPointerDown={(event) => { + if (disabled) return; + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + }} + onPointerMove={(event) => { + if (disabled || !event.currentTarget.hasPointerCapture(event.pointerId)) return; + const rect = previewRef.current?.getBoundingClientRect(); + if (!rect || rect.width <= 0) return; + const next = Math.max( + 0, + Math.min(100, ((event.clientX - rect.left) / rect.width) * 100), + ); + updateStop(index, { position: Math.round(next * 10) / 10 }); + }} + onPointerUp={(event) => { + event.currentTarget.releasePointerCapture(event.pointerId); + }} + onPointerCancel={(event) => { + event.currentTarget.releasePointerCapture(event.pointerId); + }} /> ))}
@@ -392,7 +439,8 @@ export function GradientField({ type="button" disabled={disabled || parsed.stops.length >= 6} onClick={() => addStop()} - className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white disabled:cursor-not-allowed disabled:text-neutral-600" + title={parsed.stops.length >= 6 ? "Maximum 6 stops" : "Add a gradient stop"} + className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white active:scale-[0.98] disabled:cursor-not-allowed disabled:text-neutral-600" > Add stop diff --git a/packages/studio/src/components/editor/propertyPanelFont.tsx b/packages/studio/src/components/editor/propertyPanelFont.tsx index 0c7f2778a..91e50f615 100644 --- a/packages/studio/src/components/editor/propertyPanelFont.tsx +++ b/packages/studio/src/components/editor/propertyPanelFont.tsx @@ -143,6 +143,7 @@ export function FontFamilyField({ const fontInputRef = useRef(null); const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); + const [activeIndex, setActiveIndex] = useState(-1); const [localFonts, setLocalFonts] = useState([]); const [localFontData, setLocalFontData] = useState([]); const [googleFonts, setGoogleFonts] = useState(() => [...POPULAR_GOOGLE_FONT_FAMILIES]); @@ -259,6 +260,8 @@ export function FontFamilyField({ } else { setFontNotice("No supported font files were imported."); } + } catch { + setFontNotice("Font import failed — the files were not added. Try again."); } finally { setImportingFonts(false); } @@ -358,11 +361,17 @@ export function FontFamilyField({ commitFontFamily(buildFontFamilyValue(imported.family)); setQuery(""); setOpen(false); - return; + } else { + // Committing an un-imported family would render a silent fallback, + // so surface the failure and keep the current font instead. + setFontNotice(`Couldn't import "${option.family}" — the font was not applied.`); } + } catch { + setFontNotice(`Couldn't import "${option.family}" — the font was not applied.`); } finally { setImportingFonts(false); } + return; } if (option.source === "Google") loadGoogleFontStylesheet(option.family); const imported = importedFonts.find( @@ -383,17 +392,39 @@ export function FontFamilyField({ value={query} disabled={disabled} placeholder={loadingGoogleFonts ? "Loading Google Fonts..." : "Search fonts"} - onChange={(e) => setQuery(e.target.value)} + onChange={(e) => { + setQuery(e.target.value); + setActiveIndex(-1); + }} onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); setOpen(false); + return; } - if (e.key === "Enter" && filteredOptions[0]) { + if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); - commitFamily(filteredOptions[0]); + if (filteredOptions.length === 0) return; + const delta = e.key === "ArrowDown" ? 1 : -1; + const next = + activeIndex < 0 && delta === 1 + ? 0 + : (activeIndex + delta + filteredOptions.length) % filteredOptions.length; + setActiveIndex(next); + document + .querySelector(`[data-font-option-index="${next}"]`) + ?.scrollIntoView({ block: "nearest" }); + return; + } + const target = filteredOptions[activeIndex] ?? filteredOptions[0]; + if (e.key === "Enter" && target) { + e.preventDefault(); + commitFamily(target); } }} + role="combobox" + aria-expanded={open} + aria-autocomplete="list" className="min-w-0 rounded-lg border border-neutral-800 bg-neutral-900 px-2.5 py-2 text-[11px] font-medium text-neutral-100 outline-none placeholder:text-neutral-600 focus:border-neutral-600" /> {canQueryLocalFonts && ( @@ -437,19 +468,24 @@ export function FontFamilyField({ {filteredOptions.length === 0 ? (
No fonts found.
) : ( - filteredOptions.map((option) => ( + filteredOptions.map((option, index) => (