diff --git a/.agents/inbox/2026-10-27-motion-sampling-needs-camera-readiness.md b/.agents/inbox/2026-10-27-motion-sampling-needs-camera-readiness.md new file mode 100644 index 00000000..fbc26e74 --- /dev/null +++ b/.agents/inbox/2026-10-27-motion-sampling-needs-camera-readiness.md @@ -0,0 +1,48 @@ +# Idle motion sampling must wait for completed edits and camera resize + +Date: 2026-10-27. Issue #208. The symptom index already covers explicit camera +ownership and LOD transition interruption, but neither establishes when a rotated +viewport is ready for an idle-motion measurement. + +## Evidence + +OAuth PR #202 CI run `40597251568` failed the phone motion case with a horizontal +span of `185.395922` pixels against the unchanged one-pixel bound. Its retained +trace and 1,021 frame samples show one common camera transform change at sample +index 2, followed by 1,019 identical frames. Source-node coordinates stayed +unchanged. Sampling began about 6.8 ms after viewport resizing returned, before +the queued ResizeObserver/frame callback completed. The preceding type edit was +also still pending. This evidence identifies an unfinished setup transition, +not sustained idle physics motion. + +The renderer now exposes `cameraReady` alongside the existing physics `atRest` +metric. Readiness requires the measured camera viewport to match the current +container, no queued resize frame, and no active SVG camera transition. The +existing resize behavior and physics definition are preserved. + +The browser case verifies the edit through an independent API read and rendered +type badge. It deliberately holds real graph ResizeObserver delivery during +phone rotation and proves `atRest` can be true while `cameraReady` is false. +Readiness must remain false after observer delivery until its frame is processed. +Only then does the unchanged full 17-second motion sample begin. + +On the prior runtime the new contract fails because `cameraReady` is absent; +`motion-readiness-negative.log` and its trace are retained in the owned dated +evidence directory. This local negative is a missing-contract test, not a claimed +reproduction of the original CI jump. With the correction, all three viewports +pass in 2.0 minutes, with zero retries. Build, typecheck, lint and harness 175/175 +pass; existing warnings remain. Exact-head independent review and CI are required. + +## Cause and corrective rule + +Physics rest does not imply completion of asynchronous edits, ResizeObserver +delivery or camera framing. Observe each boundary before measuring idle behavior. +Keep the actual sample duration, per-frame observations, node count and one-pixel +limits; do not hide setup races by widening the motion threshold or retrying. + +## Limits + +`cameraReady` describes the current viewport, resize frame and SVG transition. +It does not promise that no future timer or user action will move the camera. +The test separately observes persistence and rendered edit completion. It uses +owned synthetic graph fixtures and strict cleanup, not production graph writes. diff --git a/.agents/issues/INDEX.md b/.agents/issues/INDEX.md index 276c2589..7e15d372 100644 --- a/.agents/issues/INDEX.md +++ b/.agents/issues/INDEX.md @@ -2,6 +2,7 @@ | Symptom | Canonical lesson and regression | | --- | --- | +| Phone idle-motion sample includes one camera jump immediately after rotation while physics is already at rest | [Wait for edits and camera resize readiness](../inbox/2026-10-27-motion-sampling-needs-camera-readiness.md); controlled ResizeObserver delivery in `graph-motion.spec.ts` | | Animated zoom stops near the overview exit threshold instead of reaching its target | [Title refresh must not own the camera](../inbox/2026-10-27-lod-title-refresh-must-not-own-camera.md); frame-controlled `semantic-lod.spec.ts` negative control | | Disposable Git fixture teardown reports ENOTEMPTY under .git after a synchronous commit | [Prevent detached fixture maintenance](../inbox/2026-10-27-disposable-git-fixtures-must-own-maintenance.md); `tests/lib/runner/unified-cli.test.mjs` | | A router major update passes install but path handling or route UI assertions differ | [Validate installed navigation and current UI contracts](../inbox/2026-10-27-router-upgrades-need-current-consumer-contracts.md); `routerNavigation.test.tsx`, `router-navigation.spec.ts`, dependency guard | diff --git a/packages/web/src/components/InteractiveGraphVisualization.tsx b/packages/web/src/components/InteractiveGraphVisualization.tsx index d402473f..54e33205 100644 --- a/packages/web/src/components/InteractiveGraphVisualization.tsx +++ b/packages/web/src/components/InteractiveGraphVisualization.tsx @@ -4273,6 +4273,8 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const layoutStartRef = useRef(null); const lastSettleMsRef = useRef(null); const runLabelAvoidanceRef = useRef<(() => void) | null>(null); + const cameraViewportRef = useRef<{ w: number; h: number } | null>(null); + const cameraResizeFrameRef = useRef(null); // Store simulation reference for resize handling const simulationRef = useRef | null>(null); @@ -4784,6 +4786,12 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i return { simRunning: !atRest, atRest, + cameraReady: cameraViewportRef.current !== null + && cameraViewportRef.current.w === containerRef.current?.clientWidth + && cameraViewportRef.current.h === containerRef.current?.clientHeight + && cameraResizeFrameRef.current === null + && !(svgRef.current as (SVGSVGElement & { __transition?: unknown }) | null)?.__transition, + cameraViewport: cameraViewportRef.current, alpha: Math.round(alpha * 10000) / 10000, lastSettleMs: lastSettleMsRef.current, nodeCount: ns.length, @@ -4820,20 +4828,27 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i const el = containerRef.current; if (!el || !hasNodes) return undefined; let last = { w: el.clientWidth || 0, h: el.clientHeight || 0 }; - let frame = 0; + cameraViewportRef.current = last; const ro = new ResizeObserver(() => { - cancelAnimationFrame(frame); - frame = requestAnimationFrame(() => { + if (cameraResizeFrameRef.current !== null) cancelAnimationFrame(cameraResizeFrameRef.current); + cameraResizeFrameRef.current = requestAnimationFrame(() => { + cameraResizeFrameRef.current = null; const w = el.clientWidth || 0, h = el.clientHeight || 0; if (!w || !h) return; const changed = w !== last.w || h !== last.h; last = { w, h }; // Do not start competing animated zooms while the window edge moves. if (changed) fitViewRef.current(false); + cameraViewportRef.current = last; }); }); ro.observe(el); - return () => { cancelAnimationFrame(frame); ro.disconnect(); }; + return () => { + if (cameraResizeFrameRef.current !== null) cancelAnimationFrame(cameraResizeFrameRef.current); + cameraResizeFrameRef.current = null; + cameraViewportRef.current = null; + ro.disconnect(); + }; // Loading can briefly render a placeholder after cached nodes already make // hasNodes true. Re-run when either query settles so the observer follows the // graph container that is actually mounted, rather than returning once on a diff --git a/tests/e2e/quality/graph-motion.spec.ts b/tests/e2e/quality/graph-motion.spec.ts index c2cdc6ef..da4d22e6 100644 --- a/tests/e2e/quality/graph-motion.spec.ts +++ b/tests/e2e/quality/graph-motion.spec.ts @@ -18,6 +18,21 @@ for(const [width,height] of [[390,844],[844,390],[1440,900]]) { {title:'Keep this task still',positionX:750,positionY:550}, ].map(n=>({...n,type:'TASK',status:'IN_PROGRESS',positionZ:0,graph:{connect:{where:{node:{id:graph}}}}}))}); await page.evaluate(id=>{localStorage.setItem('currentGraphId',id);localStorage.setItem('graphdone:viewMode','graph');},graph); + await page.addInitScript(() => { + const Native = window.ResizeObserver; + const target = window as any; + target.__holdGraphResize = false; + target.__pendingGraphResizes = []; + window.ResizeObserver = class extends Native { + constructor(callback: ResizeObserverCallback) { + super((entries, observer) => { + const deliver = () => callback(entries, observer); + if (target.__holdGraphResize && entries.some(entry => entry.target.matches('.graph-container'))) target.__pendingGraphResizes.push(deliver); + else deliver(); + }); + } + }; + }); await page.goto('/'); const node=page.locator('.graph-container .node').filter({hasText:'Drag this task'}); await expect(node).toBeVisible(); @@ -40,13 +55,39 @@ for(const [width,height] of [[390,844],[844,390],[1440,900]]) { // A fully pinned graph has nothing to solve after release. It must not // keep reheating for seconds or fight the user's chosen placement. await expect.poll(()=>page.evaluate(()=>(window as any).__layoutMetrics?.()?.atRest),{timeout:2000}).toBe(true); + await expect.poll(()=>page.evaluate(()=>(window as any).__layoutMetrics?.()?.cameraReady)).toBe(true); await node.locator('.node-edit-icon').click(); await expect(page.getByTestId('quick-edit-surface')).toBeVisible(); await page.getByTestId('quick-type').getByRole('button',{name:'Idea',exact:true}).click(); await page.keyboard.press('Escape'); - await expect.poll(()=>page.evaluate(()=>(window as any).__layoutMetrics?.()?.atRest),{timeout:2000}).toBe(true); - if(width===390){await page.setViewportSize({width:844,height:390});await page.setViewportSize({width,height});} - await expect.poll(()=>page.evaluate(()=>(window as any).__layoutMetrics?.()?.atRest),{timeout:2000}).toBe(true); + await expect.poll(async()=>{ + const items=(await gql(page,'query($id:ID!){workItems(where:{graph:{id:$id}}){title type}}',{id:graph})).workItems; + return items.find((item:any)=>item.title==='Drag this task')?.type; + }).toBe('IDEA'); + await expect(node.locator('.node-type-text')).toHaveText(/idea/i); + const ready = () => page.evaluate(() => { + const metrics = (window as any).__layoutMetrics?.(); + return metrics?.atRest === true && metrics?.cameraReady === true; + }); + await expect.poll(ready,{timeout:2000}).toBe(true); + if(width===390){ + await page.evaluate(() => { (window as any).__holdGraphResize = true; }); + await page.setViewportSize({width:844,height:390}); + await expect.poll(()=>page.evaluate(()=>(window as any).__pendingGraphResizes.length)).toBeGreaterThan(0); + const held = await page.evaluate(() => (window as any).__layoutMetrics()); + expect(held.atRest).toBe(true); + expect(held.cameraReady).toBe(false); + expect(await page.evaluate(() => { + const target = window as any; + target.__holdGraphResize = false; + for(const deliver of target.__pendingGraphResizes.splice(0)) deliver(); + return target.__layoutMetrics().cameraReady; + })).toBe(false); + await expect.poll(ready,{timeout:2000}).toBe(true); + await page.setViewportSize({width,height}); + await expect.poll(ready,{timeout:2000}).toBe(true); + } + await expect.poll(ready,{timeout:2000}).toBe(true); const sample=await page.evaluate(async()=>{ const samples:Record>={}; const start=performance.now();let frames=0,maxGap=0,last=start;