Skip to content

Commit 868ee4b

Browse files
CodeManEthanclaude
andcommitted
Genesis: device-pixel-crisp rendering; mobile ticker hidden, controls collapse
The canvas backing store was sized in CSS pixels, so a dpr-3 phone rendered the valley into one ninth of its panel pixels and upscaled the mush. The blit and every post pass now run in device pixels (dpr capped at 3), the zoom ladder snaps to whole device pixels per world pixel (identity at dpr 1 — desktop untouched), and the fitted overview lands at exactly 1.000 device px per world px on a 3x phone. Camera math stays in CSS px; whole-world-pixel rounding already gives device alignment when the scale is integral. +0.3ms for 9x the pixels. Below 620px the ledger ticker is hidden (the aria-live mirror still announces) and the transport bar collapses to a clock+LIVE chip by default — tap to expand, fold button to collapse — so the world is unobstructed. Desktop is structurally unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B7t8Qgo2z11gAegjSKAr7b
1 parent 98449ef commit 868ee4b

2 files changed

Lines changed: 241 additions & 31 deletions

File tree

src/components/designs/genesis/TheGenesis.tsx

Lines changed: 200 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,19 @@ function logSig(log: { t: number; text: string }[]): string {
133133
/** Never equal to any real signature: forces the next push through. */
134134
const LOG_FORCE = '!';
135135

136+
/**
137+
* Pixel art has to be drawn at the *panel's* resolution. Size the backing store
138+
* in CSS pixels instead and a 3× phone renders the whole valley into a third of
139+
* the pixels it owns — the detail is decimated away before the browser scales
140+
* the finished frame back up, and what smoothing the upscale adds softens what
141+
* little survived. That is exactly what Genesis used to look like on a phone.
142+
*
143+
* Capped at 3: past that the frame costs ratio² more fill for a difference no
144+
* eye can find, and phones that report 4 are the ones least able to afford it.
145+
*/
146+
const MAX_DPR = 3;
147+
const deviceRatio = () => Math.min(Math.max(1, window.devicePixelRatio || 1), MAX_DPR);
148+
136149
const randomSeed = () => Math.floor(Math.random() * 4294967296) >>> 0;
137150
const stepSeed = (seed: number, d: 1 | -1) => ((seed + d + 4294967296) % 4294967296) >>> 0;
138151

@@ -190,7 +203,9 @@ export default function TheGenesis({ embed = false }: GenesisProps) {
190203
const wrapRef = useRef<HTMLDivElement | null>(null);
191204
const canvasRef = useRef<HTMLCanvasElement | null>(null);
192205

193-
const [size, setSize] = useState<{ w: number; h: number } | null>(null);
206+
/** CSS box of the wrapper, plus the display ratio it is being drawn at: a
207+
* change to either one has to rebuild the canvas backing store. */
208+
const [size, setSize] = useState<{ w: number; h: number; dpr: number } | null>(null);
194209
const [ready, setReady] = useState(false);
195210
const [mode, setMode] = useState<'live' | 'player'>('live');
196211
const [playing, setPlaying] = useState(false);
@@ -203,6 +218,15 @@ export default function TheGenesis({ embed = false }: GenesisProps) {
203218
/** The seed browser is a lab tool: open where the lab page is the whole
204219
* screen, folded behind the valley's own name where the world is a hero. */
205220
const [worldOpen, setWorldOpen] = useState(!embed);
221+
/**
222+
* Only ever consulted on a phone. A screen that small is nearly all world, so
223+
* the transport folds down into a single chip and the valley gets the band it
224+
* was drawn for; the wide layout below 620px ignores this entirely and keeps
225+
* the bar where it has always been.
226+
*/
227+
const [barOpen, setBarOpen] = useState(false);
228+
const chipRef = useRef<HTMLButtonElement | null>(null);
229+
const foldRef = useRef<HTMLButtonElement | null>(null);
206230

207231
/** The seed the date hands out; "Today" is whatever this is. Moves on by
208232
* itself when a LIVE world rolls over into the next day. */
@@ -276,16 +300,42 @@ export default function TheGenesis({ embed = false }: GenesisProps) {
276300
if (!el) return;
277301
const read = () => {
278302
const r = el.getBoundingClientRect();
303+
const dpr = deviceRatio();
279304
setSize((prev) =>
280-
prev && Math.abs(prev.w - r.width) < 1 && Math.abs(prev.h - r.height) < 1
305+
prev &&
306+
prev.dpr === dpr &&
307+
Math.abs(prev.w - r.width) < 1 &&
308+
Math.abs(prev.h - r.height) < 1
281309
? prev
282-
: { w: Math.max(1, r.width), h: Math.max(1, r.height) }
310+
: { w: Math.max(1, r.width), h: Math.max(1, r.height), dpr }
283311
);
284312
};
285313
read();
286314
const ro = new ResizeObserver(read);
287315
ro.observe(el);
288-
return () => ro.disconnect();
316+
317+
// A window dragged onto a different monitor, or a browser zoom, changes the
318+
// ratio without changing the element's size at all, so the observer alone
319+
// would leave the canvas at the wrong resolution. The media query has to be
320+
// re-armed each time, because it only ever matches the ratio it was made for.
321+
let mq: MediaQueryList | null = null;
322+
function onRatio() {
323+
read();
324+
watchRatio();
325+
}
326+
function watchRatio() {
327+
mq?.removeEventListener?.('change', onRatio);
328+
mq = window.matchMedia(`(resolution: ${window.devicePixelRatio || 1}dppx)`);
329+
// Old Safari has no listener on a MediaQueryList; it simply never hears
330+
// about a ratio change, which is the behaviour it had before any of this.
331+
mq.addEventListener?.('change', onRatio);
332+
}
333+
watchRatio();
334+
335+
return () => {
336+
ro.disconnect();
337+
mq?.removeEventListener?.('change', onRatio);
338+
};
289339
}, []);
290340

291341
/* --------------------------- world + the loop -------------------------- */
@@ -357,14 +407,31 @@ export default function TheGenesis({ embed = false }: GenesisProps) {
357407
let scene = sceneRef.current!;
358408
let amb = ambRef.current!;
359409

410+
// The viewport is `vw × vh` CSS pixels and `dw × dh` real ones. Every camera
411+
// number below stays in CSS pixels — that is what a pointer, a wheel and a
412+
// layout all speak — and only the render crosses over into device pixels.
413+
const dpr = size.dpr;
360414
const vw = Math.max(1, Math.round(size.w));
361415
const vh = Math.max(1, Math.round(size.h));
362-
canvas.width = vw;
363-
canvas.height = vh;
416+
const dw = Math.max(1, Math.round(vw * dpr));
417+
const dh = Math.max(1, Math.round(vh * dpr));
418+
canvas.width = dw;
419+
canvas.height = dh;
364420
canvas.style.width = `${vw}px`;
365421
canvas.style.height = `${vh}px`;
366422
ctx.imageSmoothingEnabled = false;
367423

424+
/**
425+
* Snap a wished-for zoom onto the device-pixel grid. At or above one device
426+
* pixel per world pixel the magnification has to be a whole number or the
427+
* blit resamples and the art softens; below it we are downsampling anyway,
428+
* and no whole number is available to snap to.
429+
*
430+
* At 1× this is the identity on every rung the ladder used to have, so a
431+
* desktop's zoom steps are untouched; at 3× it turns them into thirds.
432+
*/
433+
const snapZoom = (z: number) => (z * dpr >= 1 ? Math.round(z * dpr) / dpr : z);
434+
368435
/* ---- zoom ladder: fitted overview, then whole-pixel steps ---------- */
369436
// Both are derived from the *current* map, so a new valley reframes and
370437
// re-rungs the ladder without the effect having to run again.
@@ -373,14 +440,19 @@ export default function TheGenesis({ embed = false }: GenesisProps) {
373440
const contentW = Math.max(1, (c.u1 - c.u0) * (TW / 2));
374441
const contentH = Math.max(1, (c.v1 - c.v0) * (TH / 2));
375442
const raw = Math.min(vw / contentW, vh / contentH);
376-
// Above 1× the art wants whole-number magnification or the tile edges go
377-
// uneven; below it, any scale is fine because we are downsampling.
378-
const overview = raw >= 1 ? Math.max(1, Math.floor(raw)) : Math.max(0.24, raw);
443+
// Above one *device* pixel per world pixel the art wants whole-number
444+
// magnification or the tile edges go uneven; below it, any scale is fine
445+
// because we are downsampling. On a 3× phone the fitted overview is
446+
// therefore a third of a CSS pixel per world pixel — exactly one panel
447+
// pixel — rather than the arbitrary fraction it used to be.
448+
const overview =
449+
raw * dpr >= 1 ? Math.floor(raw * dpr) / dpr : Math.max(0.24, raw);
379450
const ladder: number[] = [overview];
380451
for (const z of [0.5, 1, 2, 3, 4]) {
381-
if (z > ladder[ladder.length - 1] * 1.15) ladder.push(z);
452+
const r = snapZoom(z);
453+
if (r > ladder[ladder.length - 1] * 1.15) ladder.push(r);
382454
}
383-
while (ladder.length < 4) ladder.push(ladder[ladder.length - 1] * 2);
455+
while (ladder.length < 4) ladder.push(snapZoom(ladder[ladder.length - 1] * 2));
384456
ladderRef.current = ladder;
385457
return {
386458
zoom: overview,
@@ -411,7 +483,7 @@ export default function TheGenesis({ embed = false }: GenesisProps) {
411483
const qz = Number(q.get('zoom'));
412484
const qx = Number(q.get('cx'));
413485
const qy = Number(q.get('cy'));
414-
if (q.has('zoom') && qz > 0) camRef.current.zoom = qz;
486+
if (q.has('zoom') && qz > 0) camRef.current.zoom = snapZoom(qz);
415487
if (q.has('cx') && isFinite(qx)) camRef.current.cx = qx - vw / camRef.current.zoom / 2;
416488
if (q.has('cy') && isFinite(qy)) camRef.current.cy = qy - vh / camRef.current.zoom / 2;
417489
}
@@ -599,7 +671,7 @@ export default function TheGenesis({ embed = false }: GenesisProps) {
599671
function paintOnce() {
600672
const cam = camRef.current!;
601673
const t0 = perf ? performance.now() : 0;
602-
renderGenesis(ctx!, scene, amb, snapRef.current!, { ...cam, vw, vh }, clock);
674+
renderGenesis(ctx!, scene, amb, snapRef.current!, { ...cam, vw, vh, dpr }, clock);
603675
dirtyRef.current = false;
604676
if (!perf) return;
605677
const ms = performance.now() - t0;
@@ -609,6 +681,7 @@ export default function TheGenesis({ embed = false }: GenesisProps) {
609681
console.error(
610682
`PERF render avg ${(pSum / pN).toFixed(2)}ms max ${pMax.toFixed(2)}ms ` +
611683
`(${(1000 / (pSum / pN)).toFixed(0)} fps ceiling) zoom ${camRef.current!.zoom} ` +
684+
`dpr ${dpr} devscale ${(camRef.current!.zoom * dpr).toFixed(3)} ` +
612685
`t=${tRef.current.toFixed(2)} trees=${scene.map.trees.length}`
613686
);
614687
pN = 0;
@@ -781,6 +854,20 @@ export default function TheGenesis({ embed = false }: GenesisProps) {
781854
syncUrl(seedRef.current, PACES[n], true);
782855
}, [syncUrl]);
783856

857+
/**
858+
* The disclosure swaps one control for the other, so whichever one the hand
859+
* (or the tab key) was on is about to stop existing: hand focus straight to
860+
* its counterpart. On a wide screen both are display:none — `offsetParent`
861+
* catches that — and nothing moves at all.
862+
*/
863+
const showBar = useCallback((open: boolean) => {
864+
setBarOpen(open);
865+
requestAnimationFrame(() => {
866+
const el = open ? foldRef.current : chipRef.current;
867+
if (el && el.offsetParent !== null) el.focus();
868+
});
869+
}, []);
870+
784871
const goLive = useCallback(() => {
785872
modeRef.current = 'live';
786873
setMode('live');
@@ -821,7 +908,10 @@ export default function TheGenesis({ embed = false }: GenesisProps) {
821908
const hush = tDisp >= 23.7 || tDisp < 0.25;
822909

823910
return (
824-
<div className={`genesis${embed ? ' gen-embed' : ''}`} ref={wrapRef}>
911+
<div
912+
className={`genesis${embed ? ' gen-embed' : ''}${barOpen ? ' gen-open' : ''}`}
913+
ref={wrapRef}
914+
>
825915
<style>{CSS}</style>
826916
<canvas
827917
ref={canvasRef}
@@ -929,7 +1019,28 @@ export default function TheGenesis({ embed = false }: GenesisProps) {
9291019
)}
9301020
</div>
9311021

932-
<div className="gen-bar" role="group" aria-label="World clock">
1022+
{/* The folded transport, and the only chrome along the base of a phone:
1023+
the hour, whether the world is on the wall clock, and a way back to
1024+
the controls. A wide screen never sees it — the bar is always there. */}
1025+
<button
1026+
type="button"
1027+
ref={chipRef}
1028+
className={`gen-chip${mode === 'live' ? ' on' : ''}`}
1029+
onClick={() => showBar(true)}
1030+
aria-expanded={barOpen}
1031+
aria-controls="gen-transport"
1032+
aria-label={`${fmtClock(tDisp)}${
1033+
mode === 'live' ? ', live' : ''
1034+
} — show the world clock controls`}
1035+
>
1036+
<b aria-hidden="true">{fmtClock(tDisp)}</b>
1037+
<span className="gen-chiplive" aria-hidden="true">
1038+
LIVE
1039+
</span>
1040+
<i aria-hidden="true"></i>
1041+
</button>
1042+
1043+
<div className="gen-bar" id="gen-transport" role="group" aria-label="World clock">
9331044
<button
9341045
type="button"
9351046
className="gen-ico"
@@ -1001,6 +1112,17 @@ export default function TheGenesis({ embed = false }: GenesisProps) {
10011112
>
10021113
LIVE
10031114
</button>
1115+
1116+
<button
1117+
type="button"
1118+
ref={foldRef}
1119+
className="gen-ico gen-fold"
1120+
onClick={() => showBar(false)}
1121+
aria-label="Hide the world clock controls"
1122+
title="Hide the controls"
1123+
>
1124+
1125+
</button>
10041126
</div>
10051127
</div>
10061128
);
@@ -1137,6 +1259,10 @@ const CSS = `
11371259
}
11381260
.gen-live.on { background: #63c9a8; color: #17301f; }
11391261
1262+
/* Both halves of the phone disclosure sit idle on a wide screen: the bar is
1263+
simply always open there, so neither the chip nor its fold button exists. */
1264+
.gen-chip, .gen-fold { display: none; }
1265+
11401266
.gen-corner {
11411267
position: absolute;
11421268
right: 16px;
@@ -1296,11 +1422,66 @@ const CSS = `
12961422
.gen-hint { display: none; }
12971423
}
12981424
1299-
/* A phone cannot hold the transport in one pill without clipping the end off
1300-
it, so the bar stops being a pill: the controls keep the top row, the day
1301-
gets the full width underneath to scrub along, and the buttons grow to
1302-
something a thumb can actually hit. */
1425+
/* A phone is nearly all world, so the chrome earns its space or goes.
1426+
*
1427+
* The ledger goes: three toasts across the bottom-left were most of the valley
1428+
* on a 390pt screen. Nothing is lost — every line still reaches the aria-live
1429+
* mirror below, which is where a screen reader was reading them from anyway.
1430+
*
1431+
* The transport folds: what is left at rest is one chip with the hour on it,
1432+
* and a tap brings back the whole bar. The bar itself cannot hold the transport
1433+
* in one pill at this width without clipping the end off, so when it is open it
1434+
* stops being a pill: the controls keep the top row, the day gets the full
1435+
* width underneath to scrub along, and the buttons grow to something a thumb
1436+
* can actually hit.
1437+
*
1438+
* The zoom column and the nameplate stay put through all of it — they are two
1439+
* small things against the top-right corner, they never cover the settlement
1440+
* (which fits and centres itself), and folding them away too would leave a
1441+
* phone with no way to look closer at the one thing it came for. */
13031442
@media (max-width: 620px) {
1443+
.gen-ticker { display: none; }
1444+
1445+
.gen-chip {
1446+
position: absolute;
1447+
left: 50%;
1448+
bottom: 12px;
1449+
transform: translateX(-50%);
1450+
z-index: 3;
1451+
display: flex;
1452+
align-items: center;
1453+
gap: 9px;
1454+
height: 40px;
1455+
padding: 0 8px 0 15px;
1456+
border-radius: 999px;
1457+
font: inherit;
1458+
color: #413a55;
1459+
background: rgba(253, 248, 239, 0.94);
1460+
border: 1px solid rgba(65, 58, 85, 0.14);
1461+
box-shadow: 0 8px 26px rgba(31, 28, 45, 0.28);
1462+
backdrop-filter: blur(6px);
1463+
cursor: pointer;
1464+
}
1465+
.gen-chip b { font-size: 0.98rem; font-variant-numeric: tabular-nums; }
1466+
.gen-chiplive {
1467+
padding: 3px 7px;
1468+
border-radius: 999px;
1469+
font-size: 0.58rem;
1470+
font-weight: 700;
1471+
letter-spacing: 0.08em;
1472+
background: rgba(65, 58, 85, 0.1);
1473+
color: #6b6684;
1474+
}
1475+
.gen-chip.on .gen-chiplive { background: #63c9a8; color: #17301f; }
1476+
.gen-chip i { font-style: normal; font-size: 0.7rem; opacity: 0.5; }
1477+
1478+
/* The disclosure itself. Everything else in this block describes the bar as
1479+
it looks *once it is open*. */
1480+
.gen-bar { display: none; }
1481+
.genesis.gen-open .gen-bar { display: flex; }
1482+
.genesis.gen-open .gen-chip { display: none; }
1483+
.gen-fold { display: block; }
1484+
13041485
.gen-bar {
13051486
left: 10px;
13061487
right: 10px;
@@ -1318,7 +1499,6 @@ const CSS = `
13181499
/* The one control that has to sit at the far end of the row. */
13191500
.gen-live { margin-left: auto; }
13201501
.gen-scrub { order: 2; flex: 1 0 100%; min-width: 0; height: 26px; }
1321-
.gen-ticker { max-width: 62vw; bottom: 108px; }
13221502
.gen-wid span { display: none; }
13231503
.gen-world button { font-size: 0.68rem; padding: 0 7px; }
13241504
.gen-corner { right: 10px; top: 10px; gap: 6px; }

0 commit comments

Comments
 (0)