<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>cat/klot.ski</title>
<subtitle>an offline sliding-block puzzle game in gleam and lustre, with an endless ladder of puzzles ranked by search</subtitle>
<id>https://src.rest/cat/klot.ski/</id>
<link href="https://src.rest/cat/klot.ski/"/>
<updated>2026-08-11T15:11:45-06:00</updated><entry>
<id>b9ed9858726d3d3a2a389c193f58da27583e0f03</id>
<title>The shell has one canonical address</title>
<link href="https://src.rest/cat/klot.ski/commit/b9ed9858726d3d3a2a389c193f58da27583e0f03/"/>
<author><name>Dami</name></author>
<updated>2026-08-11T15:11:45-06:00</updated>
<content type="text">Pages redirects /index.html to /, and the response kept that redirect history when the service worker precached it. Chrome refuses to reuse such a response for a navigation, so the first controlled reload ended at ERR_FAILED even though the shell was present.&#92;n&#92;nPrecache the canonical address instead. A navigation also verifies the complete versioned cache before releasing the shell, and every asset branch resolves to a real offline response rather than rejecting respondWith.</content>
</entry>
<entry>
<id>81549a9cf08bcb2d13c785adf3bf62867f35ffd3</id>
<title>A convention broken should be a decision</title>
<link href="https://src.rest/cat/klot.ski/commit/81549a9cf08bcb2d13c785adf3bf62867f35ffd3/"/>
<author><name>Dami</name></author>
<updated>2026-08-07T22:59:05-06:00</updated>
<content type="text">`docs/style.md`. The rules were only ever in the comments of whichever file had
last had to think about them, which means the reasoning survived and the rule
did not: nothing told you that a packing is nested to survive the formatter
until you had already flattened one.

It records what is actually followed rather than what would be nice — the
formatter as the authority and the three of its behaviours that have already
decided how something here is written, data that keeps its shape, comments that
say why rather than what, the line between Gleam and FFI, names that carry
digests, and the voice a commit message is written in.

The README keeps the commands and points at it for the reasons.</content>
</entry>
<entry>
<id>619a0c3c7682e51c4f04586b984216d626ecba16</id>
<title>The formatter wins, and the board still looks like a board</title>
<link href="https://src.rest/cat/klot.ski/commit/619a0c3c7682e51c4f04586b984216d626ecba16/"/>
<author><name>Dami</name></author>
<updated>2026-08-07T22:59:05-06:00</updated>
<content type="text">15 of 27 modules failed `gleam format --check`, and the reason mattered: the
packings in `families.gleam` were written as pictures of the board, and the
formatter reflows a list of integers to fill the width. Conforming meant losing
the one place where the source shows you the thing it describes.

It does not reflow a list whose elements are themselves lists. So a packing is
now a list of rows, each cell a one-letter constant — `e` empty, `s` single,
`u` upright, `r` recumbent, `g` goal, which are the shape codes `domain` already
encodes with, not a private numbering. Every cell is one character wide, so the
columns line up; the rows survive the formatter because of their structure
rather than in spite of it. `families.grid` flattens at the point of use, which
is where everything downstream wanted it flat anyway.

All nine packings were checked cell by cell against the previous revision and
are identical, and each is exactly width times height cells.

The rest is the formatter run over `src` and `test`. One real loss: it strips
trailing zeros, so the weight column in `difficulty.gleam` reads 0.15, 0.4, 0.6,
0.1 and can no longer be aligned on the decimal point. Not worth the exemption,
and there is no exemption to take.</content>
</entry>
<entry>
<id>0c29377f001337d49c64f04831abb93387bd840a</id>
<title>A door written by somebody who only found the room</title>
<link href="https://src.rest/cat/klot.ski/commit/0c29377f001337d49c64f04831abb93387bd840a/"/>
<author><name>Dami</name></author>
<updated>2026-08-07T22:31:35-06:00</updated>
<content type="text">The README is rebuilt from the working tree and nothing else, by a reader with
no memory of how any of it got there — no history, no branches, no earlier
draft to be loyal to. That is the whole method: documentation drifts because
whoever writes it already knows what it says, and so cannot tell which parts
are still true from the outside.

189 lines against 843, and none of them describe a directory that is not there.
It covers what the thing is, the dev server and the two ways it differs from a
real build, the tests, the bundler and why it is not `lustre/dev build`, Pages,
a map of the modules, and the handful of decisions that will bite somebody who
changes one thing without knowing about the other.</content>
</entry>
<entry>
<id>9b4ae6aba89882ee9d56453b75a874297e725f74</id>
<title>The proof of concept has somewhere else to live</title>
<link href="https://src.rest/cat/klot.ski/commit/9b4ae6aba89882ee9d56453b75a874297e725f74/"/>
<author><name>Dami</name></author>
<updated>2026-08-07T22:22:44-06:00</updated>
<content type="text">`mockup/` and the README go to `archive/mockup`, which is pushed and is the
only thing that needs to hold them. The mockup was locked and unimported — the
production app has been at the repository root since the port — and the README
had become mostly its design record: of 843 lines, some seven hundred described
a directory nobody builds, a release script nobody runs and a server on a port
nobody serves. Documentation that outlives what it documents is worse than
none, because it is indistinguishable from documentation that is merely long.

The `.nzip-*/` ignore goes with it. It was there because nzip published from
`mockup/`, and nothing publishes from anywhere now except Pages, from `dist/`,
on a push.

A README comes back in the next commit, written against what the repository
actually contains.</content>
</entry>
<entry>
<id>75950bfe34fab8e1f9cdf0f3732441dd7b07aac1</id>
<title>The shell was written once, and once was not enough</title>
<link href="https://src.rest/cat/klot.ski/commit/75950bfe34fab8e1f9cdf0f3732441dd7b07aac1/"/>
<author><name>Dami</name></author>
<updated>2026-08-07T20:44:23-06:00</updated>
<content type="text">A navigation whose shell is not in the cache fell through to the network, and
when that failed too the promise rejected — which a browser renders as
ERR_FAILED on a page with nothing on it. The state was also permanent. The
precache is filled by `install`, and `install` only runs again when this file&#39;s
bytes change, so an emptied cache stayed empty until the next deploy: the
navigate branch never wrote what it fetched, so every reload online worked and
every reload offline failed, silently, on an app whose whole point is the
second one.

A missing shell now repairs the entire precache rather than the one file the
navigation asked for, since whatever emptied the cache took the assets with it
and a shell alone renders nothing. `SHELL` is fetched by name rather than the
request, because a request for another path is answered with a 404 page by both
the local server and Pages, and caching that as the shell is a worse failure
than the one being fixed. When there is no network either, the reply is a page
that says so.</content>
</entry>
<entry>
<id>a2c47c76a2f0f82a41ea2d60c0689d6b9aa7d1b3</id>
<title>The tap that took the update is handed back</title>
<link href="https://src.rest/cat/klot.ski/commit/a2c47c76a2f0f82a41ea2d60c0689d6b9aa7d1b3/"/>
<author><name>Dami</name></author>
<updated>2026-08-07T17:28:30-06:00</updated>
<content type="text">Applying a new version at the start of a board was right about when and wrong
about what it costs. I wrote that the reload costs a player the wait and nothing
else; a screenshot of it says otherwise. They tap a puzzle, the app reloads, and
they are looking at the title window. The tap is gone, and what they actually
got for pressing the button was a place at the back of the queue.

So the reload carries their intent. The family they were opening goes into
session storage on the way out and is read back once on the way in, and the app
opens it rather than starting at the title. Session storage and not local, so it
cannot outlive the tab and drop somebody onto a board they never picked; read
once and cleared, so any launch that is not that reload begins where it always
did.

This is the half of the pattern PROXX has that I had left out, and the reason it
has it.</content>
</entry>
<entry>
<id>65a78924d32ed61869df110711f0e2e9373cd0bf</id>
<title>A name that carries its own digest</title>
<link href="https://src.rest/cat/klot.ski/commit/65a78924d32ed61869df110711f0e2e9373cd0bf/"/>
<author><name>Dami</name></author>
<updated>2026-08-07T17:05:26-06:00</updated>
<content type="text">Two builds could contend for one URL, and everything downstream of that was
guesswork. The stopgap — max-age=0 on everything — was the only honest header for
a filename that means different things on different days, but it left the real
hole open: `addAll` fetches through the HTTP cache, so a precache could capture a
new bundle beside an old stylesheet and then serve that pair until the next
deploy. Unlikely, not impossible, and unfalsifiable after the fact.

So every asset now carries a digest of its contents in its name. The mixed cache
stops being unlikely and becomes unrepresentable: the old stylesheet still has
its own name and nothing has overwritten it. Those files are cached for a year
and never revalidated, which is the second thing a digest buys.

Four keep their names, each for its own reason. index.html is the door and needs
a fixed address. sw.js is how an update is noticed at all — a browser compares it
byte for byte — so a hashed one would need updating in order to be updated. The
manifest and its icons are how an installed app is recognised, and moving them
can stop a browser updating it.

The build&#39;s order is the dependency order: a font is named before the stylesheet
that points at it is hashed, and the solver worker before the bundle that names
it. The worker&#39;s URL is substituted into the bundle before the bundle is hashed,
which dissolves the apparent circularity between them and means a bundle can only
ever reach the worker it was built against. They speak a protocol of their own,
so the alternative is not a missing file but a conversation at cross purposes.

The worker&#39;s version is now a digest of everything it precaches, and the manifest
is generated. Both were kept by hand and had drifted: thirteen commits touched the
app and three remembered to bump the version.

And a new version waits. No skipWaiting, so it installs and sits there while the
running one carries on; the app takes it in `begin_play`, the one place a board
starts whichever way it was reached, because that is the only moment a reload
costs a player nothing. Taking over on arrival would swap the code under a board
somebody is halfway through — and would let the new worker&#39;s activation delete
assets the running one still means to fetch, since the solver is not loaded until
the first solve. Deleting the old caches on activate is only safe because of
that: activation waits for the last page on the old version to go.

The headers are two rules that cannot both match. Cloudflare joins duplicate
header names with a comma rather than letting the narrower rule win, so a
Cache-Control on /* would be glued to the front of the immutable one and, being
the lower max-age, would beat it. A 404.html is there too: without one Pages
answers a missing asset with index.html and a 200, which fails later, somewhere
else, as a parse error.

Checked end to end against two real builds: the second installs and waits while
the page keeps serving the first, opening a board applies it, the old cache is
deleted, and the JS keeps its name across a stylesheet-only change. Offline the
whole app still comes up from cache, solver included.</content>
</entry>
<entry>
<id>dbdf91b9aa60aa03365455085b53c0972ca952c6</id>
<title>Four hours is a promise this build cannot keep</title>
<link href="https://src.rest/cat/klot.ski/commit/dbdf91b9aa60aa03365455085b53c0972ca952c6/"/>
<author><name>Dami</name></author>
<updated>2026-08-07T16:27:13-06:00</updated>
<content type="text">Cloudflare serves the HTML with max-age=0 and everything else with max-age=14400.
The filenames are not content hashed — klotski.js is always klotski.js — so for
four hours after a deploy a device that already has the bundle answers from its
own disk and never learns there is a new one. The HTML revalidates and points at
the same URL it always did.

The service worker does not save it. It is network-first, which is the right
shape and reads as though it would, but its `fetch` goes through the same HTTP
cache: &quot;network&quot; was already answering from disk. Nothing in the app can see
past that, which is why this is a header and not a code change.

So a _headers file, and max-age=0 with must-revalidate for everything. That is
not &quot;do not cache&quot; — the copy is kept and its ETag revalidates it, so a file that
has not changed costs a conditional request and a 304 with no body. Offline stays
the service worker&#39;s job, and it still holds its own copy of every core asset.

The alternative is hashing the bundle&#39;s name, which is the better answer for a
big app and the wrong shape for this one: index.html is checked in on purpose so
somebody can read it, and hashed names mean generating it again.</content>
</entry>
<entry>
<id>7906417dea964b0b722c4cfd6847888f9cb8a399</id>
<title>Intensity prints the picture, it does not lay it out</title>
<link href="https://src.rest/cat/klot.ski/commit/7906417dea964b0b722c4cfd6847888f9cb8a399/"/>
<author><name>Dami</name></author>
<updated>2026-08-07T16:20:00-06:00</updated>
<content type="text">Turning the shading down changed the size of every word on screen. On a phone at
390x844 the upscale went from 1.5 to 2, on a 1440x900 laptop from 4.5 to 3, and
on a 1920x1080 one from 3 to 4 — and the chrome is sized in layout units times
that upscale, so a third was added to or taken off every button, every label and
every rule in the DOM. Five of twelve ordinary window sizes did it.

The cause is one argument. The ladder of crisp upscales is density*k/dpr, so it
is cut differently for each density: at dpr 2 the standard density offers 1.5, 3
and 4.5, and the low one offers 1, 2, 3 and 4. `choose` was handed whichever
density the setting was on, so a control over how the picture is *printed* was
picking which layout the app got.

It is handed the standard density now, always. The ladder stops moving, and the
setting does what its label says. The default path is untouched — the standard
density is what it was already choosing from — so this only ever moves the low
setting, and it moves it onto the layout the app was going to have anyway.

What is left: at an upscale where scale*dpr is odd, 1.5 and 4.5 on a dpr 2
screen, a low-density buffer pixel now covers one and a half device pixels
rather than a whole number of them, so the ground&#39;s ruling goes slightly uneven
there. That is a shading artifact in the setting that asks for coarser shading,
which is the right place for it to land, and it is a much smaller thing than
relaying out the app.</content>
</entry>
<entry>
<id>02af01d6be57a4f79795f82536d5b4c98b4872d5</id>
<title>The build tool needed a runtime the app does not</title>
<link href="https://src.rest/cat/klot.ski/commit/02af01d6be57a4f79795f82536d5b4c98b4872d5/"/>
<author><name>Dami</name></author>
<updated>2026-08-07T16:07:41-06:00</updated>
<content type="text">The deploy fetched the compiler, verified it, compiled every package and ran all
fifty-two tests, and then stopped:

    The program `escript` was not found. Is it installed?

`lustre/dev build` is the obvious way to build a Lustre app and it cannot be the
way this one is built anywhere but here. The app targets JavaScript, but
lustre_dev_tools is an Erlang program — it carries mist and glisten and wisp so
it can run a dev server — so building with it needs an Erlang runtime that the
app itself never uses and the deploy image does not have. Pulling an OTP release
down to run a tool that shells out to esbuild is the wrong trade when esbuild is
a Node package and Node is already installed.

So build.sh does the four things that tool was doing: compile to JavaScript,
bundle with a pinned esbuild, take the shell from assets/index.html, copy the
assets in. It is now the local build as well as the deploy&#39;s, so what ships is
what was tested rather than something assembled a second way, and it uses a
compiler that is already installed rather than always fetching one — which is
what made it worth having it work on both platforms.

index.html is checked in rather than generated from a table in gleam.toml. It is
four links and a script tag, and a build step that assembles that from
configuration is indirection over something anyone should be able to open and
read. The table stays for the dev server, which knows no other way to serve a
shell, and each of them says so.

Checked by running the bundle: boot, atlas, a board, a solve and the win screen,
no console errors.</content>
</entry>
<entry>
<id>4caad897bcb5b300459eeefae484cd04533ca2f6</id>
<title>A dim is for reading past, not for hiding behind</title>
<link href="https://src.rest/cat/klot.ski/commit/4caad897bcb5b300459eeefae484cd04533ca2f6/"/>
<author><name>Dami</name></author>
<updated>2026-08-07T15:33:14-06:00</updated>
<content type="text">The stipple was the wrong instrument. It is what a Macintosh did, and what it
does is knock out every other pixel of whatever is behind — which is a way of
saying &quot;not this&quot; and a poor way of leaving something legible. Half a letter is
not a dimmer letter, it is a damaged one.

So it is a veil now: black, at under half alpha. Black in both polarities and for
the same reason each way round. On a light theme it takes the lightness out of
the field and everything standing on it. On a dark one there is no lightness left
in the field to take, so it works on the light letters instead and knocks them
back off the black. Anything tinted to the theme fails on one side or the other —
ink brightens a dark screen, paper washes out a light one.

What is behind stays readable. It just stops being the thing you are reading,
which is the entire job.</content>
</entry>
<entry>
<id>3a85d20b1968350630fd4e434cccf8ae8162d124</id>
<title>The theme is one answer, so it is one menu</title>
<link href="https://src.rest/cat/klot.ski/commit/3a85d20b1968350630fd4e434cccf8ae8162d124/"/>
<author><name>Dami</name></author>
<updated>2026-08-07T15:28:32-06:00</updated>
<content type="text">A row of buttons says these are all live at once, which is the one thing a
setting with a single answer is not. The blocks already knew this — nine screens
plus AUTO wrapped into three ragged lines and read as a wall, so it became a
pull-down. The theme is the same question with a shorter list: one of a closed
set, or AUTO. So it is the same control, and the two now come out of one
function rather than two copies of the same markup.

Which menu is open is a named thing rather than a flag each, so two menus open at
once stops being a state this sheet can get into. Opening one closes the other
for free.

A menu also closes when you click off it, which is what everyone already expects
and what the sheet had no way of hearing before: the sheet listens for the click,
the menu stops its own from reaching it. Escape still works and so does choosing.

And an open menu dims what is behind it, which lifts it off the sheet and is the
only hint anyone gets that clicking away is a thing you can do. A fifty-percent
stipple rather than an alpha fade — but stippled in *paper*, not ink, which is
the whole trick: over the sheet&#39;s own ground it is paper on paper and invisible,
and over a letter or a rule it knocks out every other pixel, so what is behind
reads as dimmed rather than as covered. Ink was backwards on a dark theme, where
the ink is the light colour and stippling with it brightens the very screen it is
meant to be putting to sleep.</content>
</entry>
<entry>
<id>14c2a9d15ffb83ce5574c3fd4814a90e63a9de37</id>
<title>A door is read from its frame, not from three marks</title>
<link href="https://src.rest/cat/klot.ski/commit/14c2a9d15ffb83ce5574c3fd4814a90e63a9de37/"/>
<author><name>Dami</name></author>
<updated>2026-08-07T15:17:34-06:00</updated>
<content type="text">The aperture was the tray&#39;s rule cut across the two cells the goal block leaves
by, with three identical ticks under it. Three marks of one size have to be read
as a group and then interpreted, and the middle one — the only one carrying any
meaning — looked exactly like the two that merely marked a boundary.

So the rule turns a right angle and goes down at each side of the gap. A rule
that stops reads as a rule that ran out; a rule that turns reads as a wall built
up to an opening on purpose. Between the jambs, where the middle tick was, an
arrowhead pointing out of the tray in the goal block&#39;s own colour, which is the
one thing on the board that says which piece the door is for and says it without
a word.

The arrowhead is a new op rather than a fill, because it has to be that colour
and everything in the scene is greyscale until the threshold has run. It is
painted in the overlay pass for the reason the maker&#39;s mark is: it is a flat mark
whose whole job is to be the same shape every time, and a screen put through
something this small returns gravel. It is drawn as a stack of whole-unit runs
and not as a polygon — the rest of this board is square pixels, and a smooth
hypotenuse beside them is a picture from a different machine.

The figure is fitted to what there is between the tray&#39;s rule and the name under
the plate, rather than sized off the cell alone. That strip is the one part of
the plate that does not grow with the board.</content>
</entry>
<entry>
<id>702e7a8fa3e6a7c818151045e906e1d36eea9a48</id>
<title>The lettered buttons were the tallest thing in the bar</title>
<link href="https://src.rest/cat/klot.ski/commit/702e7a8fa3e6a7c818151045e906e1d36eea9a48/"/>
<author><name>Dami</name></author>
<updated>2026-08-07T15:11:14-06:00</updated>
<content type="text">The floor under bar_h was derived from the boxed icons, on the understanding
that they were the tallest thing the menu bar carries. They were not. A lettered
button is six units of type on a 1.25 line with two units of padding and a
hairline each side, which is thirteen and a half against the icons&#39; twelve, so
BACK and INSTALL hung out of the strip at every upscale where the floor bites.

BACK is a ghost with no border and so never looked wrong, and INSTALL carries a
real one but only appears when the app can be installed, which is why this sat
there unseen.

A unit of padding rather than two makes a lettered button eleven and a half, puts
it back under the icons, and makes the derivation true: the icons are the tallest
thing in the bar, and the bar is as tall as the tallest thing it carries plus its
air. Measured at six viewports from 375x667 to 1920x1080: every button of either
kind now clears the rule by at least two units.</content>
</entry>
<entry>
<id>e901cbd18ae2c001f94e51f10db659c85e1f11f4</id>
<title>The deploy image has Node and no compiler</title>
<link href="https://src.rest/cat/klot.ski/commit/e901cbd18ae2c001f94e51f10db659c85e1f11f4/"/>
<author><name>Dami</name></author>
<updated>2026-08-07T15:01:22-06:00</updated>
<content type="text">Cloudflare Pages builds from the repository, and its image carries Go, Node, Bun,
Python and Ruby. It does not carry Gleam, so `gleam run -m lustre/dev build` on
its own is a command not found.

build.sh fetches the compiler instead: a single static binary, pinned to a
version because a toolchain that floats is a build that breaks on a day nobody
touched the repository, and checked against the published digest because a
download nobody verified is a download anybody can replace. Nothing needs Erlang
— the project targets JavaScript, so lustre/dev runs under Node and the compiler
is the only extra thing on the machine. The tests run before the build, so a
deploy cannot outrun them.

The project&#39;s build command becomes ./build.sh and its output directory dist.</content>
</entry>
<entry>
<id>b439fc8d2f99aad07a2147f45aa177b75cebd6fd</id>
<title>A bar is as tall as the tallest thing it carries</title>
<link href="https://src.rest/cat/klot.ski/commit/b439fc8d2f99aad07a2147f45aa177b75cebd6fd/"/>
<author><name>Dami</name></author>
<updated>2026-08-07T15:01:22-06:00</updated>
<content type="text">The two boxed icons at the right of the menu bar were sitting on its rule. Both
are drawn in ink, and a box that comes within a pixel of a line of the same
colour stops reading as a box near a line and starts reading as one shape that
has gone wrong.

The box was not the problem. bar_h is a fraction of the height it has with a
floor under it, and the floor was fourteen units — picked without reference to
what the bar actually holds. An icon is nine units of glyph, half a unit of air
each side and a hairline each side, which is twelve; the strip they sit in is
inset a unit at each end. Fourteen was therefore handing a twelve-unit box a
twelve-unit strip, and at the upscales where the fraction gives way to the floor
they met the rule exactly. Twelve and two of clearance above and below and the
two units of inset is eighteen, and that is what the floor is now derived from
rather than asserted.

Nothing else moves: every viewport whose fraction already cleared the floor is
untouched, and only the ones sitting on it grow.</content>
</entry>
<entry>
<id>8f9d8df8c5e9215409ed8cf35798861df32e645d</id>
<title>The name is the address</title>
<link href="https://src.rest/cat/klot.ski/commit/8f9d8df8c5e9215409ed8cf35798861df32e645d/"/>
<author><name>Dami</name></author>
<updated>2026-08-07T15:01:22-06:00</updated>
<content type="text">KLOT.SKI, on the title window and beside the maker&#39;s mark on the menu bar, from
one constant rather than typed out at each. The stop is part of the word: at
display tracking it reads as the name and not as two of them, and it is the same
string anyone will have typed to arrive.

The menu bar carried PUZZLES, which was doing two jobs — naming the application
where a Macintosh named the application, and labelling the screen underneath it.
It only ever needed to do the first. PUZZLES survives as the button on the win
screen, where it is navigation and not a title.

The tab and the installed app take the name in lower case, since that is how an
address is written.</content>
</entry>
<entry>
<id>8c12cb0f85cde116c0907f0b95a068d9855083ab</id>
<title>A browser is allowed to lie about what it reads back</title>
<link href="https://src.rest/cat/klot.ski/commit/8c12cb0f85cde116c0907f0b95a068d9855083ab/"/>
<author><name>Dami</name></author>
<updated>2026-08-07T15:00:59-06:00</updated>
<content type="text">Brave perturbs getImageData by one, on a scattering of channels, by default, so
that a canvas cannot be used as a fingerprint. That is invisible in a photograph
and it is catastrophic in front of a threshold, which is the one thing this
renderer does with every frame it composes.

The scene is drawn in greyscale onto an offscreen canvas and read back whole,
because error diffusion pays its error forward across the entire buffer. shade()
then fast-pathed the two extremes by testing them for equality: alpha exactly 0
was ground nobody drew over, tone exactly 0 was ink, tone exactly 255 was paper.
Under the perturbation a transparent backdrop pixel whose alpha reads 1 stops
being ground and becomes ink; paper that reads 254 falls out of the fast path,
lands in a tone bucket of its own and gets dithered. It printed as single white
specks scattered over the whole picture, and because the read happens again on
every repaint, they re-rolled whenever the atlas scrolled or a block moved.

So nothing is compared for equality any more. The scene only ever composes 0,
64, 128, 192 and 255, and a band of eight is nowhere near any of them. Diffusion
snaps to the extremes on the way in as well as banding on the way out, because it
is the path that cares most: a flat field of true 255 pays no error forward at
all, and one pixel of 254 in the middle of it starts a trail that then has to
decay back out again.

Sprites come through the same function, so they are covered by the same bands.

Checked by shimming getImageData to nudge a twentieth of a percent of pixels by
one and counting the colours on the board canvas: six when clean, twenty-one
with the shim and the old code, six with the shim and this one.</content>
</entry>
<entry>
<id>d4072597f09131396c507224285e8ec10ab0a444</id>
<title>A card is a stack, not four numbers written down</title>
<link href="https://src.rest/cat/klot.ski/commit/d4072597f09131396c507224285e8ec10ab0a444/"/>
<author><name>Dami</name></author>
<updated>2026-08-07T13:34:57-06:00</updated>
<content type="text">The atlas row set its type off the card&#39;s width and then placed that type at
fixed offsets: the name at 16, the numbers at 31, the difficulty marks at 36,
the meter thirteen units up from the bottom. Those constants describe a
72-unit card, and a card is only 72 units tall at one size. Its height is
clamped — between 46 and 72 in two columns, never under 52 in one — while its
width follows the screen and is clamped by nothing. So a wide short card asked
for four lines cut for a tall one and printed them on top of each other: at
400x700 the meter ran straight through the marks, and at 1280x430 it ran
through the numbers as well, with STEPS and STAGE meeting in the middle from
opposite ends of the same line.

Every one of those offsets now follows from the card. The four lines each claim
the height of the type or the marks they are made of, what is left over is the
air between them shared out evenly and then capped, and the whole stack is
centred. The marks keep their slot on a card that has none, so a played set and
an unplayed one still line their names up. The type takes the smaller of what
the width allows and what the height can hold. The meter is what its caption
leaves after measuring it, rather than a fixed seventy-two units that
AT THE CEILING never fitted beside; the marks and the end stops are cut from
the same unit as the type; and the two halves of the numbers line are measured
against each other instead of both running to the full column.

The faces change with it. Tiny5 for display and DM Mono for everything read
rather than announced, which is why Face is now Display and Text rather than
two weights of one family: Tiny5 ships a single weight, and a bitmap face at
fourteen pixels cannot hold a bold convincingly anyway. Tiny5 is cut on the
same eight-pixel em Silkscreen was and with the same five-pixel capital, so
nothing that was placed by a cap height moved. Cap height is now something
typeface.gleam knows rather than something the painters assume, since that is
the measurement a stack of capitals is actually built on.</content>
</entry>
<entry>
<id>9c4a2ddfcc250b3f6771b877b3b5773ac4166d92</id>
<title>Carry the design systems across instead of describing them</title>
<link href="https://src.rest/cat/klot.ski/commit/9c4a2ddfcc250b3f6771b877b3b5773ac4166d92/"/>
<author><name>Dami</name></author>
<updated>2026-08-06T23:03:55-06:00</updated>
<content type="text">The port had the shape of the app and none of its systems. A catalogue of
thirty-two hand-written levels stood in for an open-ended ladder; CSS class
names stood in for eight screens at five rulings; a hash of two indices stood
in for a seeded look; a fixed palette stood in for four themes with pools. The
survey worker swept a state space and then threw the distance field away,
answering one question with it. None of that is a smaller version of the
mockup. It is a different app that resembles it in a screenshot.

So the systems are now in Gleam, and the two the platform genuinely cannot
express are in FFI behind a Gleam API rather than absent:

- prng.gleam is mulberry32 and FNV-1a, checked against the reference to the
  bit. What is in FFI is the 32-bit multiply and the logical shift, because a
  Gleam Int here is a double. Streams are threaded, so determinism is
  structural rather than conventional.
- render_ffi.mjs is the pixel pipeline: compose, diffuse or threshold with true
  area coverage, reverse the goal block&#39;s mark, reprint the tints and their
  second plate, paint the ground into the holes, blit. It is handed a threshold
  table and a cell size and has never heard of a theme, a family or a rung.

Everything else came across as Gleam: the eight packings, the seven ground
stencils, the eight screens and their rulings with fit_ruling&#39;s coarseness and
register rules, the four themes and combo_for, the depth schedule, the XP curve
and its exact inverse, trace_stats, the versioned save, the layout search over
both arrangements at every upscale, and the seven painters as pure functions
from state to scene operations.

The ladder is open-ended again. The worker keeps a sample of states at every
depth and measures candidates with an eps-greedy walker; which depth a rung
asks for, how wide the window is and every seed it runs under are decided in
Gleam and arrive in the message. TO GOAL is an exact distance after every legal
move, and the record screen&#39;s ridge is measured rather than inferred.

window.klotski.solve() walks the board through the game&#39;s own input path, which
is how a screenshot of a real run gets taken, and how the flows above were
checked in a browser.</content>
</entry>
<entry>
<id>77d6d0748cb520b2ee8c2c85dc3b5a3c3692eb01</id>
<title>Document production Gleam app and ignore generated artifacts</title>
<link href="https://src.rest/cat/klot.ski/commit/77d6d0748cb520b2ee8c2c85dc3b5a3c3692eb01/"/>
<author><name>Dami</name></author>
<updated>2026-08-06T22:09:23-06:00</updated>
<content type="text"></content>
</entry>
<entry>
<id>a63365f982a8454526693dd3bed53a6d995463c9</id>
<title>Take the cap off ORDERED: it was the wrong diagnosis</title>
<link href="https://src.rest/cat/klot.ski/commit/a63365f982a8454526693dd3bed53a6d995463c9/"/>
<author><name>Dami</name></author>
<updated>2026-08-06T20:20:26-06:00</updated>
<content type="text">The faint rules through a dispersed screen were real and the explanation
was not. A coarse cell does enlarge a scatter, and that is not what was
happening: the compositor was resampling a buffer that did not land on
whole device pixels, and the beat that produced showed up under every
screen once there was a reason to look. Capping the ladder did not move
it, which was the tell, and shipping a bespoke exception for a symptom
is how a renderer accumulates warts nobody can later justify.

So ORDERED has all five rungs back and the look count returns to 1344 a
theme. The machinery stays: effectiveRuling still caps every ruling
against a shape&#39;s own top wherever a ruling can enter, the check still
walks every shape against every rung, and both now assert that nothing
is capped -- which is worth more than deleting it, because the question
was fair even though this answer to it was wrong.</content>
</entry>
<entry>
<id>d3b9638ff412cba9383b53b23fecf6a14691e2fb</id>
<title>A buffer pixel has to land on whole device pixels</title>
<link href="https://src.rest/cat/klot.ski/commit/d3b9638ff412cba9383b53b23fecf6a14691e2fb/"/>
<author><name>Dami</name></author>
<updated>2026-08-06T20:19:59-06:00</updated>
<content type="text">This is the bug that the ORDERED cap was a guess at, and it is not the
screen. The picture is upscaled by S CSS pixels per layout unit and
composed at DENSITY buffer pixels per unit, so one buffer pixel covers
S*dpr/DENSITY device ones. At S=2 on a phone with dpr 2.748 that is
1.832. Nearest-neighbour keeps five buffer rows out of every six and
doubles the sixth, in a rhythm that walks down the screen.

Measured rather than argued: a ground ruled every four buffer pixels
came out seven device pixels apart two hundred and three times and eight
apart a hundred. That is not a texture, it is a beat -- which is why it
reads as the pattern collapsing into lines, and why there is a seam
across the picture where the beat turns over. Every screen was doing it.
The dispersed one was just the easiest to see it in, being the one whose
ink is single pixels.

So the candidate upscales are the ones where it lands whole: S =
DENSITY*k/dpr for integer k, which is every scale at which a buffer pixel
is exactly k device pixels. S stops being an integer and does not need to
be. What has to be whole is the device mapping -- and this makes a
layout unit exactly DENSITY*k device pixels, so the one-unit rules the
whole design is drawn with are whole pixels too, which they never were
on a fractional-dpr display.

Same measurement after: 2.000 device pixels per buffer pixel, every rule
eight apart, two hundred and seventy-five times out of two hundred and
seventy-six.

Preferred rather than forced, at about an eighth of the board&#39;s size,
because which multiples exist depends on the display. At dpr 2.748 the
crisp scale is larger than the integer one and there is nothing to
trade. At dpr 2 with density 3 the only crisp scales are 1.5 and 3, and
3 leaves the column too narrow to hold -- so that display keeps a
resampled board at full size rather than a crisp one at three quarters.
Across eight devices the crisp choice cost nothing at all: a smaller
upscale with a larger cell came out to the same board in millimetres.
Worth naming, though: a unit is a multiple of DENSITY device pixels or it
is not crisp, so the intensity setting and the display interact, and
density 2 is crisp at full size on a dpr-2 screen where density 3 is not.

Type follows the device grid now instead of a capped multiple of the
upscale, for the same reason: it is not dithered and not upscaled from
the buffer, so there was nothing for the cap to protect.</content>
</entry>
<entry>
<id>c08ce727c23409cbc00d8bdcdc3882633d132054</id>
<title>A dispersed screen has a shorter ladder than a clustered one</title>
<link href="https://src.rest/cat/klot.ski/commit/c08ce727c23409cbc00d8bdcdc3882633d132054/"/>
<author><name>Dami</name></author>
<updated>2026-08-06T20:03:12-06:00</updated>
<content type="text">ORDERED at a coarse cell collapses into lines that nobody drew, and on a
1x1 at three buffer pixels a unit it does it plainly: faint rules
through the block, moving as the block moves.

The reason is what &quot;dispersed&quot; means. A clustered screen grows a dot as
its cell grows -- the ink stays in one place and gets bigger, which is
what a coarser press does to a halftone. A dispersed screen has no dot
to grow. Its ink is scattered single cells across the 8x8, so enlarging
the cell enlarges the *scatter*, and past a point it stops being a
texture and becomes a grid. At a light tone that grid is mostly empty,
its occupied cells fall into rows and columns, and the eye joins them.
The block ends up ruled.

So the ladder is a property of the shape and not only of the theme:
ORDERED ends at MEDIUM, which is cells one and two rather than two and
three. Every other ruled shape keeps all five. It is the same argument
as a block too small to carry a whole period, applied to the pattern
rather than to the thing it is printed on.

Capped in effectiveRuling rather than in the pools, because that is the
one gate every ruling passes through -- the seeded roll, a pinned look,
the console handle -- so there is no door left open for it to come back
through. And because a ladder is a prefix of the full one, fitRuling can
still step down from an allowed rung without knowing any of this.

The look count follows honestly rather than being restated: a theme
lists five rulings, ORDERED can print at two of them, so a theme is 1218
looks rather than 1344, counted through effectiveRuling so the number
cannot drift from what is actually on screen.</content>
</entry>
<entry>
<id>5e637c2b8589d3e0f132c6af3b2544500a504ce3</id>
<title>Answer the two questions the system has already been asked</title>
<link href="https://src.rest/cat/klot.ski/commit/5e637c2b8589d3e0f132c6af3b2544500a504ce3/"/>
<author><name>Dami</name></author>
<updated>2026-08-06T19:51:09-06:00</updated>
<content type="text">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&#39;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&#39;s title has already arrived, and the solve&#39;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. &quot;Reduce motion&quot; 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.</content>
</entry>
<entry>
<id>ff953b0dd805da72b95c137b4052f9cec229c474</id>
<title>One screen ruled across the picture, not one per block</title>
<link href="https://src.rest/cat/klot.ski/commit/ff953b0dd805da72b95c137b4052f9cec229c474/"/>
<author><name>Dami</name></author>
<updated>2026-08-06T19:50:34-06:00</updated>
<content type="text">Dithering each block alone is right about the texture and was wrong
about the phase. Every sprite indexed its screen from its own corner, so
whether two identical blocks a cell apart agreed came down to the
screen&#39;s period dividing the cell in buffer pixels -- which is
arithmetic luck. At a 32-unit cell that is 96 and every period divides
it, which is why this looked fine on the phone it was reviewed on. At 24
units it is 72: POSTER lands eight pixels out and BILLBOARD twenty-four,
half a period, on any 360-wide screen.

The fix is not a bigger buffer. A ruled screen is a pure function of
position, so passing a window its own offset on the sheet is exactly
equivalent to ruling one screen across the whole picture and cutting
each block out of it -- and the field never has to exist. Diffusion
takes no offset and needs none: its texture is aperiodic, so there is no
phase for it to be out of. The sprite cache gains the phase as a key,
and because a cell is a whole number of periods that is one phase per
class rather than one per square. Cold frames went 6.2ms to 4.4ms at the
coarse end; warm frames are unchanged, because warm frames never built a
sprite.

The whole-number part is now guaranteed rather than hoped for.
fitRuling already stepped a ruling down when a block was too small to
carry it, which is the same kind of rule; it steps down until the period
divides the cell as well. A 32-unit cell keeps the whole ladder and a
24-unit cell stops at COARSE -- the coarse end unavailable there rather
than available and out of register, which is the better failure. A
screen that does not hold register is not a coarser screen, it is a
misprint. Previews pass no grid and are unconstrained: nothing on an
atlas row moves.

And the block is cut at the phase of the square it *rests* on, never the
one it is being dragged through. The ink belongs to the block, so a
piece under a thumb carries its own across the board; a move is one
cell, which is a whole number of periods, so it lands on the pattern it
left with nothing to snap. Taking the phase from the animated position
instead would have made the ink a function of how the piece got there,
which is the same thing as making it depend on whether it was animated
at all.</content>
</entry>
<entry>
<id>a69b1798f115ce5864e0115698381f671b700806</id>
<title>A solved board says what it cost, and now what it cost it on</title>
<link href="https://src.rest/cat/klot.ski/commit/a69b1798f115ce5864e0115698381f671b700806/"/>
<author><name>Dami</name></author>
<updated>2026-08-06T19:23:37-06:00</updated>
<content type="text">Steps against optimal says how much a run spent. It does not say what
on -- and it very nearly says the rest by itself, which is the part that
decided this. A step back has to be re-walked, so it costs two, and
where a family offers no idle move -- classic offers one every eighty
states -- &quot;wrong moves&quot; is exactly the gap between steps and optimal,
halved. Printing that would be printing the same fact twice.

What cannot be worked out from those two numbers is the shape. Four
setbacks can be four separate stumbles or two holes two deep, and on the
chart that is the difference between four bumps and two. So the panel
reads YOUR STEPS, OPTIMAL, DETOURS, DEEPEST: one excursion above the
closest you had already been is a detour, and DEEPEST is how far back
the worst of them put you.

EARNED gives up the slot. XP is a function of the numbers beside it
rather than a fact about the run, and it is on the record screen with
every other lifetime figure, which is where someone looking for it
would go.

The same two numbers are kept with the solve, so the record screen can
carry them too: CLEAN counts solves that never went backwards at all,
DETOURS the lifetime total of the times one did, and the recent-depth
chart gains a pip under every bar that was a straight descent -- the bar
says how deep the board was, the pip says how it went. Counted as they
happen rather than derived from the log, which is capped, so deriving
them would quietly mean &quot;of the last hundred and forty&quot;. A solve
recorded before any of this has no answer rather than a zero, and gets
no pip.

The lifetime row became two rows of three on the way. Five captions
across a phone put EFFICIENCY and PLAYED into each other and the row
read as one long word; three to a row survives a 360-wide screen and
splits along the meaning anyway -- what you have solved on top, how you
play underneath.</content>
</entry>
<entry>
<id>57bd1489932199b7093d1e0f613107510d348090</id>
<title>A walk can be bad on purpose now</title>
<link href="https://src.rest/cat/klot.ski/commit/57bd1489932199b7093d1e0f613107510d348090/"/>
<author><name>Dami</name></author>
<updated>2026-08-06T19:12:12-06:00</updated>
<content type="text">The walker only ever took steps that reduced the distance to the goal,
which made it useless for the one thing the record screen exists to
show. A descent that only descends is a straight line. Every real run
has bumps in it, and there was no way to produce one -- so every
screenshot of a solved board, and every check, saw the chart in the only
case it was not drawn for.

So a walk takes a budget instead of a rule: how many moves should take
you further from the goal, how many should achieve nothing, everything
else descends. Seeded, so a run can be asked for twice and arrive twice.
The budget is spread across the run rather than spent off the top --
front-loading it was the first attempt and it produces a shape nobody
plays, three mistakes in the first three moves and then a flawless
eighty-step descent.

It lives in its own module because two callers need the same walk: the
headless checks walk their own scratch grid with it, and the browser
harness feeds the same moves through performMove, which is the game&#39;s
own input path -- so the trace, the history, the sounds and the record
all happen exactly as they would under a thumb, and what a screenshot
shows is what a check asserts.

The checks that came with it are the two identities a finished run
satisfies, since a unit move changes the distance by exactly -1, 0 or +1:

    descents = optimal + setbacks
    steps    = optimal + 2*setbacks + idle

If either breaks, either the distance field is not a unit-step
breadth-first one or the walker is playing illegal moves -- and any
&quot;wasted moves&quot; number is a guess rather than an account.

Two things fell out of measuring it. An idle move -- one that leaves the
distance exactly where it was -- is not always available, and how often
it is depends entirely on the family: along classic&#39;s optimal corridor
one state in eighty offers one, while on the 5x4 sets more than half of
all moves are idle. And a detour is not a setback: two setbacks in a row
are one excursion two deep, which is one bump on the chart and not two.
Both are counted, because they answer different questions.</content>
</entry>
<entry>
<id>85f4bddbf0a8e04973965f83de49cd863bdc4a41</id>
<title>The dev-logo is the delta it was drawn as</title>
<link href="https://src.rest/cat/klot.ski/commit/85f4bddbf0a8e04973965f83de49cd863bdc4a41/"/>
<author><name>Dami</name></author>
<updated>2026-08-06T19:11:43-06:00</updated>
<content type="text">The mark on the menu bar was a solid triangle in a ring, which is the
seed drawing with its middle filled in. The seed is a delta: the
triangle is stroked at the same weight as the circle around it, and a
fourth stroke cuts up the inside of it, so what reads at a glance is a
ring, a triangle and a slot -- and at fourteen units none of that is
decoration, it is the only thing telling this apart from a road sign.

Transcribed rather than eyeballed. The seed is a 24-unit box with a
9-radius circle stroked at 2, so every coordinate here is that drawing
divided by its own outer diameter of 20 and scaled back up by the ring&#39;s,
and the single stroke weight -- a tenth of the outer diameter -- is the
circle&#39;s and the delta&#39;s alike, as it is there. Measured back off the
rendered pixels against the seed at the same size: ring 0.833 of the box
against 0.833, delta 0.417 against 0.417, ink area 0.0937 against
0.0938, sitting 0.043 above centre in both.

Still geometry rather than an asset, and still painted after the
threshold in two flat colours -- the ring in the theme&#39;s ink, the delta
in one of its piece colours. An SVG would have to be rasterised and
recoloured per theme to end up in the same place.

lineJoin and lineCap were already set to round and were doing nothing at
all under a fill. They are load-bearing now: three heavy strokes meeting
at sharp angles are a spike at each corner under a mitre, and the seed&#39;s
corners are round.</content>
</entry>
</feed>
