@@ -133,6 +133,19 @@ function logSig(log: { t: number; text: string }[]): string {
133133/** Never equal to any real signature: forces the next push through. */
134134const 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+
136149const randomSeed = ( ) => Math . floor ( Math . random ( ) * 4294967296 ) >>> 0 ;
137150const 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