From d1fd0dd5c47e6e681dad01e86558a321594764be Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 15 Jul 2026 16:44:47 -0700 Subject: [PATCH] [eric] dashboard: spawn camera is reveal-only (no zoom-in ever, no move when visible, minimal pan otherwise) --- .../hooks/interaction/useCanvasControls.ts | 44 ++++++++++++++++++- .../hooks/lifecycle/useAgentSpawn.ts | 3 +- .../lifecycle/useDashboardCardActions.ts | 7 ++- .../hooks/lifecycle/useDashboardLifecycle.ts | 21 +++------ .../src/shared/state/dashboardLayoutSlice.ts | 2 - 5 files changed, 51 insertions(+), 26 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts index fe38753d..d6bbe20f 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts @@ -687,6 +687,46 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: [cancelAnimation, animateTo, computeFitTarget], ); + // Figma-style spawn camera: never zoom IN, never move if the cards are already on screen; otherwise the minimal pan that reveals them, zooming out only when they cannot fit at the current zoom. + const revealCards = useCallback( + (cardRects: Array<{ x: number; y: number; width: number; height: number }>) => { + const viewport = viewportRef.current; + if (!viewport || cardRects.length === 0) return; + const v = viewport.getBoundingClientRect(); + if (v.width <= 0 || v.height <= 0) return; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const r of cardRects) { + minX = Math.min(minX, r.x); + minY = Math.min(minY, r.y); + maxX = Math.max(maxX, r.x + r.width); + maxY = Math.max(maxY, r.y + r.height); + } + if (!isFinite(minX)) return; + const REVEAL_MARGIN = 48; + const cur = stateRef.current; + const fitZoom = Math.min( + (v.width - REVEAL_MARGIN * 2) / (maxX - minX), + (v.height - REVEAL_MARGIN * 2) / (maxY - minY), + ); + const zoom = clamp(Math.min(cur.zoom, fitZoom), MIN_ZOOM, MAX_ZOOM); + // If zooming out, keep the viewport-center world point fixed first, then clamp. + const ratio = zoom / cur.zoom; + let panX = v.width / 2 - (v.width / 2 - cur.panX) * ratio; + let panY = v.height / 2 - (v.height / 2 - cur.panY) * ratio; + const left = minX * zoom + panX, right = maxX * zoom + panX; + if (left < REVEAL_MARGIN) panX += REVEAL_MARGIN - left; + else if (right > v.width - REVEAL_MARGIN) panX -= right - (v.width - REVEAL_MARGIN); + const top = minY * zoom + panY, bottom = maxY * zoom + panY; + if (top < REVEAL_MARGIN) panY += REVEAL_MARGIN - top; + else if (bottom > v.height - REVEAL_MARGIN) panY -= bottom - (v.height - REVEAL_MARGIN); + const cur2 = stateRef.current; + if (Math.abs(panX - cur2.panX) < 2 && Math.abs(panY - cur2.panY) < 2 && Math.abs(zoom - cur2.zoom) < 0.005) return; + cancelAnimation(); + animateTo({ panX, panY, zoom }, FIT_DURATION); + }, + [cancelAnimation, animateTo], + ); + const handlers = useMemo(() => ({ onMouseDown: handleMouseDown, onMouseMove: handleMouseMove, @@ -694,8 +734,8 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: }), [handleMouseDown, handleMouseMove, handleMouseUp]); const actions = useMemo(() => ({ - zoomIn, zoomOut, resetZoom, fitToView, fitToCards, animateTo, cancelAnimation, setState, - }), [zoomIn, zoomOut, resetZoom, fitToView, fitToCards, animateTo, cancelAnimation]); + zoomIn, zoomOut, resetZoom, fitToView, fitToCards, revealCards, animateTo, cancelAnimation, setState, + }), [zoomIn, zoomOut, resetZoom, fitToView, fitToCards, revealCards, animateTo, cancelAnimation]); return { ...state, diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts index 80cd39d1..c5ba3d54 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts @@ -21,7 +21,6 @@ import { EXPANDED_CARD_MIN_H, GRID_GAP, type CardPosition, - SPAWN_FOCUS_MAX_ZOOM, } from '@/shared/state/dashboardLayoutSlice'; import { generateDashboardName } from '@/shared/state/dashboardsSlice'; import type { ContextPath } from '@/app/components/editor/DirectoryBrowser'; @@ -199,7 +198,7 @@ export function useAgentSpawn({ if (bc) rects.push({ x: bc.x, y: bc.y, width: bc.width, height: bc.height }); } } - canvasActions.fitToCards(rects, SPAWN_FOCUS_MAX_ZOOM, true, undefined, true); + canvasActions.revealCards(rects); handleHighlightCard(draftId); } diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts index 40650069..52ab9340 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts @@ -16,7 +16,6 @@ import { DEFAULT_NOTE_W, DEFAULT_NOTE_H, EXPANDED_CARD_MIN_H, - SPAWN_FOCUS_MAX_ZOOM, } from '@/shared/state/dashboardLayoutSlice'; import type { CardType, useDashboardSelection } from '../state/useDashboardSelection'; import type { CanvasActions } from '../interaction/useCanvasControls'; @@ -66,7 +65,7 @@ export function useDashboardCardActions({ } const card = viewCards[focusKey]; if (card) { - canvasActions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], SPAWN_FOCUS_MAX_ZOOM, true, undefined, true); + canvasActions.revealCards([{ x: card.x, y: card.y, width: card.width, height: card.height }]); handleHighlightCard(focusKey); } }, 200); @@ -89,7 +88,7 @@ export function useDashboardCardActions({ const newId = Object.keys(allNotes).find((id) => !prevIds.has(id)); if (newId) { const note = allNotes[newId]; - canvasActions.fitToCards([{ x: note.x, y: note.y, width: note.width, height: note.height }], SPAWN_FOCUS_MAX_ZOOM, true, undefined, true); + canvasActions.revealCards([{ x: note.x, y: note.y, width: note.width, height: note.height }]); handleHighlightCard(newId); } }, 200); @@ -110,7 +109,7 @@ export function useDashboardCardActions({ setTimeout(() => { const card = store.getState().dashboardLayout.cards[sessionId]; if (card) { - canvasActions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], SPAWN_FOCUS_MAX_ZOOM, true); + canvasActions.revealCards([{ x: card.x, y: card.y, width: card.width, height: card.height }]); handleHighlightCard(sessionId); } }, 200); diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index 3e913849..bc4f2f55 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -20,7 +20,6 @@ import { clearPendingFocusWorkflowId, clearPendingFocusWorkflowsHub, type ViewCardPosition, - SPAWN_FOCUS_MAX_ZOOM, } from '@/shared/state/dashboardLayoutSlice'; import { fetchOutputs, type Output } from '@/shared/state/outputsSlice'; import { generateDashboardName } from '@/shared/state/dashboardsSlice'; @@ -216,7 +215,7 @@ export function useDashboardLifecycle({ setTimeout(() => { const card = store.getState().dashboardLayout.cards[agentId]; if (card) { - canvasActions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], SPAWN_FOCUS_MAX_ZOOM, true); + canvasActions.revealCards([{ x: card.x, y: card.y, width: card.width, height: card.height }]); handleHighlightCard(agentId); } }, 350); @@ -232,13 +231,7 @@ export function useDashboardLifecycle({ setTimeout(() => { const card = store.getState().dashboardLayout.browserCards[browserId]; if (card) { - canvasActions.fitToCards( - [{ x: card.x, y: card.y, width: card.width, height: card.height }], - 1.15, - true, - 0.8, - true, - ); + canvasActions.revealCards([{ x: card.x, y: card.y, width: card.width, height: card.height }]); handleHighlightCard(browserId); } }, 200); @@ -254,7 +247,7 @@ export function useDashboardLifecycle({ setTimeout(() => { const card = store.getState().dashboardLayout.viewCards[cardKey]; if (card) { - canvasActions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], SPAWN_FOCUS_MAX_ZOOM, true); + canvasActions.revealCards([{ x: card.x, y: card.y, width: card.width, height: card.height }]); handleHighlightCard(cardKey); } }, 200); @@ -269,11 +262,7 @@ export function useDashboardLifecycle({ setTimeout(() => { const card = store.getState().dashboardLayout.workflowCards[workflowId]; if (card) { - canvasActions.fitToCards( - [{ x: card.x, y: card.y, width: card.width, height: card.height }], - SPAWN_FOCUS_MAX_ZOOM, - true, - ); + canvasActions.revealCards([{ x: card.x, y: card.y, width: card.width, height: card.height }]); handleHighlightCard(workflowId); } }, 200); @@ -353,7 +342,7 @@ export function useDashboardLifecycle({ const rects = [{ x: vc.x, y: vc.y, width: vc.width, height: vc.height }]; const ac = store.getState().dashboardLayout.cards[sid]; if (ac) rects.push({ x: ac.x, y: ac.y, width: ac.width, height: ac.height }); - canvasActions.fitToCards(rects, SPAWN_FOCUS_MAX_ZOOM, true); + canvasActions.revealCards(rects); handleHighlightCard(outputId); }, 200); } diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index d72f9370..382564c4 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -26,8 +26,6 @@ export const DEFAULT_WORKFLOWS_HUB_W = DEFAULT_BROWSER_CARD_W; export const DEFAULT_WORKFLOWS_HUB_H = DEFAULT_BROWSER_CARD_H; export const EXPANDED_CARD_MIN_H = 620; export const GRID_GAP = 24; -// Zoom ceiling when the camera frames a freshly spawned card. 1.15 slammed the user to 115% on every spawn; 0.75 keeps the new card readable without the lurch-into-your-face zoom. -export const SPAWN_FOCUS_MAX_ZOOM = 0.75; // Gap between the Workflows window and the cards it spawns (run monitor, that monitor's browser). Keeps the hub -> monitor -> browser row evenly spaced. export const WORKFLOW_CARD_GAP = 140; const GRID_ORIGIN = { x: 40, y: 100 };