zoom multiplies the baseline instead of being swallowed by it

The 2x cap flattened the panel's zoom control into doing nothing, and the
reason is worth writing down: `fit` is the SAME NUMBER at every zoom level.
CSS `zoom` takes off `offsetWidth` exactly what it adds to the pixel ratio, so
the fit is zoom-invariant by construction — and capping it therefore caps every
zoom level to the same size.

So the cap applies to the baseline, the size at 100%, and zoom multiplies what
comes out. 100% stays exactly where it is; zooming in goes past 2x again.

Quarter steps now go all the way down rather than whole numbers under 2x. With
whole numbers, zooming out from a 2x baseline fell straight to 1x and halved
the board in one press. Cells stay whole at every quarter — 20px at 1.25x —
which was always the property that mattered, not the size of the step.

The resulting ladder over the panel's 70-160% range: 1.25, 1.5, 1.75, 2.0,
2.0, 2.25, 2.5, 2.75, 3.0, 3.0. Eight of the ten presses move it; two land on
the same quarter, which is the price of keeping the grid even.

Also `overflow-hidden` on the box, so zooming past what the panel holds clips
rather than shoving the status line out of the way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
This commit is contained in:
2026-08-23 22:41:14 +00:00
co-authored by Claude Opus 5
parent 48cbb24c4c
commit e5a7c5ffff
2 changed files with 36 additions and 25 deletions
+23 -17
View File
@@ -28,13 +28,14 @@ const BUTTON_MASK: Record<number, number> = { 0: 1, 1: 2, 2: 4 };
const MARGIN_DEVICE_PX = 24;
/**
* The ceiling on how large the game is drawn, whatever room the panel has.
* The ceiling on the size at 100%, whatever room the panel has.
*
* A taste decision, not a limit of the method: fitting the panel is not the same as looking right, and
* past 2x this stops reading as a window on a desktop and starts reading as a wall. Raise it and the
* quarter steps below take over again.
* past 2x this stops reading as a window on a desktop and starts reading as a wall.
*
* The panel header has a zoom control, so this is a default rather than a verdict.
* It caps the BASELINE, not the result. The panel's zoom multiplies what comes out of here, so zooming
* in still goes past 2x — the first version of this cap did not make that distinction and flattened the
* zoom control into doing nothing, because the fit it capped is the same number at every zoom level.
*/
const MAX_SCALE = 2;
@@ -75,7 +76,7 @@ export const MinesweeperPanel = () => {
const boxRef = useRef<HTMLDivElement | null>(null);
const [frame, setFrame] = useState<{ width: number; height: number } | null>(null);
const [box, setBox] = useState<{ width: number; height: number; dpr: number } | null>(null);
const [box, setBox] = useState<{ width: number; height: number; dpr: number; zoom: number } | null>(null);
const [status, setStatus] = useState<SessionStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [connected, setConnected] = useState(false);
@@ -98,6 +99,7 @@ export const MinesweeperPanel = () => {
width: node.offsetWidth,
height: node.offsetHeight,
dpr: (window.devicePixelRatio || 1) * zoom,
zoom,
});
});
observer.observe(node);
@@ -117,14 +119,20 @@ export const MinesweeperPanel = () => {
* it removes the blur, not the unevenness. A fractional devicePixelRatio produces this at what looks
* like 1:1, which is the case that is easiest to miss.
*
* QUARTER steps are allowed from 2x up, and that is not a compromise on the above. The unit that has
* to stay regular is the CELL, not the pixel: the board is a 16px grid, so at 2.25x a cell is exactly
* 36 screen pixels — as even as at 2x or 3x — and only sub-cell pixels alternate. Below 2x that
* alternation is too large a share of the pixel, so it stays whole-number there.
* QUARTER steps, and that is not a compromise on the above. The unit that has to stay regular is the
* CELL, not the pixel: the board is a 16px grid, so every quarter step gives a whole cell — 20px at
* 1.25x, 36px at 2.25x — and only sub-cell pixels alternate. Below 1x there is nothing to floor to, so
* it takes a unit fraction and the rows that survive are at least evenly spaced.
*
* Quarters rather than halves because the steps are what make the size QUANTISED, and coarse steps
* overshoot: with halves this went 2x to 2.5x, from too small to too large with nothing in between.
* Finer steps let the picture track the panel rather than lurch between two sizes.
* Quarters all the way down rather than whole numbers under 2x, because the zoom control has to have
* somewhere to go: with whole numbers below 2x, zooming out from a 2x baseline fell straight to 1x and
* halved the board in one press.
*
* ── zoom multiplies, it does not compete ──
*
* `fit` is the same number at every zoom level — the panel's CSS `zoom` shrinks `offsetWidth` by
* exactly what it adds to the pixel ratio — so zoom has to be applied deliberately or it does nothing
* at all. The baseline is what fits (capped), and zoom moves the picture up and down from there.
*/
const stage = useMemo(() => {
if (!frame || !box || box.width <= 0 || box.height <= 0) return null;
@@ -133,10 +141,8 @@ export const MinesweeperPanel = () => {
const usableW = Math.max(1, box.width * box.dpr - MARGIN_DEVICE_PX * 2);
const usableH = Math.max(1, box.height * box.dpr - MARGIN_DEVICE_PX * 2);
const fit = Math.min(usableW / frame.width, usableH / frame.height);
// Below 1 there is nothing to floor to, so take a unit fraction: the rows that survive are then
// evenly spaced instead of dropped in clumps.
const snapped = fit < 1 ? 1 / Math.ceil(1 / fit) : fit < 2 ? 1 : Math.floor(fit * 4) / 4;
const scale = Math.min(snapped, MAX_SCALE);
const wanted = Math.min(fit, MAX_SCALE) * box.zoom;
const scale = wanted < 1 ? 1 / Math.ceil(1 / wanted) : Math.floor(wanted * 4) / 4;
return { width: (frame.width * scale) / box.dpr, height: (frame.height * scale) / box.dpr };
}, [frame, box]);
@@ -297,7 +303,7 @@ export const MinesweeperPanel = () => {
return (
<div className="flex size-full flex-col items-center justify-center gap-3 bg-muted/30 p-4">
{/* min-h-0 on the row, so the canvas gives way to the status line rather than overflowing it. */}
<div ref={boxRef} className="flex min-h-0 w-full flex-1 items-center justify-center">
<div ref={boxRef} className="flex min-h-0 w-full flex-1 items-center justify-center overflow-hidden">
<canvas
ref={canvasRef}
width={PLACEHOLDER.width}