From 29f33b6821b1622860ec64c237d9eeb3c1891a14 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 31 Jul 2026 19:29:39 -0700 Subject: [PATCH] [eric] canvas: stop the spawn camera ratcheting out forever, it reached 18% and nothing was readable --- .../pages/Dashboard/canvas/revealZoom.test.ts | 36 +++++++++++++++++++ .../app/pages/Dashboard/canvas/revealZoom.ts | 18 ++++++++++ .../hooks/interaction/useCanvasControls.ts | 9 ++++- 3 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/pages/Dashboard/canvas/revealZoom.test.ts create mode 100644 frontend/src/app/pages/Dashboard/canvas/revealZoom.ts diff --git a/frontend/src/app/pages/Dashboard/canvas/revealZoom.test.ts b/frontend/src/app/pages/Dashboard/canvas/revealZoom.test.ts new file mode 100644 index 00000000..8627671d --- /dev/null +++ b/frontend/src/app/pages/Dashboard/canvas/revealZoom.test.ts @@ -0,0 +1,36 @@ +// Run: cd frontend && npx tsx --test src/app/pages/Dashboard/canvas/revealZoom.test.ts +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { revealZoom, REVEAL_MIN_ZOOM } from './revealZoom.js'; + +const MIN = 0.15, MAX = 3.0; + +test('a spawn never zooms IN, which is the behaviour worth keeping', () => { + assert.equal(revealZoom(0.8, 2.0, MIN, MAX), 0.8); +}); + +test('it still zooms out when the card genuinely does not fit', () => { + assert.equal(revealZoom(1.0, 0.7, MIN, MAX), 0.7); +}); + +test('but never below readable, which is the bug', () => { + assert.equal(revealZoom(1.0, 0.05, MIN, MAX), REVEAL_MIN_ZOOM); + assert.equal(revealZoom(0.6, 0.18, MIN, MAX), REVEAL_MIN_ZOOM); +}); + +test('the measured death spiral bottoms out instead of reaching 18%', () => { + // Replays the real session: each step is a reveal whose fit was tighter than the last. + let z = 1.0; + for (const fit of [0.88, 0.79, 0.76, 0.61, 0.36, 0.34, 0.18]) z = revealZoom(z, fit, MIN, MAX); + assert.equal(z, REVEAL_MIN_ZOOM); + assert.ok(z >= REVEAL_MIN_ZOOM, 'must not ratchet past the floor no matter how many spawns'); +}); + +test('a camera the user already zoomed out by hand is left alone', () => { + // Below the floor already: the reveal must not yank them back IN, that would fight the user. + assert.equal(revealZoom(0.2, 0.9, MIN, MAX), 0.2); +}); + +test('it respects the hard bounds', () => { + assert.equal(revealZoom(5.0, 9.0, MIN, MAX), MAX); +}); diff --git a/frontend/src/app/pages/Dashboard/canvas/revealZoom.ts b/frontend/src/app/pages/Dashboard/canvas/revealZoom.ts new file mode 100644 index 00000000..8e927294 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/canvas/revealZoom.ts @@ -0,0 +1,18 @@ +// How far the camera is allowed to pull back when a new card appears. +// +// The reveal takes min(current, fit) so a spawn never zooms IN, which is right. But that made the +// camera monotonically decreasing: every spawn that did not fit ratcheted it out and nothing ever +// brought it back. Measured over one ordinary session: 100% -> 88% -> 79% -> 61% -> 36% -> 18%, at +// which point the workspace is a fifth of the viewport and not one word is readable. +// +// So the reveal gets a floor. Fitting every card on screen is not the goal; seeing the new one is. +// A hand-driven zoom is untouched and can still go to the hard minimum. + +export const REVEAL_MIN_ZOOM = 0.5; + +export function revealZoom(currentZoom: number, fitZoom: number, minZoom: number, maxZoom: number): number { + // Floor the FIT, not the result. Flooring the result would drag a user who had deliberately zoomed + // out back IN, which breaks the one rule this function already had: a reveal never zooms in. + const floor = Math.max(minZoom, REVEAL_MIN_ZOOM); + return Math.min(Math.min(currentZoom, Math.max(fitZoom, floor)), maxZoom); +} diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts index c8a3caf6..b61c88a0 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts @@ -7,8 +7,14 @@ 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'; const MIN_ZOOM = 0.15; +// The floor for AUTOMATIC reveals only. revealCards takes min(current, fit), which can only ever go +// down, so every spawn that did not fit ratcheted the camera out and nothing ever brought it back: +// measured 100% -> 88% -> 79% -> 61% -> 36% -> 18% over one ordinary session, at which point no word +// on the canvas is readable. A hand-driven zoom can still go all the way to MIN_ZOOM. + const MAX_ZOOM = 3.0; const ZOOM_IN_FACTOR = 1.1; const ZOOM_OUT_FACTOR = 1 / ZOOM_IN_FACTOR; @@ -792,7 +798,8 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: (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); + // Never auto-zoom below readable: showing every card at 18% is worse than showing the new one at 50%, and the pan below still brings it into view. + const zoom = clamp(revealZoom(cur.zoom, fitZoom, MIN_ZOOM, MAX_ZOOM), 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;