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
+13 -8
View File
@@ -90,15 +90,20 @@ resizing the panel, and the surround is teal (`-solid`) rather than the root's b
**The stage is small because the panel scales it by whole numbers.** Pixel art at a fractional scale gets
uneven cells — some three screen pixels wide, their neighbours two — which reads as "not quite right"
rather than as blur, and `image-rendering: pixelated` does not help because unevenness is not blur. So the
panel snaps the scale, in DEVICE pixels, to whole numbers below 2x and QUARTER steps above it. Quarters are
safe because the unit that has to stay regular is the CELL: the board is a 16px grid, so at 2.25x a cell is
exactly 36 screen pixels, as even as at 2x, and only sub-cell pixels alternate. They are quarters rather
than halves because coarse steps overshoot — with halves this jumped 2x to 2.5x, too small to too large
with nothing in between.
panel snaps the scale, in DEVICE pixels, to QUARTER steps. Quarters are safe because the unit that has to
stay regular is the CELL: 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. Quarters rather than halves because coarse steps
overshoot, and quarters all the way down rather than whole numbers under 2x because otherwise zooming out
from a 2x baseline falls straight to 1x and halves the board in one press.
The scale is then CAPPED at 2x. Fitting the panel is not the same as looking right, and past 2x this stops
reading as a window on a desktop. That is a default, not a verdict — the panel header has a zoom control,
and `MAX_SCALE` is one constant.
The BASELINE — the size at 100% — is capped at 2x. Fitting the panel is not the same as looking right, and
past 2x this stops reading as a window on a desktop.
The panel's own zoom control then multiplies that, so zooming in still goes past 2x and zooming out still
shrinks. This needs saying because it is not automatic: `fit` is the same number at every zoom level, since
CSS `zoom` takes off `offsetWidth` exactly what it adds to the pixel ratio. A cap applied to the result
rather than the baseline therefore flattens the zoom control into doing nothing, which is how the first
version of it shipped.
Two things the scale has to know about that are easy to miss. `devicePixelRatio` is not the whole story:
the panel header has its own CSS `zoom`, so the effective ratio is `devicePixelRatio × zoom`, read as
+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}