diff --git a/README.md b/README.md index d4575cf..dd78866 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/web/MinesweeperPanel.tsx b/web/MinesweeperPanel.tsx index f64c77a..4cc08c2 100644 --- a/web/MinesweeperPanel.tsx +++ b/web/MinesweeperPanel.tsx @@ -28,13 +28,14 @@ const BUTTON_MASK: Record = { 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(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(null); const [error, setError] = useState(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 (
{/* min-h-0 on the row, so the canvas gives way to the status line rather than overflowing it. */} -
+