[eric] canvas: tiled cards restyle in the same task as every camera write (zoom jitter gone); chat header gets a hover runway so the pop-above handle is actually grabbable

This commit is contained in:
ciregenz
2026-07-28 13:16:26 -07:00
parent d97dd98911
commit 1a0a2c0b2c
6 changed files with 56 additions and 6 deletions
@@ -641,7 +641,7 @@ const AgentCard: React.FC<Props> = ({
}, [tileZone]);
void tileTick;
const cam = getCanvasState();
const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom);
const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom, getCanvasState, session.id);
// A collapsed chat can never stay tiled: collapsing while fullscreen left a white full-window shell
// (the header collapse control still fires in full size view). Seal the state instead of the path.
useEffect(() => {
@@ -792,6 +792,19 @@ const AgentCard: React.FC<Props> = ({
}}
sx={{
position: 'relative',
// Hover runway for the pop-above header: the header is pointer-events:none until the CARD
// is hovered, but it floats ABOVE the card's box, so without this strip the pointer leaving
// the card to reach it dropped :hover and the header died mid-approach (chats ungrabbable).
...(expanded && !tiledStyle && !pillMode && {
'&::before': {
content: '""',
position: 'absolute',
left: 0,
right: 0,
top: -40,
height: 40,
},
}),
// contain: streaming chat updates inside don't reflow the dashboard. Skipping `paint` here because the highlighted/selected/glow boxShadows legitimately extend past the card border, `paint` containment would clip those visuals.
contain: 'layout style',
// Each card gets its own compositor layer; hover-cross used to cost 100-200ms PRESENTATION by re-painting the whole canvas.
@@ -234,7 +234,7 @@ const BrowserCard: React.FC<Props> = ({
}, [tileZone]);
void tileTick;
const cam = getCanvasState();
const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom);
const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom, getCanvasState, browserId);
const onTile = useCallback((zone: string): void => {
if (zone === 'restore') dispatch(clearTiledCard(browserId));
else dispatch(setTiledCard({ cardId: browserId, zone }));
@@ -164,7 +164,7 @@ const DashboardViewCard: React.FC<Props> = ({
}, [tileZone]);
void tileTick;
const cam = getCanvasState();
const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom);
const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom, getCanvasState, cardKey);
const isFullscreen = tileZone === 'fullscreen';
// Keep the live preview mounted only when the user can actually see/use this app card. Always live
@@ -270,7 +270,7 @@ const NoteCard: React.FC<Props> = ({
}, [tileZone]);
void tileTick;
const cam = getCanvasState();
const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom);
const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom, getCanvasState, noteId);
const isFullscreen = tileZone === 'fullscreen';
const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0;
@@ -78,7 +78,17 @@ export function computeTiledStyle(zone: string, panX: number, panY: number, zoom
// Tiled geometry depends on live DOM measurements, so re-render when the workspace resizes:
// the chrome collapsing on fullscreen-enter, a window resize, a banner appearing. The initial
// ResizeObserver fire also re-measures right after the same-commit layout change that set the zone.
export function useTiledStyle(zone: string | undefined, panX: number, panY: number, zoom: number): TiledStyle | null {
export function useTiledStyle(
zone: string | undefined,
panX: number,
panY: number,
zoom: number,
// Live sync: with these, every camera write restyles the card element IN THE SAME TASK as the
// canvas transform. The React path alone lands a commit behind the compositor, so tiled cards
// visibly wobbled against the viewport on every zoom/pan frame.
getLive?: () => { panX: number; panY: number; zoom: number },
selectId?: string,
): TiledStyle | null {
const [, bump] = React.useReducer((n: number) => n + 1, 0);
React.useEffect(() => {
if (!zone) return undefined;
@@ -94,5 +104,32 @@ export function useTiledStyle(zone: string | undefined, panX: number, panY: numb
const timers = [60, 250, 700].map((ms) => window.setTimeout(() => bump(), ms));
return () => { ro.disconnect(); window.removeEventListener('resize', onResize); timers.forEach((t) => window.clearTimeout(t)); };
}, [zone]);
React.useEffect(() => {
if (!zone || !getLive || !selectId) return undefined;
const el = document.querySelector(`[data-select-id="${CSS.escape(selectId)}"]`) as HTMLElement | null;
if (!el) return undefined;
const props = ['left', 'top', 'width', 'height', 'transform', 'transform-origin'];
const apply = (): void => {
// A parked card (kept alive off-screen / minimized) must keep its sx parking position.
if (el.getAttribute('data-keepalive-hidden') === '1') { props.forEach((pr) => el.style.removeProperty(pr)); return; }
const cam = getLive();
const s = computeTiledStyle(zone, cam.panX, cam.panY, cam.zoom);
if (!s) return;
el.style.left = `${s.left}px`;
el.style.top = `${s.top}px`;
el.style.width = `${s.width}px`;
el.style.height = `${s.height}px`;
el.style.transform = s.transform;
el.style.transformOrigin = s.transformOrigin;
};
apply();
window.addEventListener('openswarm:canvas-pan-changed', apply);
window.addEventListener('resize', apply);
return () => {
window.removeEventListener('openswarm:canvas-pan-changed', apply);
window.removeEventListener('resize', apply);
props.forEach((pr) => el.style.removeProperty(pr));
};
}, [zone, getLive, selectId]);
return zone ? computeTiledStyle(zone, panX, panY, zoom) : null;
}
@@ -65,7 +65,7 @@ const WorkflowsAppCard: React.FC<Props> = ({
return () => window.removeEventListener('openswarm:canvas-pan-changed', onPan);
}, [isFullscreen]);
const cam = getCanvasState();
const fsStyle = useTiledStyle(isFullscreen ? 'fullscreen' : undefined, cam.panX, cam.panY, cam.zoom);
const fsStyle = useTiledStyle(isFullscreen ? 'fullscreen' : undefined, cam.panX, cam.panY, cam.zoom, getCanvasState, 'workflows-hub');
// ---- Drag (title bar is the handle) ----