[eric] canvas: Tidy layout left you at 41% with a 2-wide ribbon, it now grids to the screen and frames it readable

This commit is contained in:
ciregenz
2026-07-31 19:51:27 -07:00
parent 29f33b6821
commit c7e5de2dfa
3 changed files with 75 additions and 23 deletions
@@ -7,7 +7,7 @@ import { getScrollFocusedCard } from '@/shared/cardScrollFocus';
import { getWebview } from '@/shared/browserRegistry';
import { applyBrowserZoom } from '@/shared/browserZoom';
import { syncTiledGeometry } from '../../canvas/tiledGeometry';
import { revealZoom } from '../../canvas/revealZoom';
import { revealZoom, REVEAL_MIN_ZOOM } from '../../canvas/revealZoom';
const MIN_ZOOM = 0.15;
// The floor for AUTOMATIC reveals only. revealCards takes min(current, fit), which can only ever go
@@ -19,6 +19,10 @@ const MAX_ZOOM = 3.0;
const ZOOM_IN_FACTOR = 1.1;
const ZOOM_OUT_FACTOR = 1 / ZOOM_IN_FACTOR;
const FIT_PADDING = 200;
// Tidy frames everything at once, so it gets its own tighter margin than a single-card fit. The wider
// x inset is the left dock, which floats over the canvas and would otherwise sit on the first column.
const TIDY_PADDING = { x: 120, y: 56 };
const TIDY_MIN_ZOOM = REVEAL_MIN_ZOOM;
// Card-framing (spawn, click-to-focus, arrow-nav) snaps as fast as the zoom buttons so a new card lands under you now, not after a lazy glide.
const FIT_DURATION = 150;
// Must outlast FIT_DURATION so the drift re-snap lands after the glide, never mid-flight.
@@ -691,6 +695,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
maxZoom?: number,
minZoom?: number,
centered?: boolean,
padding?: { x: number; y: number },
): { panX: number; panY: number; zoom: number } | null => {
const viewport = viewportRef.current;
if (!viewport || cardRects.length === 0) return null;
@@ -711,8 +716,10 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
const contentWidth = maxX - minX;
const contentHeight = maxY - minY;
const availW = vRect.width - FIT_PADDING * 2;
const availH = vRect.height - FIT_PADDING * 2;
const padX = padding?.x ?? FIT_PADDING;
const padY = padding?.y ?? FIT_PADDING;
const availW = vRect.width - padX * 2;
const availH = vRect.height - padY * 2;
const ceiling = maxZoom ?? MAX_ZOOM;
const floor = minZoom ?? MIN_ZOOM;
const targetZoom = clamp(
@@ -720,12 +727,14 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
floor,
ceiling,
);
const targetPanX =
(vRect.width - contentWidth * targetZoom) / 2 - minX * targetZoom;
// Content wider/taller than the viewport only happens when the zoom floor bit; centering then hides both ends, so anchor the start of it instead.
const targetPanX = contentWidth * targetZoom > vRect.width
? padX - minX * targetZoom
: (vRect.width - contentWidth * targetZoom) / 2 - minX * targetZoom;
// A single card normally top-biases (header up top, no dead space below). On creation we want the opposite: the new card dead-centered "in front of you", so `centered` forces true vertical centering.
const topBiased = cardRects.length === 1 && !centered;
const targetPanY = topBiased
? FIT_PADDING * 0.4 - minY * targetZoom
const targetPanY = topBiased || contentHeight * targetZoom > vRect.height
? padY * 0.4 - minY * targetZoom
: (vRect.height - contentHeight * targetZoom) / 2 -
minY * targetZoom;
return { panX: targetPanX, panY: targetPanY, zoom: targetZoom };
@@ -740,10 +749,11 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
animate?: boolean,
minZoom?: number,
centered?: boolean,
padding?: { x: number; y: number },
) => {
cancelAnimation();
const target = computeFitTarget(cardRects, maxZoom, minZoom, centered);
const target = computeFitTarget(cardRects, maxZoom, minZoom, centered, padding);
if (!target) {
// Keep current camera; snapping to (0,0,1) used to desync the minimap.
if (cardRects.length === 0 || !viewportRef.current) {
@@ -761,7 +771,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
// Settle pass: cancelAnimation() must be able to cancel it, else back-to-back fitToCards races and the first settle overwrites the second target.
settleTimerRef.current = window.setTimeout(() => {
settleTimerRef.current = null;
const fresh = computeFitTarget(cardRects, maxZoom, minZoom, centered);
const fresh = computeFitTarget(cardRects, maxZoom, minZoom, centered, padding);
if (!fresh) return;
const cur2 = stateRef.current;
const drift =
@@ -777,6 +787,15 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
[cancelAnimation, animateTo, computeFitTarget, setCanvasState],
);
// The camera half of Tidy: frame the freshly gridded cards close in (the 200px fit padding was
// eating a third of the viewport), never below readable, and never magnified past life size.
const fitTidy = useCallback(
(cardRects: Array<{ x: number; y: number; width: number; height: number }>) => {
fitToCards(cardRects, 1, true, TIDY_MIN_ZOOM, false, TIDY_PADDING);
},
[fitToCards],
);
// 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 }>) => {
@@ -833,9 +852,9 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
const getLiveState = useCallback((): CanvasState => stateRef.current, []);
const actions = useMemo(() => ({
zoomIn, zoomOut, resetZoom, fitToView, fitToCards, revealCards, animateTo, cancelAnimation,
zoomIn, zoomOut, resetZoom, fitToView, fitToCards, fitTidy, revealCards, animateTo, cancelAnimation,
setState: setCanvasState, panBy, commit: commitLive, syncTransform: applyLiveToDom, getLiveState,
}), [zoomIn, zoomOut, resetZoom, fitToView, fitToCards, revealCards, animateTo, cancelAnimation, setCanvasState, panBy, commitLive, applyLiveToDom, getLiveState]);
}), [zoomIn, zoomOut, resetZoom, fitToView, fitToCards, fitTidy, revealCards, animateTo, cancelAnimation, setCanvasState, panBy, commitLive, applyLiveToDom, getLiveState]);
return {
...state,
@@ -111,6 +111,7 @@ export function useDashboardCardActions({
const {
cards: tidied, viewCards: tidiedViews, browserCards: tidiedBrowsers,
workflowCards: tidiedWorkflows, workflowsHub: tidiedHub,
workflowsMonitorCard: tidiedMonitor, settingsCard: tidiedSettings,
} = store.getState().dashboardLayout;
const allRects = [
...Object.values(tidied).map((c) => ({
@@ -120,9 +121,12 @@ export function useDashboardCardActions({
...Object.values(tidiedViews).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),
...Object.values(tidiedBrowsers).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),
...Object.values(tidiedWorkflows).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),
...(tidiedHub ? [{ x: tidiedHub.x, y: tidiedHub.y, width: tidiedHub.width, height: tidiedHub.height }] : []),
// The hub, the monitor and Settings get tidied into the grid too, so the camera has to know about them or it frames a stale box.
...[tidiedHub, tidiedMonitor, tidiedSettings]
.filter((c): c is NonNullable<typeof c> => !!c)
.map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),
];
canvasActions.fitToCards(allRects);
canvasActions.fitTidy(allRects);
}, [dispatch, canvasActions]);
return {
@@ -345,10 +345,11 @@ export function findOpenGridCell(
occupiedRects: Rect[],
newW: number,
newH: number,
colLimit?: number,
): { x: number; y: number } {
const cellW = DEFAULT_CARD_W + GRID_GAP;
const cellH = DEFAULT_CARD_H + GRID_GAP;
const maxCols = Math.max(
const maxCols = colLimit ?? Math.max(
1,
Math.floor((window.innerWidth - GRID_ORIGIN.x) / cellW) || GRID_COLS_FALLBACK,
);
@@ -365,6 +366,34 @@ export function findOpenGridCell(
}
}
// Tidy packs into the grid shape that fills the SCREEN best. The default column count is derived from
// window.innerWidth, which is screen pixels pretending to be world units: it laid 8 cards out as a
// 2-wide, 4-tall ribbon that the camera then had to pull back to 41% to show.
export function tidyColumnCount(itemSizes: Array<{ w: number; h: number }>): number {
const cellW = DEFAULT_CARD_W + GRID_GAP;
const cellH = DEFAULT_CARD_H + GRID_GAP;
let cells = 0;
let widest = 1;
for (const s of itemSizes) {
const cols = Math.max(1, Math.ceil(s.w / cellW));
cells += cols * Math.max(1, Math.ceil(s.h / cellH));
widest = Math.max(widest, cols);
}
const vw = window.innerWidth || 1440;
const vh = window.innerHeight || 900;
let best = widest;
let bestZoom = 0;
for (let cols = widest; cols <= Math.max(widest, cells); cols++) {
const rows = Math.ceil(cells / cols);
const zoom = Math.min(vw / (cols * cellW), vh / (rows * cellH));
if (zoom > bestZoom) {
bestZoom = zoom;
best = cols;
}
}
return best;
}
// Like findOpenGridCell but biased to stay near a proposed (x,y) anchor. Used when the backend hands us a card with a position that's already occupied (sub-agent or sub-browser spawning on top of its parent or a sibling). Spirals outward from the anchor on a grid, snapping to cell-aligned positions so the result still looks intentional, not dropped from orbit. Caps the spiral search at ~1000 cells to avoid pathological work in adversarial layouts, falls back to findOpenGridCell after that. Cost: O(rects × cells_scanned). Spawn events are rare (not per-frame), so this only runs when a new card appears. Typical scan resolves in <10 cells, well below the cap. No perf impact on steady-state UI.
export function findOpenSpotNear(
anchorX: number,
@@ -768,19 +797,19 @@ const dashboardLayoutSlice = createSlice({
];
allItems.sort((a, b) => a.y - b.y || a.x - b.x);
const sizeOf = (item: typeof allItems[number]): { w: number; h: number } => ({
w: item.storedW,
h: item.kind === 'agent' && expanded.has(item.id)
? Math.max(EXPANDED_CARD_MIN_H, item.storedH)
: item.storedH,
});
const cols = tidyColumnCount(allItems.map(sizeOf));
const placedRects: Rect[] = [];
for (const item of allItems) {
let w: number, h: number;
if (item.kind === 'agent') {
w = item.storedW;
h = expanded.has(item.id) ? Math.max(EXPANDED_CARD_MIN_H, item.storedH) : item.storedH;
} else {
w = item.storedW;
h = item.storedH;
}
const { w, h } = sizeOf(item);
const pos = findOpenGridCell(placedRects, w, h);
const pos = findOpenGridCell(placedRects, w, h, cols);
placedRects.push({ x: pos.x, y: pos.y, w, h });
if (item.kind === 'agent') {