A phone is no longer one shape. It is 22:9 and it is 4:3, it folds open into a square, it turns on its side, and a keypad phone is a 4:3 screen the size of a card. None of that should reach the composition, which is a game drawn at one scale in a ~200-unit column -- stretching that column to fit a screen is how a designed thing turns into a responsive web page. So the canvas covers the whole safe area and the column sits in the middle of it at the width it has always been. What that leaves over is not a margin to apologise for: every pixel nobody paints is ground, so on a wide screen the leftover is the desktop, patterned and dithered, which is what a Macintosh with a big monitor showed. It was a flat band of body background. That handles every proportion in one direction and not the other. On a landscape screen height is the scarce dimension, and a column stacked board-over-controls shrinks the board to nothing to fit its own chrome underneath -- so there is a second arrangement, in which the same two windows sit side by side. Two windows on a desktop is not a concession to landscape; it is what this system did with a wide screen. The sets list and the record take a second column with it, because eight sets in one column of a landscape picture is three of them and a scroll. Which arrangement, and at what upscale, is chosen rather than calculated. Both were tried as formulas first and both kept being wrong at the edges: a threshold on the proportion sends a 768x800 fold to the side-by-side arrangement, where it comes out worse than stacked, and any expression that gets the upscale right for a 900-tall desktop gets it wrong for a 393-tall phone on its side. There are about a dozen candidates and each can be costed exactly -- the board's size in real pixels, with a floor under how small a layout unit may get, since a unit under two CSS pixels sets a six-unit label at six pixels. chooseLayout builds them all and takes the best, so the arrangement falls out of that rather than being a rule anyone has to defend. Two bugs fell out of measuring it. The head and foot fractions of the stacked column had no floors: on a 360x640 phone the foot came up seven units short and the UNDO/RESET row was drawn fourteen pixels below the bottom of the screen. They are now stated as the thing they hold, and the head gives way first, being air above the board rather than anything in it. And the title screen sized KLOTSKI off the column but placed it by a fraction of the window height -- two measurements that agree on a tall phone and pull apart on a short one, where it printed through the frame. The tall phone is unchanged: same plate, same cell, same 64-unit head, and the only pixels that move are the gauge's end labels, which now sit on the ends of the scale they label rather than two units inside them.
- commit
- aa31fefd515fba22dc0203571210f3fd4e31298a
- parent
- 2774b28e33977b7eb061c6e6b57b7ae3507eeab0
- author
- Dami <dami.etoile@gmail.com>
| M | README.md | 31 | ++ |
| M | mockup/ | 34 | +- |
| M | mockup/ | 572 | +++++++++++++++++++++++------- |
| M | mockup/ | 31 | +- |
| M | mockup/ | 2 | +- |
@@ -124,6 +124,37 @@ times the room, so the whole ladder is reachable for the first time.
124124 It costs about 3ms a frame on a nine-times buffer, because the scene outside the block sprites is125125 composed entirely at 0 and 255 and so gives error diffusion nothing to propagate.126126 127+**The column is not the screen.** A phone is no longer one shape: 22:9 and 4:3, folding open into a128+square, turned on its side, and a keypad phone is a 4:3 screen the size of a card. None of that129+should reach the composition, which is a game drawn at one scale in that ~200-unit column —130+stretching the column to fit a screen is how a designed thing turns into a responsive web page. So131+the canvas covers the whole safe area, the column sits in the middle of it at the width it has132+always been, and what is left over is not a margin to apologise for: every pixel nobody paints is133+ground, so on a wide screen the leftover is the desktop, patterned and dithered, which is what a134+Macintosh with a big monitor showed. It used to be a flat band of body background.135+136+That handles every proportion in one direction and not the other. On a landscape screen height is137+the scarce dimension, and a column stacked board-over-controls has to shrink the board to nothing to138+fit its own chrome underneath — so there is a second arrangement, in which the same two windows sit139+side by side. Two windows on a desktop is not a concession to landscape; it is what this system did140+with a wide screen. The sets list and the record take a second column with it, because eight sets in141+one column of a landscape picture is three of them and a scroll.142+143+Which arrangement, and at what upscale, is *chosen* rather than calculated. Both were tried as144+formulas first and both kept being wrong at the edges: a threshold on the proportion sends a 768×800145+fold to the side-by-side arrangement, where it comes out worse than stacked, and any expression that146+gets the upscale right for a 900-tall desktop gets it wrong for a 393-tall phone on its side. There147+are about a dozen candidates and each can be costed exactly — the size of the board in real pixels,148+with a floor under how small a layout unit may get, since a unit under two CSS pixels sets a149+six-unit label at six pixels — so `chooseLayout` builds them all and takes the best. The150+arrangement falls out of that rather than being a rule anyone has to defend.151+152+The fractions that place the head and the foot of the stacked column now have floors under them,153+stated as the thing they hold — the gauge, the readout, the buttons and the air between them —154+rather than as a share of the height that happened to work at 852 tall. On a 360×640 phone the old155+share came up seven units short and drew the UNDO/RESET row fourteen pixels below the bottom of the156+screen. The head gives way first, since it is air above the board rather than anything in it.157+127158 **The goal block's mark is made of the shading, not stamped on it.** It used to be a struck ring128159 in bare paper, and a struck ring is an opinion about colour: stamping paper through the middle of a129160 shaded block fought whatever screen the rung drew, and a hard outline across a Ben-Day field or a@@ -317,24 +317,32 @@ function atlasTap(v) {
317317 const G = atlasGeometry();318318 for (let fi = 0; fi < FAMILIES.length; fi++) {319319 const r = familyRowRect(G, fi);320- if (v.y >= r.y && v.y <= r.y + r.h) { openFamily(FAMILIES[fi]); return; }320+ if (v.x >= r.x && v.x <= r.x + r.w &&321+ v.y >= r.y && v.y <= r.y + r.h) { openFamily(FAMILIES[fi]); return; }321322 }322323 }323324 324-/* ------------------------------------------------------------------ chrome */325-function zone(id, top, height, show) {325+/* ------------------------------------------------------------------ chrome326+ * A zone is a band of DOM laid over the picture, in the picture's own units.327+ * It used to span the picture, because the picture was the composition; now328+ * the composition is a column somewhere inside it, so a zone is told which box329+ * it belongs to and only the menu bar still spans the screen. */330+function zone(id, top, height, show, box) {326331 const el = $(id);327332 el.style.display = show ? 'flex' : 'none';328333 el.style.top = `${top * view.S}px`;329334 el.style.height = `${height * view.S}px`;335+ el.style.left = box ? `${box.x * view.S}px` : '0';336+ el.style.width = box ? `${box.w * view.S}px` : '100%';330337 }331338 332339 function placeDom() {333340 const sc = App.screen;334- const P = LY.plate;341+ const panel = LY.panel, g = LY.gauge;335342 const playing = sc === 'play', won = sc === 'won';336343 const chrome = sc === 'atlas' || playing || won || sc === 'stats';337344 345+ document.body.dataset.mode = view.mode;338346 zone('top', 1, LY.barH - 2, chrome);339347 $('btnBack').style.visibility = sc === 'atlas' || sc === 'boot' ? 'hidden' : 'visible';340348 $('btnStats').style.display = sc === 'atlas' ? '' : 'none';@@ -345,18 +353,22 @@ function placeDom() {
345353 * takes away the exact distance and leaves the gauge, OFF takes the gauge346354 * too. STEPS and OPTIMAL stay throughout -- one is a record of what you did347355 * and the other is on the card before you start, so neither is a hint. */348- const gaugePad = Math.round(view.VW * 0.19) * view.S;349- $('gauge').style.padding = `0 ${gaugePad}px`;350- zone('gauge', LY.gaugeY + 6, 14, playing && save.hints !== 'off');351- zone('hud', LY.hudY, 30, playing);356+ // The zone is the drawn scale, so the two end labels land on its two ends357+ // without a padding that has to be kept in step with it by hand.358+ zone('gauge', g.y + 6, 14, playing && save.hints !== 'off', g);359+ zone('hud', LY.hudY, 30, playing, panel);352360 $('statDist').style.display = save.hints === 'full' ? '' : 'none';353361 // The gauge's left end stops being the start once BEST anchors it to the354362 // furthest out you have been, and a scale labelled with the wrong end is355363 // worse than no label.356364 $('gaugeFrom').textContent = save.hints === 'best' ? 'DEEPEST' : 'START';357- zone('record', P.y + P.h + LY.belowPlate + Math.round(view.VH * 0.04), 34, won);358- zone('acts', LY.panel.actionsY, 34, playing || won);359- zone('gate', LY.bootWin.y + Math.round(LY.bootWin.h * 0.85), 20, sc === 'boot');365+ zone('record', LY.recordY, 34, won, panel);366+ zone('acts', panel.actionsY, 34, playing || won, panel);367+ // Held clear of the window's own bottom rule: the fraction alone puts the368+ // box through it once the window is short enough.369+ const W = LY.bootWin;370+ zone('gate', Math.min(W.y + Math.round(W.h * 0.85), W.y + W.h - 24),371+ 20, sc === 'boot', LY.col);360372 361373 ['btnUndo', 'btnReset'].forEach(id => { $(id).style.display = playing ? '' : 'none'; });362374 ['btnNext', 'btnAtlas'].forEach(id => { $(id).style.display = won ? '' : 'none'; });@@ -96,7 +96,7 @@ const tctx = typeCvs.getContext('2d');
9696 const scene = document.createElement('canvas');9797 const sctx = scene.getContext('2d', { willReadFrequently: true });9898 99-export const view = { S: 2, VW: 208, VH: 440, offX: 0, offY: 0 };99+export const view = { S: 2, VW: 208, VH: 440, offX: 0, offY: 0, mode: 'stack' };100100 export const LY = {};101101 export const fx = { wipe: -1, wipeAxis: 'x', flash: 0 };102102 @@ -250,20 +250,197 @@ export function setTheme(id) {
250250 }251251 252252 /* ------------------------------------------------------------------ layout253- * Composed to a portrait area even on a wide screen, so what shows on a254- * desktop is an honest reading of what shows on a phone. The upscale from the255- * picture buffer to CSS pixels is always an exact integer -- a fractional one256- * smears the dither, which is the fastest way to make this look cheap. The257- * type canvas has no such constraint and runs at full device resolution. */253+ *254+ * A phone is no longer one shape. It is 22:9 and it is 4:3, it folds open into255+ * a square, it turns on its side, and a keypad phone is a 4:3 screen the size256+ * of a card. What all of those have in common is that the *composition* does257+ * not want to change with them: this is a game drawn at one scale in a ~200258+ * unit column, and stretching that column to fit a screen is how a designed259+ * thing turns into a responsive web page.260+ *261+ * So the column is no longer the picture. The picture is the screen -- the262+ * canvas covers the whole safe area -- and the column sits in the middle of263+ * it at the width it has always been. What that leaves over is not a margin264+ * to be apologised for: every pixel nobody paints becomes ground, so on a265+ * wide screen the leftover is the desktop, patterned, dithered, and exactly266+ * what a Macintosh with a big monitor showed. It used to be a flat band of267+ * body background, which is the one thing it should never have been.268+ *269+ * That handles every proportion in one direction. It does not handle the270+ * other one: on a landscape screen height is the scarce dimension, and a271+ * column stacked board-over-controls has to shrink the board to nothing to272+ * fit its own chrome underneath. So there is a second arrangement, in which273+ * the same two windows stop stacking and sit side by side. Two windows on a274+ * desktop is not a concession to landscape -- it is what this system did with275+ * a wide screen.276+ *277+ * Which arrangement, and at what upscale, is *chosen* rather than calculated.278+ * Both were tried as formulas first and both kept being wrong at the edges: a279+ * threshold on the proportion sends a 768x800 fold to the side-by-side280+ * arrangement, where it comes out worse than stacked, and any expression that281+ * gets the upscale right for a 900-tall desktop gets it wrong for a 393-tall282+ * phone on its side. There are only about a dozen candidates, they can each be283+ * costed exactly, and the cost is not a matter of opinion -- so chooseLayout284+ * builds all of them and takes the best. The arrangement falls out of that285+ * rather than being a rule anyone has to defend.286+ *287+ * Everything else is unchanged: the upscale from the picture buffer to CSS288+ * pixels is an exact integer, because a fractional one smears the dither and289+ * that is the fastest way to make this look cheap. The type canvas has no290+ * such constraint and runs at full device resolution.291+ */292+293+/* The design width, in layout units. Every type size and offset in the app is294+ * a fraction of this and always has been; it is the one number the drawing295+ * code is entitled to assume. */296+const COL = 200;297+/* How tall a column is allowed to get before the extra goes to the desktop298+ * instead. Past about this, a column stops reading as a page and starts299+ * reading as a corridor with the board adrift in the middle of it. */300+const COL_R = 1.9;301+/* Between the two windows when they sit side by side, and how wide the one302+ * holding the controls is allowed to be. Its floor is three readouts abreast;303+ * past its ceiling it is just a wide empty panel. */304+const PAIR_GAP = 10, PANEL_MIN = 100, PANEL_MAX = 168;305+/* The board the picture is sized for. Every family is re-fitted afterwards --306+ * a 3x5 set gets a bigger cell than a 5x4 one -- but the *picture* has to be307+ * decided before a family is open, and it cannot change when one is picked. */308+const REF_W = 4, REF_H = 5;309+310+/* The screen, less anything the hardware has taken out of it. The body carries311+ * the safe-area insets as padding, so the content box is the honest figure --312+ * innerWidth is not, and centring against it is what used to push the picture313+ * under a notch on a landscape phone. */314+function safeBox() {315+ const b = document.body, cs = getComputedStyle(b);316+ const n = v => parseFloat(v) || 0;317+ const x = n(cs.paddingLeft), y = n(cs.paddingTop);318+ return {319+ x, y,320+ w: Math.max(160, b.clientWidth - x - n(cs.paddingRight)),321+ h: Math.max(160, b.clientHeight - y - n(cs.paddingBottom)),322+ };323+}324+325+/* The plate's three measurements as functions of the cell, for a given board.326+ * Shared by the fitting and the drawing so the two cannot drift apart: the327+ * tray is inset from the cells because a block's drop shadow reaches past its328+ * own cell, and without that margin the bottom and right rules sit directly on329+ * the last row of blocks and clip their shadows. */330+function plateOf(bw, bh) {331+ const pad = c => Math.max(3, Math.round(c * 0.16));332+ return {333+ pad,334+ w: c => c * bw + pad(c) * 2 + Math.round(c * 0.5) * 2,335+ h: c => Math.round(c * bh + pad(c) * 2 + c * 0.5 + c * 1.05),336+ };337+}338+339+/* The largest cell that fits the room, in whole dither periods so the ordered340+ * mode does not flash when a block slides. Null when even the smallest one341+ * does not fit, which is how a candidate picture gets ruled out.342+ *343+ * Stacked, the air under the plate is part of what has to fit: it is measured344+ * off the cell, so leaving it out of the fitting is how a board that fits by345+ * two units posts the buttons under it off the bottom of the screen. */346+function fitCell(m, bw, bh) {347+ const P = plateOf(bw, bh);348+ const need = c => P.h(c) + (m.mode === 'split' ? 0 : Math.round(c * 0.3));349+ let cell = 40;350+ while (cell > 16 && (need(cell) > m.roomH || P.w(cell) > m.roomW)) cell -= PERIOD;351+ return need(cell) <= m.roomH && P.w(cell) <= m.roomW ? cell : null;352+}353+354+/* Everything that follows from an arrangement and an upscale, as a pure355+ * function of the two, so a candidate can be built and costed without356+ * committing to it. */357+function metricsFor(mode, S, box) {358+ const VW = Math.floor(box.w / S), VH = Math.floor(box.h / S);359+ const split = mode === 'split';360+361+ /* The column: where everything that is not the desktop is composed. It is362+ * COL units wide wherever there is room for COL units, which is the point --363+ * the app is drawn at one scale and the screen does not get a say in it. It364+ * gives way in exactly two directions. A picture narrower than the design365+ * width has to be taken as it comes, and a taller one is allowed to widen a366+ * little rather than leave the board stranded in a corridor, up to a limit:367+ * past about a third over, the fixed things in it -- the margins, the table368+ * rows, the buttons -- start looking small for the page they are on. */369+ const colW = Math.min(VW, split370+ ? Math.max(COL + 24, Math.min(Math.round(VH * 1.2), Math.round(COL * 1.6)))371+ : Math.max(COL, Math.min(Math.round(VH / COL_R), Math.round(COL * 1.35))));372+373+ /* The menu bar spans the screen, not the column -- it did on a Macintosh and374+ * a bar that stopped short of the edges would read as a strip of window. It375+ * is proportioned against the height it has: a bar taking a twentieth of a376+ * tall column would take a tenth of a short one. */377+ const barH = Math.max(14, Math.round(VH * (split ? 0.07 : 0.048)));378+379+ /* The head and the foot are fractions with floors under them. The fractions380+ * are what a tall phone always used; the floors are what stops a short381+ * column from computing a foot too small to hold the controls that go in it382+ * and posting the actions row off the bottom of the screen -- which is what383+ * a 360x640 phone did. The foot is the gauge, the readout, the buttons and384+ * the air between them, so it is stated as exactly that rather than as a385+ * fraction that happens to work out. */386+ const hudGap = Math.min(46, Math.max(26, Math.round(VH * 0.095)));387+ const headH = Math.max(barH + 10, Math.round(VH * 0.14));388+ const footH = Math.max(hudGap + 30 + 10 + 34 + 4, Math.round(VH * 0.30));389+390+ return {391+ mode, S, VW, VH, barH, hudGap, headH, footH, colW,392+ colX: Math.round((VW - colW) / 2),393+ // What the plate has to fit inside -- the only thing the two arrangements394+ // actually argue about. Split measures against the narrowest the control395+ // panel is allowed to be, so the board gets first call on the width and396+ // the panel takes what is left.397+ roomW: split ? VW - 24 - PANEL_MIN - PAIR_GAP : colW - 8,398+ // Stacked, the board is fitted against the *least* head it could have --399+ // the bar and a little clearance. The head above is a composition, not a400+ // reservation: on a tall phone there is room for all of it and the plate401+ // sits where it always did, and on a short one it gives way rather than402+ // taking a step off the board to keep itself whole.403+ roomH: split ? VH - barH - 12 : VH - footH - (barH + 10),404+ };405+}406+407+/* Build every arrangement at every upscale and take the best of them.408+ *409+ * The cost is the size of the board in real pixels, which is the thing a410+ * player is actually given, and it is not the same as the size of the picture:411+ * a cell is capped at 40 units, so past a point more units stop turning into412+ * more board and the upscale is what has to grow instead.413+ *414+ * Two riders on it. A layout unit under two CSS pixels sets a six-unit label415+ * at six pixels, so anything that can only be had at S=1 is a last resort416+ * rather than a candidate -- it is what a keypad phone gets and nothing else.417+ * And where two candidates give the same board, the smaller upscale wins,418+ * because it is the one with more units left over for everything around it. */419+function chooseLayout(box) {420+ let best = null, bestCost = -1;421+ for (const mode of ['stack', 'split']) {422+ for (let S = 1; S <= 8; S++) {423+ const m = metricsFor(mode, S, box);424+ if (m.VW < 150 || m.VW > 640 || m.VH < 120) continue;425+ const cell = fitCell(m, REF_W, REF_H);426+ if (!cell) continue;427+ const cost = (S > 1 ? 1e6 : 0) + cell * S * 100 - S;428+ if (cost > bestCost) { bestCost = cost; best = m; }429+ }430+ }431+ // Nothing fit at all: a screen this small has no good answer, only an432+ // honest one.433+ return best || metricsFor('stack', 1, box);434+}435+258436 export function layout() {259- const cw = window.innerWidth, ch = window.innerHeight;260- const gh = ch, gw = Math.min(cw, Math.round(ch * 0.5));261- const S = Math.max(1, Math.round(gw / 200));262- const VW = Math.floor(gw / S), VH = Math.floor(gh / S);437+ const box = safeBox();438+ const m = chooseLayout(box);439+ const { mode, S, VW, VH } = m;263440 Object.assign(view, {264- S, VW, VH,265- offX: Math.round((cw - VW * S) / 2),266- offY: Math.round((ch - VH * S) / 2),441+ mode, S, VW, VH,442+ offX: box.x + Math.round((box.w - VW * S) / 2),443+ offY: box.y + Math.round((box.h - VH * S) / 2),267444 });268445 269446 // The backing stores carry the density; the CSS size does not, so the@@ -286,12 +463,16 @@ export function layout() {
286463 outImg = octx.createImageData(dpx(VW), dpx(VH));287464 sprites.clear();288465 289- const barH = Math.max(14, Math.round(VH * 0.048));290466 Object.assign(LY, {291- headH: Math.round(VH * 0.14), footH: Math.round(VH * 0.30), barH,467+ barH: m.barH, hudGap: m.hudGap, headH: m.headH, footH: m.footH,468+ roomW: m.roomW, roomH: m.roomH,469+ col: { x: m.colX, w: m.colW, cx: m.colX + m.colW / 2 },292470 // Computed here, not while drawing, so the DOM prompt can be positioned293471 // before the first frame is painted.294- bootWin: { x: 12, y: Math.round(VH * 0.15), w: VW - 24, h: Math.round(VH * 0.66) },472+ bootWin: {473+ x: m.colX + 12, y: Math.round(VH * 0.15),474+ w: m.colW - 24, h: Math.round(VH * 0.66),475+ },295476 });296477 layoutBoard(App.fam || FAMILIES[0]);297478 requestRender();@@ -299,25 +480,28 @@ export function layout() {
299480 300481 /* The board's own measurements, which depend on the family in play -- a 3x5301482 * set and a 5x4 set want different cells and a different plate. Kept apart302- * from layout() so a resize does not need to know which family is open. */483+ * from layout() so a resize does not need to know which family is open.484+ *485+ * Two arrangements of the same two windows. Stacked, the plate sits in the486+ * column with the controls beneath it. Split, they sit side by side, because487+ * on a landscape screen the height is all spoken for and a foot of chrome488+ * under the board is paid for out of the board. Everything downstream reads489+ * the same names either way: the painters and the DOM chrome are told where490+ * the plate, the panel and the gauge are, and never which arrangement put491+ * them there. */303492 export function layoutBoard(fam) {304- const { VW, VH } = view;493+ const { VW, VH, mode } = view;494+ const col = LY.col;305495 const bw = fam.w, bh = fam.h;306- const headH = LY.headH, footH = LY.footH;307-308- // The cell stays a whole number of dither periods so the ordered mode does309- // not flash when a block slides.310- // The tray is inset from the cells: a block's drop shadow reaches past its311- // own cell, and without this margin the bottom and right rules sit directly312- // on the last row of blocks and clip their shadows.313- const trayPadFor = c => Math.max(3, Math.round(c * 0.16));314- const plateFor = c =>315- Math.round(c * bh + trayPadFor(c) * 2 + c * 0.5 + c * 1.05);316- let cell = Math.min(40, Math.floor((VW - 40) / bw / PERIOD) * PERIOD);317- while (cell > 16 && plateFor(cell) > VH - headH - footH) cell -= PERIOD;318- cell = Math.max(16, cell);319-320- const trayPad = trayPadFor(cell);496+ const barH = LY.barH;497+ const P = plateOf(bw, bh);498+499+ // The room was worked out when the picture was chosen; only the board it has500+ // to hold changes here. A family that will not fit at the smallest cell gets501+ // the smallest cell anyway -- there is nothing else to give it.502+ const cell = fitCell({ mode, roomW: LY.roomW, roomH: LY.roomH }, bw, bh) || 16;503+504+ const trayPad = P.pad(cell);321505 const padX = Math.round(cell * 0.5);322506 const padTop = Math.round(cell * 0.5);323507 const padBottom = Math.round(cell * 1.05); // the caption band@@ -326,27 +510,78 @@ export function layoutBoard(fam) {
326510 // and it read as a stray textured bar; the space it held is kept as plain327511 // breathing room.328512 const belowPlate = Math.round(cell * 0.3);329- const plateW = cell * bw + trayPad * 2 + padX * 2;330- const plateH = cell * bh + trayPad * 2 + padTop + padBottom;331- const plateX = Math.round((VW - plateW) / 2);332- const plateY = Math.round(headH + (VH - headH - footH - plateH - belowPlate) / 2);513+ const plateW = P.w(cell);514+ const plateH = P.h(cell);515+516+ const split = mode === 'split';517+ /* The panel takes what the plate leaves, between the narrowest that holds518+ * three readouts abreast and the widest that is not just empty panel. The519+ * board has first call because it is the thing being played; a five-wide set520+ * on a square screen is the case where there is nothing spare, and there the521+ * panel goes to its floor rather than the board going to its. */522+ const panelW = split523+ ? Math.min(PANEL_MAX, Math.max(PANEL_MIN, VW - 24 - PAIR_GAP - plateW))524+ : 0;525+ // Split centres the pair, so the desktop shows either side of both windows526+ // rather than all of it landing on one edge.527+ const compW = split ? plateW + PAIR_GAP + panelW : plateW;528+ const plateX = Math.round((VW - compW) / 2);529+ /* Stacked, the plate takes the head it is owed and then centres itself in530+ * whatever is left. On a tall phone that is the same 14% of the height it531+ * has always been and the plate lands exactly where it did; on a short one532+ * the head shrinks to the bar plus clearance instead of pushing the foot533+ * off the bottom of the screen. */534+ const headRoom = VH - LY.footH - plateH - belowPlate;535+ const head = Math.max(barH + 10, Math.min(LY.headH, headRoom));536+ const plateY = split537+ ? Math.max(barH + 6, Math.round(barH + (VH - barH - plateH) / 2))538+ : Math.round(head + Math.max(0, headRoom - head) / 2);539+540+ /* The panel: below the plate, or beside it. Stacked, it hugs its contents541+ * rather than reaching for the bottom of the screen, so the buttons sit with542+ * the numbers they belong to. Split, it matches the plate's height, because543+ * two windows of the same height side by side is a pair and two of different544+ * heights is an accident. */545+ const foot = plateY + plateH + belowPlate;546+547+ /* Split, the panel is as tall as the plate and its contents are worth less548+ * than that, so they are centred in it as one group rather than pinned to549+ * its two ends -- pinned, the gauge and the buttons drift apart until the550+ * panel reads as two things with a hole between them. */551+ const inner = split ? Math.min(26, Math.max(8, Math.round(plateH * 0.12))) : 0;552+ const groupH = split ? 14 + inner + 30 + inner + 34 : 0;553+ const groupY = split ? plateY + Math.max(4, Math.round((plateH - groupH) / 2)) : 0;554+555+ const actionsY = split556+ ? groupY + 14 + inner + 30 + inner557+ : foot + LY.hudGap + 42;558+ const panel = split559+ ? { x: plateX + plateW + PAIR_GAP, y: plateY, w: panelW, h: plateH, actionsY }560+ : (() => {561+ const y = foot + Math.round(VH * 0.02);562+ return { x: col.x + 7, y, w: col.w - 14, h: Math.min(VH - 6, actionsY + 34 + 8) - y, actionsY };563+ })();564+565+ // The gauge is measured off whatever is holding it, so it spans the column566+ // when it is under the board and the panel when it is beside it.567+ const gaugeW = split ? panel.w - 24 : Math.round(col.w * 0.6);568+ const gaugeY = split569+ ? groupY570+ : foot + Math.min(22, Math.max(12, Math.round(VH * 0.045)));333571 334572 Object.assign(LY, {335- cell, padX, padTop, padBottom, belowPlate, trayPad,573+ cell, padX, padTop, padBottom, belowPlate, trayPad, panel,336574 plate: { x: plateX, y: plateY, w: plateW, h: plateH },337575 board: {338576 x: plateX + padX + trayPad, y: plateY + padTop + trayPad,339577 w: cell * bw, h: cell * bh,340578 },341- gaugeY: plateY + plateH + belowPlate + Math.round(VH * 0.045),342- hudY: plateY + plateH + belowPlate + Math.round(VH * 0.095),343- // The panel hugs its contents rather than reaching for the bottom of the344- // screen, so the buttons sit with the numbers they belong to.345- panel: (() => {346- const y = plateY + plateH + belowPlate + Math.round(VH * 0.02);347- const actionsY = plateY + plateH + belowPlate + Math.round(VH * 0.095) + 42;348- return { x: 7, y, w: VW - 14, h: Math.min(VH - 6, actionsY + 34 + 8) - y, actionsY };349- })(),579+ gauge: { x: split ? panel.x + 12 : Math.round(col.cx - gaugeW / 2), y: gaugeY, w: gaugeW },580+ gaugeY,581+ hudY: split ? groupY + 14 + inner : foot + LY.hudGap,582+ // The record stands where the readout does -- it is the same row of583+ // numbers, counted up rather than counted down.584+ recordY: split ? groupY + 12 + inner : foot + Math.round(VH * 0.04),350585 });351586 }352587 @@ -814,22 +1049,35 @@ export function renderFrame() {
8141049 * at that weight sitting straight on a dithered ground is exactly the fight8151050 * this rule exists to avoid. */8161051 function drawBoot(c) {817- const { VW, VH } = view;818- const cx = VW / 2;1052+ // Sized off the column rather than the picture: on a wide screen the picture1053+ // is mostly desktop, and type scaled to it would be set for a window twice1054+ // the size of the one it is actually in.1055+ const U = LY.col.w, cx = LY.col.cx;8191056 const win = LY.bootWin;8201057 drawPanel(c, win);8211058 822- label('KLOTSKI', cx, win.y + Math.round(win.h * 0.15),823- { size: fitSize('KLOTSKI', VW * 0.14, 'Silk', VW * 0.02, win.w - 16),824- track: VW * 0.02, align: 'center' });825- label('SLIDING BLOCK PUZZLES', cx, win.y + Math.round(win.h * 0.225),826- { size: fitSize('SLIDING BLOCK PUZZLES', VW * 0.05, 'SilkR', VW * 0.006, win.w - 16),827- face: 'SilkR', track: VW * 0.006, align: 'center' });1059+ /* The title is sized off the column and set by a fraction of the window --1060+ * two measurements that agree on a tall phone and pull apart on a short1061+ * wide one, where the window is wide enough to ask for big type and not tall1062+ * enough to have anywhere to put it. So the size is capped by the height as1063+ * well, and the baseline is never nearer the top of the window than the type1064+ * is tall. Left to the fraction alone, KLOTSKI is printed through the frame.1065+ */1066+ const titleSize = fitSize('KLOTSKI', Math.min(U * 0.14, win.h * 0.19),1067+ 'Silk', U * 0.02, win.w - 16);1068+ const titleY = win.y + Math.max(Math.round(win.h * 0.15), Math.round(titleSize) + 4);1069+ label('KLOTSKI', cx, titleY, { size: titleSize, track: U * 0.02, align: 'center' });1070+ label('SLIDING BLOCK PUZZLES', cx, titleY + Math.round(titleSize * 0.55),1071+ { size: fitSize('SLIDING BLOCK PUZZLES', U * 0.05, 'SilkR', U * 0.006, win.w - 16),1072+ face: 'SilkR', track: U * 0.006, align: 'center' });1073+ const ruleY = titleY + Math.round(titleSize * 0.95);8281074 c.fillStyle = grey(T.rule);829- c.fillRect(win.x + 10, win.y + Math.round(win.h * 0.28), win.w - 20, 1);1075+ c.fillRect(win.x + 10, ruleY, win.w - 20, 1);8301076 831- const top = win.y + Math.round(win.h * 0.34);832- const room = Math.round(win.h * 0.52);1077+ // The specimen takes what is left under the rule rather than its own1078+ // fraction of the window, which is the same trap the title fell into.1079+ const top = ruleY + Math.round(win.h * 0.06);1080+ const room = win.y + win.h - top - Math.round(win.h * 0.22);8331081 const hero = FAMILIES.find(f => f.id === 'classic') || FAMILIES[0];8341082 const cell = Math.max(8, Math.min(Math.floor((win.w - 52) / hero.w),8351083 Math.floor(room / hero.h) - 2));@@ -850,22 +1098,22 @@ function drawBoot(c) {
8501098 8511099 /* --- the sweep, made visible ---------------------------------------------- */8521100 function drawSurvey(c) {853- const { VW, VH } = view;854- const cx = VW / 2, sv = App.pending;1101+ const { VH } = view;1102+ const U = LY.col.w, cx = LY.col.cx, sv = App.pending;8551103 8561104 label('MAPPING THE STATE SPACE', cx, VH * 0.42,857- { size: fitSize('MAPPING THE STATE SPACE', VW * 0.05, 'SilkR', VW * 0.006, VW * 0.84),858- face: 'SilkR', track: VW * 0.006, align: 'center' });1105+ { size: fitSize('MAPPING THE STATE SPACE', U * 0.05, 'SilkR', U * 0.006, U * 0.84),1106+ face: 'SilkR', track: U * 0.006, align: 'center' });8591107 label((sv ? sv.count : 0).toLocaleString('en-US'), cx, VH * 0.53,860- { size: VW * 0.1, track: VW * 0.008, align: 'center' });1108+ { size: U * 0.1, track: U * 0.008, align: 'center' });8611109 label(sv && sv.phase === 2 ? 'MEASURING DISTANCE TO GOAL' : 'CONFIGURATIONS REACHED',8621110 cx, VH * 0.575,863- { size: fitSize('MEASURING DISTANCE TO GOAL', VW * 0.038, 'SilkR', 0, VW * 0.84),1111+ { size: fitSize('MEASURING DISTANCE TO GOAL', U * 0.038, 'SilkR', 0, U * 0.84),8641112 face: 'SilkR', align: 'center', alpha: 0.75 });8651113 label(sv ? sv.fam.label : '', cx, VH * 0.645,866- { size: VW * 0.05, track: VW * 0.01, align: 'center' });1114+ { size: U * 0.05, track: U * 0.01, align: 'center' });8671115 868- const w = Math.round(VW * 0.5), x = Math.round(cx - w / 2), y = Math.round(VH * 0.70);1116+ const w = Math.round(U * 0.5), x = Math.round(cx - w / 2), y = Math.round(VH * 0.70);8691117 fill(c, x, y, w, 4, 255);8701118 c.fillStyle = grey(T.rule);8711119 c.fillRect(x - 1, y - 1, w + 2, 1); c.fillRect(x - 1, y + 4, w + 2, 1);@@ -881,48 +1129,68 @@ function drawSurvey(c) {
8811129 * The card shows where you are on that ladder and what the next board costs.8821130 */8831131 export function atlasGeometry() {884- const { VW, VH } = view;885- const margin = 7, width = VW - 14;1132+ const { VH, mode } = view;1133+ const col = LY.col;1134+1135+ /* Two columns of cards on a wide screen. Eight sets in a single column of a1136+ * landscape picture is three of them visible and five below the fold, which1137+ * makes a list of eight things read as a list of three -- and the scroll1138+ * that reveals the rest is the one gesture this app otherwise never asks1139+ * for. Filled down the first column and then the second, so it still reads1140+ * as one ordered ladder rather than as two lists. */1141+ const cols = mode === 'split' && FAMILIES.length > 3 ? 2 : 1;1142+ const gapX = 8;1143+ const margin = col.x + 7;1144+ const width = Math.round((col.w - 14 - (cols - 1) * gapX) / cols);1145+ const rows = Math.ceil(FAMILIES.length / cols);8861146 8871147 // The board column is a fixed width and the text column starts at a fixed8881148 // x, whatever shape the board is. Sizing the column to each board made every8891149 // row start its name in a different place and at a different size, which is8901150 // the one thing a list of like items must not do.8911151 const boardCol = Math.round(width * 0.33);892- const tx = margin + 8 + boardCol + 10;1152+ const tx = 8 + boardCol + 10; // from the card's own left edge1153+1154+ // Type is sized off the card rather than off the picture, so a pair of1155+ // half-width cards sets its names for the space a half-width card has.1156+ const U = width * 1.08;8931157 8941158 // One size for every name, and the name gets the whole text column. Sharing8951159 // that line with the stage number left so little room that the names came8961160 // out smaller than the numbers beside them, which inverts the hierarchy of8971161 // the card: the set is the subject, the stage is a detail about it.898- const stageSize = snap(VW * 0.038);899- const avail = margin + width - 8 - tx;900- let nameSize = snap(VW * 0.052);1162+ const stageSize = snap(U * 0.038);1163+ const avail = width - 8 - tx;1164+ let nameSize = snap(U * 0.052);9011165 for (const f of FAMILIES) {902- nameSize = Math.min(nameSize, fitSize(f.label, nameSize, 'Silk', VW * 0.006, avail));1166+ nameSize = Math.min(nameSize, fitSize(f.label, nameSize, 'Silk', U * 0.006, avail));9031167 }9041168 905- return {906- top: LY.barH + 8,907- rowH: Math.max(52, Math.round(VH * 0.155)),908- gap: 6, margin, width, boardCol, tx, nameSize, stageSize,909- };1169+ /* One column is a list and always scrolls -- eight sets never fit a phone.1170+ * Two columns usually can fit, so they are sized to, and the scroll goes1171+ * away on anything with the room for it. */1172+ const top = LY.barH + 8, gap = 6;1173+ const rowH = cols > 11174+ ? Math.min(72, Math.max(46, Math.floor((VH - top - 8) / rows) - gap))1175+ : Math.max(52, Math.round(VH * 0.155));1176+1177+ return { top, rowH, gap, margin, width, boardCol, tx, nameSize, stageSize, cols, gapX, rows, U };9101178 }9111179 export function familyRowRect(G, fi) {9121180 return {913- x: G.margin,914- y: Math.round(G.top + fi * (G.rowH + G.gap) + App.atlasScroll),1181+ x: G.margin + Math.floor(fi / G.rows) * (G.width + G.gapX),1182+ y: Math.round(G.top + (fi % G.rows) * (G.rowH + G.gap) + App.atlasScroll),9151183 w: G.width, h: G.rowH,9161184 };9171185 }9181186 export function atlasContentHeight() {9191187 const G = atlasGeometry();920- return FAMILIES.length * (G.rowH + G.gap) + 8;1188+ return G.rows * (G.rowH + G.gap) + 8;9211189 }9221190 9231191 function drawAtlas(c) {9241192 const { VW, VH } = view;925- const G = atlasGeometry();1193+ const G = atlasGeometry(), U = G.U;9261194 setClip({ x: 0, y: LY.barH + 1, w: VW, h: VH - LY.barH - 1 });9271195 9281196 FAMILIES.forEach((fam, fi) => {@@ -952,14 +1220,14 @@ function drawAtlas(c) {
9521220 shading: look.shading, ruling: fitRuling(look.ruling, dpx(mini - 2)),9531221 });9541222 955- const tx = G.tx;956- label(fam.label, tx, r.y + 16, { size: G.nameSize, track: VW * 0.006 });1223+ const tx = r.x + G.tx;1224+ label(fam.label, tx, r.y + 16, { size: G.nameSize, track: U * 0.006 });9571225 label(`STAGE ${rec.rung}`, r.x + r.w - 8, r.y + 31,958- { size: G.stageSize, face: 'SilkR', track: VW * 0.004, align: 'right', alpha: 0.85 });1226+ { size: G.stageSize, face: 'SilkR', track: U * 0.004, align: 'right', alpha: 0.85 });9591227 9601228 if (pz) {9611229 label(`${pz.metrics.L} STEPS`, tx, r.y + 31,962- { size: snap(VW * 0.036), face: 'SilkR', alpha: 0.85 });1230+ { size: snap(U * 0.036), face: 'SilkR', alpha: 0.85 });9631231 const gr = grade(pz.metrics.score);9641232 c.fillStyle = grey(T.rule);9651233 for (let m = 0; m < 5; m++) {@@ -979,12 +1247,12 @@ function drawAtlas(c) {
9791247 const k = Math.max(0, Math.min(1, depthForRung(rec.rung, sv.ecc) / Math.max(1, sv.ecc)));9801248 c.fillRect(tx + Math.round(k * (bw - 3)), by2 - 4, 3, 6);9811249 label(atCeiling(rec.rung, sv.ecc) ? 'AT THE CEILING' : `TO ${sv.ecc}`,982- tx + bw + 6, by2 + 3, { size: snap(VW * 0.03), face: 'SilkR', alpha: 0.6 });1250+ tx + bw + 6, by2 + 3, { size: snap(U * 0.03), face: 'SilkR', alpha: 0.6 });9831251 } else {9841252 // Set in the middle of the space the played rows fill, rather than9851253 // tucked under the name leaving the rest of the card empty.9861254 label('TAP TO BEGIN', tx, r.y + Math.round((r.h + 26) / 2) + 6,987- { size: snap(VW * 0.04), face: 'SilkR', track: VW * 0.004, alpha: 0.65 });1255+ { size: snap(U * 0.04), face: 'SilkR', track: U * 0.004, alpha: 0.65 });9881256 }9891257 });9901258 @@ -1000,7 +1268,7 @@ function drawAtlas(c) {
10001268 const logo = Math.round(LY.barH * 0.62);10011269 devLogo(8 + logo / 2, LY.barH / 2, logo, css(logoInk(save.palette, dayOfYear())));10021270 label('PUZZLES', 8 + logo + 6, LY.barH - Math.round(LY.barH * 0.28),1003- { size: LY.barH * 0.5, track: VW * 0.008 });1271+ { size: LY.barH * 0.5, track: LY.barH * 0.08 });10041272 }10051273 10061274 /* puzzleFor memoises per (family, rung), so calling it every frame costs a@@ -1008,25 +1276,25 @@ function drawAtlas(c) {
10081276 10091277 /* --- the card between boards ---------------------------------------------- */10101278 function drawCard(c) {1011- const { VW, VH } = view;1012- const cx = VW / 2;1279+ const { VH } = view;1280+ const U = LY.col.w, cx = LY.col.cx;10131281 const pz = App.pz;1014- const win = { x: 14, y: Math.round(VH * 0.37), w: VW - 28, h: Math.round(VH * 0.25) };1282+ const win = { x: LY.col.x + 14, y: Math.round(VH * 0.37), w: U - 28, h: Math.round(VH * 0.25) };10151283 drawPanel(c, win);10161284 10171285 // Arrives in discrete steps, never a smooth fade: a continuous ramp would10181286 // put in-between frames in the middle of a one-bit game.10191287 const step = Math.min(4, Math.floor(Math.min(1, App.cardT / 280) * 5));10201288 label(App.fam.label, cx, win.y + Math.round(win.h * 0.42), {1021- size: fitSize(App.fam.label, VW * 0.11, 'Silk', VW * 0.016, win.w - 18),1022- track: VW * 0.016, align: 'center', alpha: step / 4,1289+ size: fitSize(App.fam.label, U * 0.11, 'Silk', U * 0.016, win.w - 18),1290+ track: U * 0.016, align: 'center', alpha: step / 4,10231291 });10241292 10251293 if (App.cardT > 240) {10261294 label(`STAGE ${App.rung}`, cx, win.y + Math.round(win.h * 0.66),1027- { size: VW * 0.05, face: 'SilkR', track: VW * 0.008, align: 'center' });1295+ { size: U * 0.05, face: 'SilkR', track: U * 0.008, align: 'center' });10281296 label(`${pz.metrics.L} STEPS MINIMUM`, cx, win.y + Math.round(win.h * 0.87),1029- { size: VW * 0.042, face: 'SilkR', track: VW * 0.006, align: 'center', alpha: 0.75 });1297+ { size: U * 0.042, face: 'SilkR', track: U * 0.006, align: 'center', alpha: 0.75 });10301298 }10311299 }10321300 @@ -1039,7 +1307,6 @@ function pieceOffset(p) {
10391307 return { dx: 0, dy: 0 };10401308 }10411309 function drawPlay(c) {1042- const { VW } = view;10431310 const cell = LY.cell, B = LY.board, P = LY.plate;10441311 const rise = App.riseT < 1 ? Math.round((1 - App.riseT) * 12 / 2) * 2 : 0;10451312 const plate = { x: P.x, y: P.y - rise, w: P.w, h: P.h };@@ -1081,12 +1348,14 @@ function drawPlay(c) {
10811348 p.w * cell, p.h * cell, p.c, blockOpt(p));10821349 }10831350 1084- label(App.fam.label, VW / 2,1351+ // Centred on the plate it is printed on, not on the picture: beside a panel1352+ // the plate is no longer in the middle of the screen.1353+ label(App.fam.label, plate.x + plate.w / 2,10851354 plate.y + plate.h - Math.round(LY.padBottom * 0.34),10861355 { size: fitSize(App.fam.label, cell * 0.44, 'Silk', 1.5, P.w - 18),10871356 track: 1.5, align: 'center' });10881357 1089- drawGauge(c, LY.gaugeY - rise);1358+ drawGauge(c, LY.gaugeY - (view.mode === 'split' ? 0 : rise));10901359 }10911360 10921361 /* How far you actually are, against how far you began -- and the reason a@@ -1111,8 +1380,7 @@ function drawGauge(c, y) {
11111380 * that cannot show it hides it.11121381 */11131382 const ref = save.hints === 'best' ? Math.max(d0, ...App.trace) : d0;1114- const { VW } = view;1115- const w = Math.round(VW * 0.6), x = Math.round((VW - w) / 2);1383+ const w = LY.gauge.w, x = LY.gauge.x;11161384 c.fillStyle = grey(T.rule);11171385 c.fillRect(x, y, w, 1);11181386 for (let i = 0; i <= 4; i++) c.fillRect(x + Math.round(i * w / 4), y - 3, 1, 4);@@ -1122,13 +1390,19 @@ function drawGauge(c, y) {
11221390 11231391 /* --- the record ----------------------------------------------------------- */11241392 function drawWon(c) {1125- const { VW } = view;11261393 const P = LY.plate;11271394 const plate = { x: P.x, y: P.y, w: P.w, h: P.h };11281395 drawMenuBar(c);11291396 drawPanel(c, LY.panel);11301397 drawPlate(c, plate);1131- const cx = VW / 2;1398+ const cx = P.x + P.w / 2;1399+ /* Everything here is set inside the plate, so it is measured off the plate.1400+ * It used to be measured off the picture, which was the same number while1401+ * the picture was a phone-wide column and is not one beside a panel -- and1402+ * a three-wide board's plate is narrow enough that it was never quite the1403+ * same number anyway. The 1.15 is what keeps the tall-phone sizes exactly1404+ * where they were. */1405+ const U = P.w * 1.15;11321406 11331407 // Stacked down the centre, and spaced from the trophy's actual height11341408 // rather than by fractions of the plate -- on a short board the fractions@@ -1136,18 +1410,18 @@ function drawWon(c) {
11361410 const cupSize = LY.cell * 0.62;11371411 const cupY = plate.y + Math.round(P.h * 0.085);11381412 trophy(cx, cupY, cupSize);1139- const solvedY = Math.round(cupY + cupSize * 0.55 + snap(VW * 0.045) + 6);1413+ const solvedY = Math.round(cupY + cupSize * 0.55 + snap(U * 0.045) + 6);11401414 label('SOLVED', cx, solvedY,1141- { size: VW * 0.045, face: 'SilkR', track: VW * 0.018, align: 'center' });1142- label(App.fam.label, cx, solvedY + Math.round(VW * 0.085),1143- { size: VW * 0.075, track: VW * 0.01, align: 'center' });1415+ { size: U * 0.045, face: 'SilkR', track: U * 0.018, align: 'center' });1416+ label(App.fam.label, cx, solvedY + Math.round(U * 0.085),1417+ { size: U * 0.075, track: U * 0.01, align: 'center' });11441418 11451419 // Distance to goal, falling as the steps accumulate. The dotted rule is the11461420 // line a perfect solver draws -- straight down. The ridge is what actually11471421 // happened, and every place it climbs is a stretch where the board took you11481422 // further from the answer than you already were.11491423 const gx = plate.x + Math.round(P.w * 0.13), gw = Math.round(P.w * 0.74);1150- const gy = solvedY + Math.round(VW * 0.13), gh = Math.round(P.h * 0.34);1424+ const gy = solvedY + Math.round(U * 0.13), gh = Math.round(P.h * 0.34);11511425 const tr = App.trace, n = Math.max(1, tr.length);11521426 const d0 = Math.max(1, tr[0] || 1);11531427 // Scaled to the furthest you ever got, not to where you started. Going@@ -1190,19 +1464,33 @@ function fmtTime(ms) {
11901464 }11911465 11921466 function drawStats(c) {1193- const { VW, VH } = view;1194- const t = save.totals, cx = VW / 2;1467+ const { VW, VH, mode } = view;1468+ const col = LY.col;1469+ const t = save.totals;11951470 const prog = levelProgress(t.xp || 0);1196- const M = 7, W = VW - 14;1197- let y = LY.barH + 8;1471+1472+ /* Two columns when the screen is wide, because the alternative is a stack of1473+ * panels taller than the picture with the chart -- the one thing on here1474+ * worth looking at -- squeezed to a strip at the bottom. The per-family1475+ * table is the tall item and it is also the one that does not want to be1476+ * cut, so it takes the second column whole. */1477+ const two = mode === 'split';1478+ const gapX = 8;1479+ const M = col.x + 7;1480+ const W = two ? Math.round((col.w - 14 - gapX) / 2) : col.w - 14;1481+ const M2 = M + W + gapX;1482+ const U = W * 1.08; // type is sized off the panel it is in1483+ const cx = M + W / 2;1484+ const top = LY.barH + 8;1485+ let y = top, y2 = top;11981486 11991487 // Level.1200- const lvlH = Math.round(VH * 0.13);1488+ const lvlH = Math.max(48, Math.round(VH * 0.13));12011489 drawPanel(c, { x: M, y, w: W, h: lvlH });1202- label('LEVEL', M + 10, y + 16, { size: snap(VW * 0.036), face: 'SilkR', track: VW * 0.01, alpha: 0.7 });1203- label(String(prog.level), M + 10, y + Math.round(lvlH * 0.72), { size: snap(VW * 0.11), track: VW * 0.006 });1490+ label('LEVEL', M + 10, y + 16, { size: snap(U * 0.036), face: 'SilkR', track: U * 0.01, alpha: 0.7 });1491+ label(String(prog.level), M + 10, y + Math.round(lvlH * 0.72), { size: snap(U * 0.11), track: U * 0.006 });12041492 label(`${t.xp || 0} XP`, M + W - 10, y + 16,1205- { size: snap(VW * 0.036), face: 'SilkR', align: 'right', alpha: 0.7 });1493+ { size: snap(U * 0.036), face: 'SilkR', align: 'right', alpha: 0.7 });12061494 12071495 const bx = M + Math.round(W * 0.38), bw = Math.round(W * 0.52);12081496 const by = y + Math.round(lvlH * 0.58);@@ -1212,11 +1500,11 @@ function drawStats(c) {
12121500 const k = Math.max(0, Math.min(1, prog.into / prog.span));12131501 fill(c, bx, by, Math.max(0, Math.round(bw * k)), 6, 0);12141502 label(`${prog.into} / ${prog.span} TO ${prog.level + 1}`, bx + bw, by + 16,1215- { size: snap(VW * 0.03), face: 'SilkR', align: 'right', alpha: 0.6 });1503+ { size: snap(U * 0.03), face: 'SilkR', align: 'right', alpha: 0.6 });12161504 y += lvlH + 6;12171505 12181506 // Lifetime.1219- const lifeH = Math.round(VH * 0.105);1507+ const lifeH = Math.max(36, Math.round(VH * 0.105));12201508 drawPanel(c, { x: M, y, w: W, h: lifeH });12211509 const eff = t.steps ? Math.round((t.optimal / t.steps) * 100) : 0;12221510 const cells = [@@ -1227,32 +1515,34 @@ function drawStats(c) {
12271515 ];12281516 cells.forEach(([v, k2], i) => {12291517 const x = M + Math.round(W * (i + 0.5) / cells.length);1230- label(v, x, y + Math.round(lifeH * 0.52), { size: snap(VW * 0.048), align: 'center' });1518+ label(v, x, y + Math.round(lifeH * 0.52), { size: snap(U * 0.048), align: 'center' });12311519 label(k2, x, y + Math.round(lifeH * 0.82),1232- { size: snap(VW * 0.028), face: 'SilkR', align: 'center', alpha: 0.65 });1520+ { size: snap(U * 0.028), face: 'SilkR', align: 'center', alpha: 0.65 });12331521 });12341522 y += lifeH + 6;12351523 1236- // Per family.1524+ // Per family -- the second column when there is one, the next panel down1525+ // when there is not.1526+ const fmX = two ? M2 : M, fmY = two ? y2 : y;12371527 const rowH = 11;12381528 const famH = 16 + FAMILIES.length * rowH + 6;1239- drawPanel(c, { x: M, y, w: W, h: famH });1240- const col = [M + 8, M + Math.round(W * 0.50), M + Math.round(W * 0.68), M + W - 8];1241- const head = snap(VW * 0.028);1242- label('SET', col[0], y + 12, { size: head, face: 'SilkR', alpha: 0.55 });1243- label('STAGE', col[1], y + 12, { size: head, face: 'SilkR', align: 'right', alpha: 0.55 });1244- label('DONE', col[2], y + 12, { size: head, face: 'SilkR', align: 'right', alpha: 0.55 });1245- label('DEEPEST', col[3], y + 12, { size: head, face: 'SilkR', align: 'right', alpha: 0.55 });1529+ drawPanel(c, { x: fmX, y: fmY, w: W, h: famH });1530+ const tc = [fmX + 8, fmX + Math.round(W * 0.50), fmX + Math.round(W * 0.68), fmX + W - 8];1531+ const head = snap(U * 0.028);1532+ label('SET', tc[0], fmY + 12, { size: head, face: 'SilkR', alpha: 0.55 });1533+ label('STAGE', tc[1], fmY + 12, { size: head, face: 'SilkR', align: 'right', alpha: 0.55 });1534+ label('DONE', tc[2], fmY + 12, { size: head, face: 'SilkR', align: 'right', alpha: 0.55 });1535+ label('DEEPEST', tc[3], fmY + 12, { size: head, face: 'SilkR', align: 'right', alpha: 0.55 });12461536 FAMILIES.forEach((fam, i) => {12471537 const rec = famStats(fam.id);1248- const ry = y + 16 + (i + 1) * rowH - 2;1538+ const ry = fmY + 16 + (i + 1) * rowH - 2;12491539 const dim = rec.solved ? 1 : 0.45;1250- label(fam.label, col[0], ry, { size: snap(VW * 0.032), alpha: dim });1251- label(String(rec.rung), col[1], ry, { size: snap(VW * 0.032), face: 'SilkR', align: 'right', alpha: dim });1252- label(String(rec.solved), col[2], ry, { size: snap(VW * 0.032), face: 'SilkR', align: 'right', alpha: dim });1253- label(String(rec.deepest || 0), col[3], ry, { size: snap(VW * 0.032), face: 'SilkR', align: 'right', alpha: dim });1540+ label(fam.label, tc[0], ry, { size: snap(U * 0.032), alpha: dim });1541+ label(String(rec.rung), tc[1], ry, { size: snap(U * 0.032), face: 'SilkR', align: 'right', alpha: dim });1542+ label(String(rec.solved), tc[2], ry, { size: snap(U * 0.032), face: 'SilkR', align: 'right', alpha: dim });1543+ label(String(rec.deepest || 0), tc[3], ry, { size: snap(U * 0.032), face: 'SilkR', align: 'right', alpha: dim });12541544 });1255- y += famH + 6;1545+ if (two) y2 += famH + 6; else y += famH + 6;12561546 12571547 // Recent solves, deepest first-glance shape of how the ladder is going.12581548 const log = save.log || [];@@ -1261,7 +1551,7 @@ function drawStats(c) {
12611551 // of the page empty beneath it.12621552 const chartH = Math.max(38, VH - y - 30);12631553 drawPanel(c, { x: M, y, w: W, h: chartH });1264- label('RECENT DEPTH', M + 8, y + 12, { size: snap(VW * 0.03), face: 'SilkR', alpha: 0.6 });1554+ label('RECENT DEPTH', M + 8, y + 12, { size: snap(U * 0.03), face: 'SilkR', alpha: 0.6 });12651555 if (log.length) {12661556 const gx = M + 8, gw = W - 16;12671557 const gy = y + 16, gh = chartH - 24;@@ -1279,10 +1569,10 @@ function drawStats(c) {
12791569 });12801570 c.fillRect(gx, gy + gh + 1, gw, 1);12811571 label(String(peak), M + W - 8, y + 12,1282- { size: snap(VW * 0.03), face: 'SilkR', align: 'right', alpha: 0.6 });1572+ { size: snap(U * 0.03), face: 'SilkR', align: 'right', alpha: 0.6 });12831573 } else {12841574 label('NOTHING SOLVED YET', cx, y + Math.round(chartH * 0.62),1285- { size: snap(VW * 0.034), face: 'SilkR', align: 'center', alpha: 0.5 });1575+ { size: snap(U * 0.034), face: 'SilkR', align: 'center', alpha: 0.5 });12861576 }12871577 y += chartH + 6;12881578 @@ -1296,12 +1586,12 @@ function drawStats(c) {
12961586 label(swept.length12971587 ? `${states.toLocaleString('en-US')} CONFIGURATIONS MAPPED`12981588 : `${FAMILIES.length} PIECE SETS, NONE MAPPED YET`,1299- cx, y + 12, { size: snap(VW * 0.03), face: 'SilkR', align: 'center', alpha: 0.6 });1589+ cx, y + 12, { size: snap(U * 0.03), face: 'SilkR', align: 'center', alpha: 0.6 });13001590 }13011591 13021592 drawMenuBar(c);13031593 // Centred, because unlike the atlas this screen has a BACK button sitting in1304- // the left of the bar.1305- label('RECORD', cx, LY.barH - Math.round(LY.barH * 0.28),1306- { size: LY.barH * 0.5, track: VW * 0.008, align: 'center' });1594+ // the left of the bar -- and centred on the bar, which spans the screen.1595+ label('RECORD', VW / 2, LY.barH - Math.round(LY.barH * 0.28),1596+ { size: LY.barH * 0.5, track: LY.barH * 0.08, align: 'center' });13071597 }@@ -21,10 +21,10 @@
2121 2222 html, body {2323 height: 100%;24- /* The upscale is a whole number, so on anything wider than a phone there is25- a margin of page either side of the picture. It has to carry the backdrop26- rather than the paper, or the edge of the canvas becomes a visible seam27- the moment the two stop being the same colour. */24+ /* The picture now covers the whole safe area, so what is left of the page is25+ the strip the hardware took -- a notch, a rounded corner, a home bar. It26+ carries the backdrop rather than the paper, or the edge of the canvas27+ becomes a visible seam the moment the two stop being the same colour. */2828 background: var(--ground);2929 color: var(--ink);3030 font-family: Mono, ui-monospace, monospace;@@ -56,11 +56,19 @@ body {
5656 #ui { position: absolute; inset: 0; pointer-events: none; }5757 #ui button { pointer-events: auto; }5858 59+/* Left and width are written by placeDom in picture units: a zone belongs to60+ the panel it sits in, and only the menu bar still spans the screen. */5961 .zone {6062 position: absolute; left: 0; width: 100%;6163 display: none; align-items: center; justify-content: center;6264 gap: calc(13px * var(--s));6365 }66+/* Beside the board rather than under it, the panel is a third of the width it67+ had, and the spacing that set three readouts across a phone has to come in68+ with it. The type does not change size -- it is the same panel at the same69+ scale, just narrower. */70+[data-mode="split"] .zone { gap: calc(6px * var(--s)); }71+[data-mode="split"] #acts { gap: calc(5px * var(--s)); }6472 /* The menu bar is already white; a button background would punch a hole in6573 its rule. */6674 #top { justify-content: flex-start; gap: calc(5px * var(--s)); padding: 0 calc(8px * var(--s)); }@@ -97,7 +105,7 @@ body {
97105 #top .iconbtn:active { background: var(--ink); color: var(--paper); }98106 #acts { gap: calc(6px * var(--s)); }99107 100-.stat { display: flex; flex-direction: column; align-items: center; gap: calc(2px * var(--s)); }108+.stat { display: flex; flex-direction: column; align-items: center; gap: calc(2px * var(--s)); min-width: 0; }101109 .val { font-family: Silk, monospace; font-size: calc(10px * var(--s)); }102110 .lbl { font-size: calc(5px * var(--s)); letter-spacing: calc(1px * var(--s)); opacity: .72; }103111 #gauge { justify-content: space-between; gap: 0; }@@ -135,6 +143,19 @@ button.ghost { border-color: transparent; }
135143 pointer-events: auto;136144 }137145 #sheet.open { display: flex; }146+/* On a short screen the six stacked groups are taller than the screen is. Two147+ columns rather than a scroll: a settings sheet you have to scroll to reach148+ CLOSE is a dialog that has stopped being a dialog. The credit and the button149+ take a line of their own, which is where they were anyway. */150+[data-mode="split"] #sheet.open {151+ display: grid;152+ grid-template-columns: auto auto;153+ justify-content: center; align-content: center;154+ gap: calc(7px * var(--s)) calc(16px * var(--s));155+ padding: calc(10px * var(--s));156+}157+[data-mode="split"] #sheet .credit,158+[data-mode="split"] #sheet #btnClose { grid-column: 1 / -1; justify-self: center; }138159 .group { display: flex; flex-direction: column; align-items: center; }139160 .grouplbl {140161 font-size: calc(5px * var(--s)); letter-spacing: calc(1.6px * var(--s));