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:
@@ -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
|
**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"
|
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
|
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
|
panel snaps the scale, in DEVICE pixels, to QUARTER steps. Quarters are safe because the unit that has to
|
||||||
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
|
stay regular is the CELL: the board is a 16px grid, so every quarter step gives a whole cell — 20px at
|
||||||
exactly 36 screen pixels, as even as at 2x, and only sub-cell pixels alternate. They are quarters rather
|
1.25x, 36px at 2.25x — and only sub-cell pixels alternate. Quarters rather than halves because coarse steps
|
||||||
than halves because coarse steps overshoot — with halves this jumped 2x to 2.5x, too small to too large
|
overshoot, and quarters all the way down rather than whole numbers under 2x because otherwise zooming out
|
||||||
with nothing in between.
|
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
|
The BASELINE — the size at 100% — is capped at 2x. Fitting the panel is not the same as looking right, and
|
||||||
reading as a window on a desktop. That is a default, not a verdict — the panel header has a zoom control,
|
past 2x this stops reading as a window on a desktop.
|
||||||
and `MAX_SCALE` is one constant.
|
|
||||||
|
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:
|
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
|
the panel header has its own CSS `zoom`, so the effective ratio is `devicePixelRatio × zoom`, read as
|
||||||
|
|||||||
+23
-17
@@ -28,13 +28,14 @@ const BUTTON_MASK: Record<number, number> = { 0: 1, 1: 2, 2: 4 };
|
|||||||
const MARGIN_DEVICE_PX = 24;
|
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
|
* 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
|
* past 2x this stops reading as a window on a desktop and starts reading as a wall.
|
||||||
* quarter steps below take over again.
|
|
||||||
*
|
*
|
||||||
* 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;
|
const MAX_SCALE = 2;
|
||||||
|
|
||||||
@@ -75,7 +76,7 @@ export const MinesweeperPanel = () => {
|
|||||||
const boxRef = useRef<HTMLDivElement | null>(null);
|
const boxRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const [frame, setFrame] = useState<{ width: number; height: number } | 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 [status, setStatus] = useState<SessionStatus | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [connected, setConnected] = useState(false);
|
const [connected, setConnected] = useState(false);
|
||||||
@@ -98,6 +99,7 @@ export const MinesweeperPanel = () => {
|
|||||||
width: node.offsetWidth,
|
width: node.offsetWidth,
|
||||||
height: node.offsetHeight,
|
height: node.offsetHeight,
|
||||||
dpr: (window.devicePixelRatio || 1) * zoom,
|
dpr: (window.devicePixelRatio || 1) * zoom,
|
||||||
|
zoom,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
observer.observe(node);
|
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
|
* 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.
|
* 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
|
* QUARTER steps, and that is not a compromise on the above. The unit that has to stay regular is the
|
||||||
* to stay regular is the CELL, not the pixel: the board is a 16px grid, so at 2.25x a cell is exactly
|
* CELL, not the pixel: the board is a 16px grid, so every quarter step gives a whole cell — 20px at
|
||||||
* 36 screen pixels — as even as at 2x or 3x — and only sub-cell pixels alternate. Below 2x that
|
* 1.25x, 36px at 2.25x — and only sub-cell pixels alternate. Below 1x there is nothing to floor to, so
|
||||||
* alternation is too large a share of the pixel, so it stays whole-number there.
|
* 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
|
* Quarters all the way down rather than whole numbers under 2x, because the zoom control has to have
|
||||||
* overshoot: with halves this went 2x to 2.5x, from too small to too large with nothing in between.
|
* somewhere to go: with whole numbers below 2x, zooming out from a 2x baseline fell straight to 1x and
|
||||||
* Finer steps let the picture track the panel rather than lurch between two sizes.
|
* 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(() => {
|
const stage = useMemo(() => {
|
||||||
if (!frame || !box || box.width <= 0 || box.height <= 0) return null;
|
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 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 usableH = Math.max(1, box.height * box.dpr - MARGIN_DEVICE_PX * 2);
|
||||||
const fit = Math.min(usableW / frame.width, usableH / frame.height);
|
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
|
const wanted = Math.min(fit, MAX_SCALE) * box.zoom;
|
||||||
// evenly spaced instead of dropped in clumps.
|
const scale = wanted < 1 ? 1 / Math.ceil(1 / wanted) : Math.floor(wanted * 4) / 4;
|
||||||
const snapped = fit < 1 ? 1 / Math.ceil(1 / fit) : fit < 2 ? 1 : Math.floor(fit * 4) / 4;
|
|
||||||
const scale = Math.min(snapped, MAX_SCALE);
|
|
||||||
return { width: (frame.width * scale) / box.dpr, height: (frame.height * scale) / box.dpr };
|
return { width: (frame.width * scale) / box.dpr, height: (frame.height * scale) / box.dpr };
|
||||||
}, [frame, box]);
|
}, [frame, box]);
|
||||||
|
|
||||||
@@ -297,7 +303,7 @@ export const MinesweeperPanel = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex size-full flex-col items-center justify-center gap-3 bg-muted/30 p-4">
|
<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. */}
|
{/* 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
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
width={PLACEHOLDER.width}
|
width={PLACEHOLDER.width}
|
||||||
|
|||||||
Reference in New Issue
Block a user