Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .agents/inbox/2026-10-27-motion-sampling-needs-camera-readiness.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions .agents/issues/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
23 changes: 19 additions & 4 deletions packages/web/src/components/InteractiveGraphVisualization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4273,6 +4273,8 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i
const layoutStartRef = useRef<number | null>(null);
const lastSettleMsRef = useRef<number | null>(null);
const runLabelAvoidanceRef = useRef<(() => void) | null>(null);
const cameraViewportRef = useRef<{ w: number; h: number } | null>(null);
const cameraResizeFrameRef = useRef<number | null>(null);

// Store simulation reference for resize handling
const simulationRef = useRef<d3.Simulation<any, any> | null>(null);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
47 changes: 44 additions & 3 deletions tests/e2e/quality/graph-motion.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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<string,Array<{x:number;y:number}>>={};
const start=performance.now();let frames=0,maxGap=0,last=start;
Expand Down
Loading