clone

an offline sliding-block puzzle game in gleam and lustre, with an endless ladder of puzzles ranked by search

Answer the two questions the system has already been asked

Dami committed 5e637c2
Dark, and stillness. Both were answered somewhere else by the person
using this, and both are read live rather than at boot -- a phone that
goes dark at sunset should not need a reload, and someone turning motion
off because of something this app just did should see it take effect
while they are still looking at it.

prefers-color-scheme becomes the theme row's AUTO, and the default for
anyone who has not chosen. It resolves to one pair, MACINTOSH and NIGHT,
rather than to a rule over the whole table: PAPER is a warmer version of
the same light idea and AMOLED is a deliberate extreme, and neither is
what someone means when they have told their phone they prefer dark.
They mean the house look, in the dark, which is the pair these tables
were written as. AUTO is stored as AUTO and resolved on the way out,
never resolved and written down -- otherwise the app remembers a
decision nobody made. A pinned theme beats the system permanently,
because that is what pinning is.

prefers-reduced-motion stops everything that moves in order to be
pleasant. The board is in place rather than falling into it. A block
arrives where it was sent instead of sliding there. The refused-move
jolt is not drawn, the card's title has already arrived, and the solve's
inverting wipe and its struck frame do not happen at all -- that one is
a full-screen flash, which is the only thing on this list that can
actually hurt someone. What does not stop is anything carrying
information: the sweep still counts up, because it is a progress bar and
a still screen through a second and a half of work is a worse answer.
The sounds and the haptics are untouched; they were never motion. There
is no setting for it. "Reduce motion" is not a preference about this
app, it is a fact about the person, and offering to put it back is
offering to ignore them.

The two paths draw the same picture, and that is a property rather than
a hope. Block ink is cut at the square a piece rests on, so a piece that
jumps and a piece that slides show identical dots when they arrive:
three moves, animated and still, are byte-identical PNGs. Carrying the
phase through the tween instead would have made stillness a second
rendering that nobody reviews, which is the footgun worth not building.
commit
5e637c2b8589d3e0f132c6af3b2544500a504ce3
parent
ff953b0dd805da72b95c137b4052f9cec229c474
author
Dami <dami.etoile@gmail.com>
9 files changed, +232 -12
MREADME.md53++++++++++++++++++++++++++++
Mmockup/src/game.js43+++++++++++++++++++----
Mmockup/src/main.js16+++++++-
Amockup/src/prefs.js46++++++++++++++++++++++++
Mmockup/src/store.js5++-
Mmockup/src/theme.js15++++++++
Mmockup/styles.css6+++
Mmockup/sw.js3+-
Mmockup/tools/check.mjs57+++++++++++++++++++++++++++++-
M README.md +53 -0
@@ -97,6 +97,31 @@ to the block and its shadow so neighbours compose without one sprite's paper mar
9797 next one's shadow. It also means nothing boils while a block slides, which is what the mechanism9898 was originally built for.9999 100+**One screen, ruled across the whole picture.** Dithering each block alone is right for the101+*texture* and was wrong about the *phase*. Every sprite indexed its screen from its own corner, so102+whether two identical blocks a cell apart agreed depended on the screen's period dividing the cell103+in buffer pixels — which is arithmetic luck. At a 32-unit cell it is 96 and every period divides it;104+at 24 units it is 72 and POSTER lands 8 pixels out, BILLBOARD 24 — half a period, on a 360-wide105+phone.106+107+A ruled screen is a pure function of position, so the fix is not a bigger buffer. `shade` takes the108+window's own offset on the sheet, which is *exactly* equivalent to ruling one screen across the109+whole picture and cutting each block out of it — at no cost, because the field never has to exist.110+Diffusion takes no offset and needs none: its texture is aperiodic, so there is no phase to be out111+of. The sprite cache gains the phase as a key and, because the cell is a whole number of periods,112+that is one phase per class rather than one per square.113+114+That last part is now guaranteed rather than hoped for. `fitRuling` already stepped a ruling down115+when a block was too small to carry it; it also steps down until the period divides the cell. So a116+32-unit cell carries the whole ladder and a 24-unit cell stops at COARSE — the coarse end is117+unavailable there rather than available and out of register, which is the better failure. A screen118+that does not hold register is not a coarser screen, it is a misprint.119+120+The block is cut at the phase of the square it **rests** on, never the one it is being dragged121+through. Ink belongs to the block; a piece under a thumb carries its own across the board, and122+because a move is one cell — a whole number of periods — it lands on the pattern it left, with123+nothing to snap. That is also what makes stillness free: see below.124+100125 Nothing small is ever set on the desktop pattern — including the title screen, which is a single101126 centred window holding the title, the specimen board and the prompt. An About box, essentially.102127
@@ -254,6 +279,34 @@ the card before you start, so neither is a hint.
254279 in flat ink. Thresholding a typeface turns it to gravel, which is survivable for a title and255280 unreadable for anything else. Silkscreen for display, Space Mono for labels.256281 282+**What the system already knows, it is not asked again.** Two questions have been answered283+somewhere else and are read live rather than at boot, so a phone that goes dark at sunset does not284+wait for a reload.285+286+`prefers-color-scheme` is the theme row's AUTO, which is the default for anyone who has not chosen.287+It resolves to one pair — MACINTOSH and NIGHT — rather than to a rule over the whole table: PAPER is288+a warmer version of the same light idea and AMOLED is a deliberate extreme, and neither is what289+someone means when they have simply told their phone they prefer dark. They mean the house look, in290+the dark, which is the pair the tables were written as. AUTO is *stored* as AUTO, never resolved and291+written down, or the app would remember a decision the person never made. Pinning a theme wins over292+the system, permanently, because that is what pinning is.293+294+`prefers-reduced-motion` stops everything that moves to be nice: the board is in place instead of295+falling into it, a block arrives where it was sent instead of sliding there, the refused-move jolt296+is not drawn, the card's title has already arrived, and the solve's inverting wipe and its struck297+frame — a full-screen flash, the one thing here that can actually hurt someone — do not happen at298+all. What does not stop is anything carrying information: the sweep still counts up, because that is299+a progress bar and a still screen through a second and a half of work is a worse answer. Sounds and300+haptics are untouched; they were never motion. There is no setting for it, because "reduce motion"301+is not a preference about this app, it is a fact about the person, and offering to put it back is302+offering to ignore them.303+304+The two paths draw the *same picture*, and that is a property rather than a hope: block ink is cut305+at the square a piece rests on, so a piece that jumps and a piece that slides show identical dots on306+arrival. Verified by screenshot — the same three moves, animated and still, are byte-identical PNGs.307+Had the phase been carried through the tween instead, stillness would have been a second rendering308+that nobody reviews, which is the footgun this arrangement exists to not build.309+257310 **One theme object, and four things a board rolls for.** Every colour in the app is written down258311 in `src/theme.js` and nowhere else. A theme owns its ink and paper, its four piece colours, its259312 gold — and four *pools*: which backdrop stencils belong to it, which colour pairs those are
M mockup/src/game.js +36 -7
@@ -29,6 +29,7 @@ import {
2929 import { THEMES } from './theme.js';3030 import { PATTERNS } from './shading.js';3131 import { makeWalker, traceStats } from './walk.js';32+import { prefersStill } from './prefs.js';3233 3334 const $ = id => document.getElementById(id);3435 const cvs = $('board');
@@ -113,7 +114,10 @@ function tick(now) {
113114 * for in the frame it happened in. */114115 App.rise = App.riseT < 1 ? Math.round((1 - App.riseT) * 12 / 2) * 2 : 0;115116 App.shakeOff = App.shake > 0 ? Math.round(Math.sin(App.shake * 0.9) * 2) : 0;116- App.cardStep = Math.min(4, Math.floor(Math.min(1, App.cardT / 280) * 5));117+ // The card's title arrives in five steps, or has already arrived. The card118+ // itself keeps its pause either way: it is a screen naming the board, not an119+ // effect, and cutting it would be cutting information rather than motion.120+ App.cardStep = prefersStill() ? 4 : Math.min(4, Math.floor(Math.min(1, App.cardT / 280) * 5));117121 118122 const key = frameKey();119123 if (key !== lastKey) { lastKey = key; requestRender(); }
@@ -177,7 +181,9 @@ function beginPlay() {
177181 App.trace = [App.sv.dist.get(pz.state)];178182 App.drag = null;179183 App.anim = null;180- App.riseT = 0;184+ // The board falls into place, unless the system asked for stillness -- then185+ // it is simply in place, which is where the fall was always going.186+ App.riseT = prefersStill() ? 1 : 0;181187 App.startedAt = Date.now();182188 go('play');183189 syncHud();
@@ -202,7 +208,11 @@ function win() {
202208 203209 Sound.solve();204210 buzz(App.lastGain.levelled ? [24, 40, 90, 40, 120] : [24, 40, 90]);205- fx.wipe = 0; fx.wipeAxis = 'x'; // the invert wipe, spent once per solve211+ // The invert wipe, spent once per solve -- and skipped entirely under reduced212+ // motion, along with the pause it exists to fill. It is the whole screen213+ // turning over; there is no gentler version of it that is still it.214+ if (prefersStill()) { go('won'); return; }215+ fx.wipe = 0; fx.wipeAxis = 'x';206216 setTimeout(() => go('won'), 330);207217 }208218
@@ -243,12 +253,25 @@ function undo() {
243253 App.steps -= h.cells;244254 App.trace.length = Math.max(1, App.trace.length - h.cells);245255 Sound.page(); buzz(6);246- fx.flash = 55; // a single struck frame of inversion256+ // A single struck frame of inversion. Not drawn under reduced motion: a257+ // full-screen flash is the one thing on this list that can actually hurt258+ // someone, and the undo is already announced by the board and the tone.259+ if (!prefersStill()) fx.flash = 55;247260 syncHud();248261 requestRender();249262 }250263 264+/* A block sliding to where it was sent.265+ *266+ * Under reduced motion it does not slide: it is already there. The move itself267+ * is not the animation -- the board changes either way, the sound plays either268+ * way -- so this returns the same position by the same path, without the tween269+ * in between. Which is only true because a block's ink is cut at its resting270+ * square rather than at wherever it happens to be passing: the two paths draw271+ * the same picture, rather than the still one being a second rendering that272+ * nobody looks at. */251273 function animate(piece, axis, from, to, done) {274+ if (prefersStill()) { if (done) done(); requestRender(); return; }252275 App.anim = {253276 piece, axis, from, to, cur: from, t: 0,254277 dur: Math.max(80, Math.abs(to - from) * 3), done,
@@ -257,7 +280,9 @@ function animate(piece, axis, from, to, done) {
257280 }258281 259282 function refuse(p) {260- App.shake = 12; App.shakePiece = p;283+ // The jolt that says a piece has nowhere to go. Under reduced motion the284+ // thud and the buzz say it on their own; they were never movement.285+ if (!prefersStill()) { App.shake = 12; App.shakePiece = p; }261286 Sound.thud(); buzz([10, 26, 10]);262287 requestRender();263288 }
@@ -463,11 +488,15 @@ $('btnClose').onclick = () => $('sheet').classList.remove('open');
463488 /* Both rows are generated from their registries rather than written into the464489 * markup, so adding a theme or a block style is a one-object edit and the465490 * settings sheet picks it up. */491+/* AUTO first, and the default for anyone who has not chosen: the phone was492+ * already told whether it wants a dark screen, and asking again is asking493+ * someone to state a preference they have stated. The rest pin one, which is494+ * what a person who wants PAPER at night is for. */466495 (function buildThemeRow() {467496 const row = $('themeRow');468- for (const id of Object.keys(THEMES)) {497+ for (const id of ['auto', ...Object.keys(THEMES)]) {469498 const b = document.createElement('button');470- b.textContent = THEMES[id].name;499+ b.textContent = id === 'auto' ? 'AUTO' : THEMES[id].name;471500 b.dataset.theme = id;472501 b.onclick = () => { setTheme(id); persist(); syncSheet(); };473502 row.appendChild(b);
M mockup/src/main.js +14 -2
@@ -5,8 +5,9 @@ import { save, requestPersistence } from './store.js';
55 import { FAMILIES } from './families.js';66 import {77 setTheme, setComboOverride, resolveCombo, lookFor, requestRender, renderFrame, DENSITY,8- setDensity, view, LY, atlasGeometry, familyRowRect,8+ setDensity, activePalette, view, LY, atlasGeometry, familyRowRect, clearSprites,99 } from './render.js';10+import { onSystemChange, prefersDark, prefersStill } from './prefs.js';1011 import { depthForRung, levelProgress, puzzleFor } from './progress.js';1112 import { THEMES, themeById, comboFor } from './theme.js';1213 import { GROUND_NAMES } from './ground.js';
@@ -23,7 +24,7 @@ import { start, placeDom, walkOptimal, walkWandering, openFamily } from './game.
2324 function combo(backdrop, groundName, shading, ruling = 'fine') {2425 if (arguments.length === 0) return resolveCombo();2526 if (backdrop === null) { setComboOverride(null); return 'seeded'; }26- const t = themeById(save.palette);27+ const t = themeById(activePalette());2728 const ground = t.grounds.find(g => g.name === String(groundName).toUpperCase()) || t.grounds[0];2829 const r = effectiveRuling(shading, ruling);2930 setComboOverride({ backdrop, ground, shading, ruling: r });
@@ -39,6 +40,8 @@ window.klotski = {
3940 depthForRung, levelProgress, puzzleFor,4041 THEMES, comboFor, combo, setTheme, lookFor, placeDom, requestRender, renderFrame, setDensity,4142 backdrops: GROUND_NAMES, shading: PATTERN_NAMES, rulings: RULING_NAMES,43+ // What the system asked for, so a harness can check that the app did it.44+ system: () => ({ dark: prefersDark(), still: prefersStill(), palette: activePalette() }),4245 // A getter, not a copy: it is a setting now, and a number read once at boot4346 // would tell the screenshot harness whatever it happened to be then.4447 get DENSITY() { return DENSITY; },
@@ -47,6 +50,15 @@ window.klotski = {
4750 setTheme(save.palette);4851 requestPersistence();4952 53+/* The system can change its mind while the app is open -- a phone that goes54+ * dark at sunset, or someone turning motion off because of something this just55+ * did. Re-resolving costs nothing and applying it late looks like a bug. */56+onSystemChange(() => {57+ if (save.palette === 'auto') setTheme('auto'); // clears the sprite cache too58+ else clearSprites();59+ requestRender();60+});61+5062 if ('serviceWorker' in navigator) {5163 window.addEventListener('load', () => {5264 navigator.serviceWorker.register('./sw.js').catch(() => {});
A mockup/src/prefs.js +46 -0
@@ -0,0 +1,46 @@
1+/* What the system has already been told.2+ *3+ * Two questions the person answered once, somewhere else, and should not have4+ * to answer again here: whether they want a dark screen, and whether they want5+ * things to move. Both are read live rather than at boot -- a phone that6+ * switches to dark at sunset, or a person who turns motion off *because* of7+ * something this app just did, gets the answer applied on the spot.8+ *9+ * Deliberately not settings. The app's own theme row can override the first,10+ * and does; there is no override for the second, because "reduce motion" is not11+ * a preference about this app, it is a fact about the person, and offering to12+ * put it back is offering to ignore them.13+ *14+ * matchMedia is guarded because this file is imported by tooling that has no15+ * window at all -- the headless checks import the theme tables through it.16+ */17+18+const query = q => (typeof window !== 'undefined' && window.matchMedia19+ ? window.matchMedia(q)20+ : { matches: false, addEventListener() {} });21+22+const DARK = query('(prefers-color-scheme: dark)');23+const STILL = query('(prefers-reduced-motion: reduce)');24+25+/** True when the system asks for a dark screen. */26+export const prefersDark = () => DARK.matches;27+28+/* True when the system asks for less movement.29+ *30+ * What that means here is that everything which moves *to be nice* stops: the31+ * board stops falling into place, a block arrives where it was sent instead of32+ * sliding there, the refused-move jolt is not drawn, and the solve's inverting33+ * wipe and its struck frame -- a full-screen flash, which is the one thing on34+ * this list that can actually hurt someone -- do not happen at all.35+ *36+ * What does not stop is anything carrying information: the sweep still counts37+ * up, because that is a progress bar and hiding it would leave a still screen38+ * during a second and a half of work. The sounds and the haptics are untouched;39+ * they were never motion. */40+export const prefersStill = () => STILL.matches;41+42+/** Both, as one subscription -- callers here want to redraw either way. */43+export function onSystemChange(fn) {44+ DARK.addEventListener('change', fn);45+ STILL.addEventListener('change', fn);46+}
M mockup/src/store.js +4 -1
M mockup/src/theme.js +15 -0
M mockup/styles.css +6 -0
M mockup/sw.js +2 -1
M mockup/tools/check.mjs +56 -1
@@ -3,9 +3,12 @@ import { sweep } from '../src/survey.js';
33 import { puzzleFor, depthForRung, xpFor, levelFromXp, xpForLevel, levelProgress } from '../src/progress.js';44 import {55 THEMES, comboFor, houseCombo, validateThemes, luma, distance, logoInk, dayOfYear,6+ AUTO_THEMES, isDarkTheme, resolveTheme,67 } from '../src/theme.js';78 import { GROUND_TILES } from '../src/ground.js';8-import { PATTERNS, RULINGS, effectiveRuling, screenOf } from '../src/shading.js';9+import {10+ PATTERNS, RULINGS, RULING_NAMES, effectiveRuling, fitRuling, screenOf,11+} from '../src/shading.js';912 import { walkTrace, traceStats } from '../src/walk.js';1013 1114 let fail = 0;
@@ -105,6 +108,58 @@ console.log(`six rungs at the ceiling gave ${deep.size} distinct boards`);
105108 `longest run ${st.longestRun}`);106109 }107110 111+/* 3c. register.112+ *113+ * The screen is ruled across the whole picture rather than restarted at each114+ * block, so a block that moves one cell has to land on the pattern it left.115+ * That is true exactly when the cell, in buffer pixels, is a whole number of116+ * periods -- which fitRuling is now responsible for guaranteeing. If this117+ * breaks, neighbouring blocks sit out of register and a sliding one snaps when118+ * it lands, which is the artifact the whole arrangement exists to prevent.119+ */120+/* The reachable grids and no others: fitCell starts at 40 units and steps down121+ * by PERIOD, and DENSITY is 2 or 3, so a cell is one of eight sizes in buffer122+ * pixels. FINE's period is 8 and divides every one of them, which is what makes123+ * the floor of the ladder always available rather than usually. */124+const CELLS = [16, 24, 32, 40], DENSITIES = [2, 3];125+for (const d of DENSITIES) {126+ for (const cell of CELLS) {127+ const gridPx = cell * d;128+ ok(gridPx % 8 === 0, `a ${cell}-unit cell at density ${d} is ${gridPx}px, which FINE cannot divide`);129+ for (const asked of RULING_NAMES) {130+ const got = fitRuling(asked, 9999, gridPx);131+ const period = RULINGS[got].cell * 8;132+ ok(gridPx % period === 0,133+ `grid ${gridPx}: ${asked} settled on ${got}, period ${period} does not divide it`);134+ ok(RULING_NAMES.indexOf(got) <= RULING_NAMES.indexOf(asked),135+ `grid ${gridPx}: ${asked} was stepped *up* to ${got}`);136+ }137+ }138+}139+// The two constraints are independent, and the coarse one still applies.140+ok(fitRuling('billboard', 12, 96) === 'fine', 'a tiny block still steps down to fine');141+ok(fitRuling('billboard', 9999, 96) === 'billboard', 'a 96px grid carries the whole ladder');142+ok(fitRuling('billboard', 9999, 72) === 'coarse', 'a 72px grid stops at coarse');143+// Previews pass no grid and are unconstrained: nothing on an atlas row moves.144+ok(fitRuling('billboard', 9999) === 'billboard', 'a preview should not be held to register');145+console.log(`register: a 96px cell carries ${RULING_NAMES.filter(r => 96 % (RULINGS[r].cell * 8) === 0).length}` +146+ ` of ${RULING_NAMES.length} rulings, a 72px cell ${RULING_NAMES.filter(r => 72 % (RULINGS[r].cell * 8) === 0).length}`);147+148+/* 3d. AUTO resolves to a real pair.149+ *150+ * The light and dark ends have to exist, and they have to actually be light and151+ * dark -- which is not a flag anyone sets, it is what the paper is. A pair that152+ * resolved to two light themes would leave the system preference doing nothing153+ * while appearing to be honoured. */154+for (const [want, id] of Object.entries(AUTO_THEMES)) {155+ ok(THEMES[id], `AUTO ${want} names ${id}, which is not a theme`);156+ ok(isDarkTheme(id) === (want === 'dark'), `AUTO ${want} resolves to ${id}, which is not ${want}`);157+}158+ok(resolveTheme('auto', true) === AUTO_THEMES.dark && resolveTheme('auto', false) === AUTO_THEMES.light,159+ 'auto does not follow the system');160+ok(resolveTheme('paper', true) === 'paper', 'a pinned theme must ignore the system');161+ok(resolveTheme('nonsense', false) === 'macintosh', 'an unknown theme must fall back');162+108163 // 4. xp and levels109164 ok(xpFor({ L: 20, score: 2, steps: 20, firstClear: true }) >110165 xpFor({ L: 20, score: 2, steps: 60, firstClear: true }), 'efficiency must pay');