Skip to content

Let Astro write the pages, on the field this branch draws - #33

Merged
bjarneo merged 8 commits into
mainfrom
omarchy-field
Sep 7, 2026
Merged

Let Astro write the pages, on the field this branch draws#33
bjarneo merged 8 commits into
mainfrom
omarchy-field

Conversation

@bjarneo

@bjarneo bjarneo commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Eight commits on this branch. The first draws the deck's background the way the omarchy.org redesign draws its field — it is described in full at the bottom, unchanged. The rest hand the pages to Astro and add what that made easy.

They go together because the tree does not build half-converted.


⚠️ One repository setting has to change, or merging takes the site down

Pages is on build_type: legacy today — serving main at /. This branch deletes the committed HTML from main, so a merge without the switch leaves Pages rebuilding a branch with no index.html in it.

gh api -X PUT repos/omacom/radio.omarchy.org/pages -f build_type=workflow

or Settings → Pages → Source → GitHub Actions.

No workflow can do this for itself: actions/configure-pages only creates a Pages site that does not exist yet and leaves an existing one on whatever source it already has. deploy.yml checks the setting as its first step instead and fails in seconds with that command in the log, rather than after four minutes and a 160 MB upload with "Not Found".

Flip it, then merge. The site 404s from the flip until the first Actions run finishes — a few minutes, most of it uploading the songs. Merging first instead gets you one failed run and an outage of about the same length, so there is nothing to gain by it.

The custom domain and the certificate survive: cname and https_enforced are repository settings, and public/CNAME ships in the artifact as well. github-pages already allows deploys from main, so the environment needs nothing — which is also why the run could not be warmed up from this branch first.


Astro writes the pages

tools/build-routes.py templated index.html through five marked comment regions and committed the thirty-five pages it wrote, so adding one song meant a pull request carrying the thirty-odd files that song moved. Astro does that half now, in CI, and nothing generated lives in the repo. A contributor still sends an MP3 and three lines of JSON.

What is the same, checked rather than assumed

  • dist/sitemap.xml is byte-identical to the one that was committed
  • every structured-data node matches by @id across all six page kinds
  • every panel note matches byte for byte
  • the OG cards re-render byte-identically through the moved tool
  • all 24 derived themes compared value by value against the old theme()
  • every numeric literal in the field compared one by one

build.format is 'file', so /playlist/still-licensed is still a file served at the extensionless path with a 200 and no redirect. The directory form would answer every existing link with a 301 to a trailing slash that none of the canonical links, the sitemap, or the address the deck writes as it plays actually carry.

Things that existed twice now exist once

slugify() and assignSlugs() were in the deck and in the Python, with a test whose whole job was to run both over the same titles and check they still agreed. They are src/lib/slug.ts, imported by both.

The row labels were duplicated the same way and had drifted: the build spelled a date in UTC and the deck in local time, so a row changed under the reader on load. One src/lib/format.ts now, in UTC.

build-routes.py hashed app.js and the two stylesheets by hand and rewrote their ? suffixes in every page, so a cached deck could never be served against a page it no longer fits. That is what a content-hashed bundle is; sw.js needed no change to keep the property.


What got added

The deck can wear the desktop's own theme

omarchy-theme-sync publishes the running Omarchy palette, and the deck's four seeds turn out to be exactly a projection of colors.toml:

seed key
ground background
ink bright_foreground, else foreground
accent accent
line selection

Over the 22 Omarchy themes installed on a machine, those four keys reproduce all 22 of the hand-copied entries in SKINS value for value — the list is this projection, written down. So following the desktop is the derivation the deck already does, with live seeds. It goes first in the picker as desktop and repaints when the desktop theme changes, no reload.

It is the default whenever it is on offer, which needed a distinction the deck did not have: applyTheme() stores the theme it painted on every paint, so a returning listener always has one stored whether they ever chose it or not. That is not a choice. A separate key records what somebody actually picked out of the menu, and only that outranks the machine — and picking desktop is how you go back to following it.

The theme on is tracked by name now rather than by index into SKINS: a list that grows a row at the top when an extension wakes up is a list where an index quietly means the theme below the one that was picked.

Without the extension the entry is not there and the deck is the twenty-four themes it always was. The extension only lets omarchy.org set themes, so this is a one-way follow.

A find box

/ focuses it, escape empties it, and the key cap is also a button because a phone has neither key. Every word has to appear across the title and whoever made it, so koontz fix is a query and not a mistake; both sides are folded, so aurelien finds Aurélien. A filtered row keeps the number it has in the playlist, not the number it has among the matches. It leaves the address alone — everything else the deck does to what is on screen is a place you can send somebody, and a half-typed query is not one.

A link lands on its song

Following a permalink to the twenty-seventh song opened the list at the top of it. What tracks this is a key, not a flag: a permalink paints three times before the list is the real one, and the middle paint still has the seeded copy's index, so a flag gets spent there and scrolls to the wrong row. The test caught exactly that. It is also spent only by a paint that could act on it, so the one-row page does not use it up.

Narrow on purpose: a press on a row is a press on something already on screen, and a track running into the next one leaves the list where its reader left it — the same rule about whose scroller it is that the lyric sheet follows.

The meter is a stereo's meter

Bricks on a fixed lattice, a whole number of them per column, three zones in one hue taken off the LCD's own accent mixes rather than the deck's — that panel has a ground of its own. Depth is which ink a brick wears and never opacity, which is the field's rule and why the two look related. On a light theme the top of the ramp darkens instead of lightening, because contrast is what "hot" means. Nothing is hardcoded; it comes out of whatever theme is on.

The readout, played back as tape

Canvas UI's VHS shader over the LCD, vendored the way that library ships, with the wave, the jitter and the head-switch band driven off the same smoothed loudness the field lights its cells by, and a tear when the record changes. It is the only surface that gets one — nothing on it is a control, so nothing has to be clicked at a position a shader has moved.

Two things had to be worked out: a canvas subtree is not laid out against the canvas's box (the readout took its max-content width, 1646px inside an 1178px canvas, and the clock and source label were laid out past the edge of the texture and never drawn), and the component only re-captures the element on resize, which is right for a page that sits still and wrong for a clock.

It needs Chrome's chrome://flags/#canvas-draw-element. Everywhere else supportsHtmlInCanvas() is false and the readout is plain DOM wearing the CSS scanlines, which is what almost everyone sees.

Light mode was hiding something

The meter went in built out of --g1, --ac and --acHi, and on Rosé Pine half of it disappeared. Measuring that turned up something bigger than the meter.

--g1 and --g2 are the LCD's dim inks — its small labels, the artist line, the total time — and they are the accent mixed 45% and 62% of the way toward the LCD's own ground. That works on a dark theme, where the ground is near-black and the accent is bright. It does not work on a light one, where the ground is near-white and an accent has little contrast against white to begin with. A fraction of the way to the ground is not the same amount of dimming in both directions. On thirteen of the twenty-four themes --g1 was under 3:1 against the panel it sits on — every light theme, worst at 1.83, and several dark ones whose accent is close to their ground.

dim() asks for as much dimming as it can have and walks back in twentieths until the result reads: 3:1 for --g1 at 9px, 2.2:1 for --g2, which is the quietest thing on the panel and meant to be. A theme already clear of the floor is untouched — green, hackerman, kanagawa, lumon and retro 82 come out exactly as before — and --g1 stays ahead of --g2 on all twenty-four.

That needed a second luminance function: lum() is Rec. 709 over gamma-encoded values and it decides whether a theme is light, which it has done the same way for twenty-four themes. relative() linearises, which is what a contrast ratio actually needs.

And the fallbacks had drifted. :root carries the first skin's values as the theme the page wears for the one frame before the deck runs. Twelve of the twenty-three were wrong — --g1 in there was the old --g2's value. Invisible, being one frame, and wrong all the same. Regenerated, and the route test compares the two now, reading the list of properties out of applyTheme() itself so that is not a third copy to keep by hand.

The songs have filenames a URL can carry

They were Artist - Title.mp3, which works — the player encodes it — and arrives as /tracks/Aur%C3%A9lien%20-%20Omarchee%2C%20c'est%20la%20vie.mp3. A working address, and not one anybody can read, type, or paste into mpv without care.

They go through the same slug rule an address does now: aurelien-omarchee-cest-la-vie.mp3. The title and artist in playlist.json keep their punctuation, accents and capitals, because those are what anybody reads; the filename is plumbing, and the test holds it to that shape so it cannot drift back. The one lyric sheet moved with its track, a sheet being named after its MP3.

Nothing was broken before this — all 33 URLs resolved, they were just unreadable. public/tracks/README.md documented the old convention and now documents this one.

A returning visitor, after the rename

Renaming the files left every address alone — slugs come from titles — but the copy of the playlist kept from the last visit still named the old file, and tuneIn() reaches for that copy before the manifest lands so a permalink can start on the first tick. It started playing a file that had moved.

It recovered on its own: the reconnect budget retries wantedSrc(), which reads the address back out of the list. Recovered is not the same as right, though — the listener watched it say reconnecting in 1s (1/8) to find out something the manifest had already told it. loadTracks() now compares what is loaded against what the list says and loads the difference.

Found by asking whether this was good to go rather than by anything failing. The first test written for it passed against the bug twice — once because it used the front page, which never reaches for the kept copy, and once because both versions end up playing the right file. What separates them is whether anybody had to watch it reconnect, which is a state the deck passes through and leaves nothing behind, so the probe records every value the status line has held rather than the one it holds now.

sw.js is v6. The shell changed shape completely and that is what VERSION retires the old caches for: the v5 cache holds the previous index.html, its stylesheets and its deck under addresses that no longer exist, and stale-while-revalidate would have handed a returning visitor the old playlist.json, old filenames and all, once.

It still runs off the end of one song into the next

Asked about, so: already the case and already tested. The browser test seeks the last track to duration - 0.3 and waits for the first to come round, checking the row, the file playing and the address. That check predates this branch and passes.

It starts itself, which took a measurement

click anywhere to start was an admission of defeat and a premature one. No browser grants an audible autoplay to a site nobody has engaged with. The exemption everybody quotes is that a muted autoplay is always allowed — the first attempt at this muted the <audio> element and tried again, and was refused:

attempt result
new Audio(src), muted = true NotAllowedError
new Audio(src), volume = 0 NotAllowedError
<video>, muted = true plays

The exemption is for <video>. So the deck plays through a video element with no picture — an ordinary thing for a media element to be, and nothing else changes: same HTMLMediaElement API, same events, same createMediaElementSource, same media session, still never in the document. playsInline is for iOS, which would otherwise read a play() as a request to go fullscreen and which is also the platform where <audio> could never autoplay at all.

A first visit now arrives with the clock running, the marquee on the song and the row saying playing — silently, and the footer says which kind of playing it is. The first press anywhere turns the sound on where the track has got to, not from the top: the clock has been visibly running and restarting would contradict what the listener has been watching.

Every play() sets muted = false first, so being silenced once is not being silenced for good — pressing next while silent simply comes up with sound. arm() survives for the case where even the muted attempt is refused.

The refused-autoplay section of the browser test went from asserting that the deck asks for a press to asserting that it does not have to.

The icons are drawn, not typed

The transport was characters — ◀◀ ❙❙, the carets, the outbound arrow — and not one of them is in a subset this site ships. JetBrains Mono arrives as latin and latin-ext; geometric shapes and arrows are in neither, so the deck's four most important controls were drawn out of whatever font each platform fell back to. and are in Unicode's emoji set, so a phone is entitled to render the play button in colour.

They are cells on a lattice now, in src/lib/icons.ts — the same material as the 15×15 wordmark, the field's hard cells and the meter's bricks, and the one kind of icon that cannot go blurry because there is no curve in it to resolve. One cell to one CSS pixel with crispEdges.

Play/pause and the theme caret keep both faces in the page with a class deciding which shows, rather than rewriting a button's contents forty times a minute. The one the deck draws is the caret on an episode's row, because the deck builds that row — from the same table, the way the build and the browser share the slug rule. Prev and next are two triangles rather than a bar and one: a disc player marks skip with the bar, a tape deck with the pair, and this deck has a tape on its readout.

The two lists get one each — a note and a microphone. npm test fails if any of those characters ever comes back.

And the tagline is a door

The line that says every song is a pull request now links to tracks/README.md — the three lines of JSON, not the repository front page. Somebody reading that line wants to know how.


Tests

tools/test-routes.py is tools/test-routes.mjs, over dist/, and grew checks for the find box and the fold rule. The browser test grew three sections: finding a song, being sent to a song down the list, and wearing the desktop theme.

npm run check          types clean
npm test               1073 checks
npm run test:browser   129 checks

deploy.yml runs the same build and the same checks on a pull request, minus the publish — no Pages preflight, since a branch cannot change a repository setting, and no artifact, since 160 MB of it is the songs. It is green on this branch.

Four of the new browser checks failed first time and three of those were the tests' own fault, all the same mistake: a page here always arrives with rows already in it, so "a row exists" never means "the deck is running". One existing check had it too — the offline leg read the prerendered row and called the cache empty. It flaked the same way on the old tree, checked with a worktree at HEAD before touching it, so that one is not this branch's.

The one cost

dist/ is 161 MB, 159 MB of it songs. Serving from the branch meant no artifact upload; every deploy now re-uploads the audio. Well under Pages' limits, but it is the price of the Actions source.


The first commit — drawing the background the way omarchy.org draws its field

The redesign of omarchy.org builds its hero out of hard on/off cells on one lattice, thresholded through an ordered dither, each cell wearing one of five inks. Its front page plays "We Can Fix Everything (The Ultimate Machine)" by Kevin Koontz — a record from this repository's own playlist — and links back here. The two should not look like different projects.

So the ground under the deck is now made of the same material.

What changed

The field has an ink ramp. theme() derives five field inks from the accent, the way the redesign hand-picks its --t-field-* steps: fDim, fMid, fLit (the accent itself), fHov (which turned out to already exist as --acHi) and fCrest. applyTheme() writes them out as --field-dim--field-crest so CSS can reach them, and style.css carries the green-theme values as the pre-script fallback.

The background is dithered. drawBg() no longer varies alpha. Cells are hard on or off, thresholded through the 8×8 Bayer matrix plus a fixed per-cell jitter, over two octaves of drifting value noise with a per-cell twinkle. Each lit cell takes dim, mid or lit by heat.

The spectrum is mirrored — bass at the outer edges, treble in towards the deck, columns thickening from the bottom and easing off towards the top. Symmetrical, and it leaves the middle calm where the deck sits.

The lattice is the deck's own. 81 cells across the measured frame, taking the frame's left edge as the origin, so the field cannot drift against the deck at any window size. 81 is the count omarchy.org cuts its wordmark into. The field also stays clear of the frame over 240px, cubed rather than smoothstepped, so texture builds out in the margins instead of packing against the 1px border.

Reduced motion draws. One still frame at t = 0, repainted only on resize or a theme change. It used to skip the canvas entirely, which handed those readers a blank ground.

One thing that did not carry over

The web field weights its threshold 0.78 Bayer to 0.22 jitter, which works when the field is hundreds of cells across. At 81 the ordered tile repeated often enough to read as a visible checkerboard, so the jitter carries more weight and the resting texture is held at 0.34 of a cell's luminance. That was tuned by eye, not derived.

Verification

Rendered locally and checked on a dark theme and on Rosé Pine, where the ramp has to darken against a light ground rather than lighten. No console errors. Nothing outside drawBg(), theme() and applyTheme() is touched, and the deck itself is unchanged.

DESIGN.md is new: it records the redesign's system — the token spine, the six themes, the lattice, the field, the pixel typography, how the track drives it — and what was taken from it, what was not, and why.

🤖 Generated with Claude Code

🤖 Generated with Claude Code

bjarneo and others added 3 commits September 6, 2026 00:12
The redesign of omarchy.org (omacom/omarchy-site#171) builds its hero out
of hard on/off cells on one lattice, thresholded through an ordered dither,
each cell wearing one of five inks. Its front page plays a record from this
repository's own playlist. The two should not look like different projects.

So the field here is made of the same material. theme() derives five field
inks from the accent, the way the redesign hand-picks its --t-field-* steps,
and writes them out as --field-dim through --field-crest. drawBg() stops
varying alpha: cells are hard on or off, thresholded through the 8x8 Bayer
matrix plus a fixed per-cell jitter, over two octaves of drifting value
noise, and each lit cell takes dim, mid or lit by heat.

The spectrum is mirrored, bass at the outer edges and treble in towards the
deck, columns thickening from the bottom. The lattice is the deck's own:
81 cells across the measured frame, taking the frame's left edge as the
origin, so the field cannot drift against the deck at any window size. It
stays clear of the frame over 240px, cubed rather than smoothstepped, so
texture builds in the margins instead of packing against the border.

Reduced motion now paints one still frame rather than skipping the canvas,
which used to hand those readers a blank ground.

One thing did not carry over unchanged. The web field weights its threshold
0.78 Bayer to 0.22 jitter, which works when the field is hundreds of cells
across; at 81 the ordered tile repeated often enough to read as a
checkerboard, so the resting texture is held at 0.34 of a cell's luminance.
That was tuned by eye.

DESIGN.md records the rest of the redesign's system and what was taken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tools/build-routes.py templated index.html through five marked comment
regions and committed the thirty-five pages it wrote, so adding one song
meant a pull request carrying the thirty-odd files that song moved. Astro
does that half now, in CI, and nothing generated lives in the repo.

One commit because the tree does not build half-converted.

What is the same, and checked rather than assumed:

  - dist/sitemap.xml is byte-identical to the one that was committed
  - every structured-data node matches by @id across all six page kinds
  - every panel note matches byte for byte
  - the OG cards re-render byte-identically through the moved tool
  - all 24 derived themes compared value by value against the old theme()
  - every numeric literal in the field compared one by one

build.format is 'file', so /playlist/still-licensed is still a file served
at the extensionless path with a 200 and no redirect. The directory form
would answer every existing link with a 301 to a trailing slash that none
of the canonical links, the sitemap or the address the deck writes carry.

Things that existed twice now exist once. slugify() and assignSlugs() were
in the deck and in the Python, with a test whose whole job was to run both
over the same titles and check they still agreed; they are src/lib/slug.ts,
imported by both. The row labels were duplicated the same way and had
drifted — the build spelled a date in UTC and the deck in local time, so a
row changed under the reader on load. build-routes.py's hand-rolled content
hashing is the bundler's.

Three things are new.

The readout is played back as tape. Canvas UI's VHS shader over the LCD,
vendored the way that library ships, with the wave, the jitter and the
head-switch band driven off the same smoothed loudness the field lights its
cells by, and a tear when the record changes. It is the only surface that
gets one: nothing on it is a control, so nothing has to be clicked at a
position a shader has moved. Chrome behind a flag, and plain DOM wearing the
CSS scanlines everywhere else.

The deck can wear the desktop's own theme. omarchy-theme-sync publishes the
running Omarchy palette, and the deck's four seeds turn out to be exactly a
projection of colors.toml — background, bright_foreground, accent,
selection. Run over the 22 themes installed on a machine those four keys
reproduce all 22 of the hand-copied entries in SKINS value for value, so
following the desktop is the same derivation with live seeds rather than a
new palette. The twenty-four stay, and stay the fallback.

And there is a find box. Every word has to appear across the title and
whoever made it, folded on both sides so an accent nobody will type is not
the difference between finding a song and not. A filtered row keeps the
number it has in the playlist. It leaves the address alone: everything else
the deck does to what is on screen is a place you can send somebody, and a
half-typed query is not one.

tools/test-routes.py is tools/test-routes.mjs, over dist/, and grew the
find box and the fold rule. The browser test grew a section each for
finding a song and for wearing the desktop theme, and one of its existing
checks was wrong: the offline leg read the prerendered rows and called the
cache empty. It flaked the same way on the old tree, which is how I know it
was not this.

736 route checks, 108 browser checks, types clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… sent

Five things on top of the Astro conversion, all of them small except the
first, which turned out not to be a design decision at all.

The deck can wear the desktop's own theme. omarchy-theme-sync publishes the
running Omarchy palette onto <html>, and the deck's four seeds are exactly a
projection of colors.toml — background, bright_foreground, accent, selection.
Over the 22 themes installed on a machine those four keys reproduce all 22 of
the hand-copied entries in SKINS value for value, so this is not a new palette
or an approximation of one: it is the derivation the deck already does, with
the seeds arriving live. It goes first in the picker as "desktop" and repaints
when the desktop theme changes.

It is the default whenever it is on offer, which needed a distinction the deck
did not have: applyTheme() writes the theme it painted on every paint, so a
returning listener always has one stored whether they ever chose it or not.
That is not a choice. STORE_PIN records what somebody actually picked out of
the menu, and only that outranks the machine. Picking "desktop" is how you go
back to following it.

The theme on is tracked by name now rather than by index into SKINS. A list
that grows a row at the top when an extension wakes up is a list where an
index quietly means the theme below the one that was picked.

There is a find box. Every word has to appear across the title and whoever
made it, folded on both sides so an accent nobody will type is not the
difference between finding a song and not. A filtered row keeps the number it
has in the playlist. It leaves the address alone: everything else the deck
does to what is on screen is a place you can send somebody, and a half-typed
query is not one.

A link lands on its song. Following a permalink to the twenty-seventh opened
the list at the top of it. What it holds is a key rather than a flag, because
a permalink paints three times before the list is the real one and the middle
paint still has the seeded copy's index — a flag is spent there and scrolls to
the wrong row, which is what the test caught. It is also spent only by a paint
that could act on it, so the one-row page does not use it up. Narrow on
purpose: a press on a row is a press on something already on screen, and a
track running into the next one leaves the list where its reader left it.

The meter is a stereo's meter. Bricks on a fixed lattice, a whole number of
them per column, three zones in one hue off the LCD's own accent mixes rather
than the deck's — this panel has a ground of its own. Depth is which ink a
brick wears and never opacity, which is the field's rule and why the two look
related. On a light theme the top of the ramp darkens, because contrast is
what hot means.

And the line that says every song is a pull request is now the way to send
one.

736 route checks, 120 browser checks, types clean. Three of the new browser
checks are for mistakes made writing them: a page here always arrives with
rows already in it, so "a row exists" never means "the deck is running".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bjarneo bjarneo changed the title Draw the background the way omarchy.org draws its field Let Astro write the pages, on the field this branch draws Sep 7, 2026
bjarneo and others added 5 commits September 7, 2026 12:17
The merge is the deploy here, so a pull request that would not build had
nowhere to say so until it was too late. Same job, same checks, minus the
publish: no Pages preflight, because a branch cannot change a repository
setting and a check that went red over one would be red on every pull
request; and no artifact, because 160 MB of it is the songs and there is
nothing to publish it to.

Its own concurrency group, so a check never queues behind a deploy and a
second push calls off the check still running on the first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The meter went in built out of --g1, --ac and --acHi, and on Rosé Pine half
of it disappeared. Measuring that turned up something bigger than the meter.

--g1 and --g2 are the LCD's dim inks: the accent mixed 45% and 62% of the way
toward the LCD's own ground. That works on a dark theme, where the ground is
near-black and the accent is bright, and it does not work on a light one,
where the ground is near-white and an accent has little contrast against
white to begin with. A fraction of the way to the ground is not the same
amount of dimming in both directions. On thirteen of the twenty-four themes
--g1 was under 3:1 against the panel it sits on: every light theme, worst at
1.83, and several dark ones whose accent is close to their ground.

dim() asks for as much dimming as it can have and walks back in twentieths
until the result reads — 3:1 for --g1 at 9px, 2.2:1 for --g2, which is the
quietest thing on the panel and meant to be. A theme already clear of the
floor is not touched: green, hackerman, kanagawa, lumon and retro 82 come out
exactly as before, and --g1 stays ahead of --g2 on all twenty-four.

That needed a second luminance function. lum() is Rec. 709 over gamma-encoded
values and it decides whether a theme is light; it has decided that the same
way for twenty-four themes and it is not going to start deciding differently.
relative() linearises, which is what a contrast ratio needs.

The meter has its own ramp now rather than reusing tokens built for text. It
steps away from the ground in whichever direction the theme's lift points, so
the hot end is brighter on a dark theme and darker on a light one, and "hot"
means "more contrast" with nothing special-cased.

While measuring: :root in the stylesheet carries the first skin's values as
the theme the page wears for the frame before the deck runs, and twelve of the
twenty-three were wrong — --g1 in there was the old --g2's value. Invisible,
being one frame, and wrong all the same. Regenerated, and test-routes.mjs
compares the two now, reading the list of properties out of applyTheme()
itself so that is not a third copy to keep by hand.

And the songs have filenames a URL can carry as it stands. They were
`Artist - Title.mp3`, which the player encodes into
/tracks/Aur%C3%A9lien%20-%20Omarchee%2C%20c'est%20la%20vie.mp3 — a working
address and not one anybody can read, type, or paste into mpv. They go through
the same slug rule an address does now. All 33 load, none needs an escape, and
the test holds them to it. The one lyric sheet moved with its track, since a
sheet is named after its MP3.

Nothing was broken before this: all 33 URLs resolved, they were just
unreadable.

841 route checks, 120 browser checks, types clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Slugs come from titles, so renaming the files left every address alone — but
the copy of the playlist kept from the last visit still named the old file,
and tuneIn() reaches for that copy before the manifest lands so a permalink
can start playing on the first tick. It started playing a file that had moved.

It recovered: the reconnect budget retries wantedSrc(), which reads the
address back out of the list, so a second later it was playing the right
thing. Recovered is not the same as right — the listener watched it say
"reconnecting in 1s (1/8)" to find out something the manifest had already
told it. loadTracks() now compares what is loaded against what the list says
and loads the difference.

Found by asking whether the pull request was good to go rather than by
anything failing, so it came with a test that could not tell the two apart:
both end up playing the right file. What separates them is whether anybody had
to watch it reconnect, and that is a state the deck passes through and leaves
nothing behind — so the probe records every value the status line has held,
not just the one it holds now. It fails without the fix and says why.

Two other things the same question turned up:

sw.js is v6. The shell changed shape completely, and VERSION is the thing that
retires the old caches for exactly that — the v5 cache holds the previous
index.html, its stylesheets and its deck under addresses that no longer exist,
and stale-while-revalidate would have served a returning visitor the old
playlist.json, with the old filenames in it, once.

The first arrival at the front page never used the kept copy at all, which is
why the first version of this test passed against the bug: tuneIn() only
restores it for an address that names something out of a list.

841 route checks, 125 browser checks, types clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The transport was characters — ▶ ◀◀ ■ ❙❙, the two carets, the arrow on an
outbound link — and not one of them is in a subset this site ships. JetBrains
Mono arrives as latin and latin-ext; geometric shapes and arrows are in
neither. So the deck shipped three carefully cut webfaces and then drew its
four most important controls out of whatever font each platform fell back to.
❙❙ (U+2759 MEDIUM VERTICAL BAR) is a poor bet anywhere. ▶ and ◀ are worse:
both are in Unicode's emoji set, so a phone is entitled to render the play
button in colour, and some do.

They are cells on a lattice now, in src/lib/icons.ts. Less a style decision
than the only one consistent with the rest of this deck: the wordmark is a
15x15 bitmap of axis-aligned rects, the field is hard on-or-off cells, the
meter is bricks. A stepped triangle is the same material, and it is the one
kind of icon that cannot go blurry, because there is no curve in it to
resolve. One cell to one CSS pixel with crispEdges, so the steps land on the
grid rather than near it.

Play/pause and the theme caret keep both faces in the page with a class
deciding which shows, rather than having the deck rewrite a button's contents
forty times a minute to say the same two things. The one icon the deck draws
is the caret on an episode's row, because the deck builds that row — and it
draws it from the same table, the way the build and the browser share the slug
rule.

Prev and next are two triangles rather than a bar and one: a disc player marks
skip with the bar, a tape deck marks it with the pair, and this deck has a
tape on its readout.

The test fails if any of those characters comes back, and checks every cell is
a whole number inside its own box.

1051 route checks, 125 browser checks, types clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Click anywhere to start" was an admission of defeat and a premature one.

No browser grants an audible autoplay to a site nobody has engaged with, and
there is no arguing with that. The exemption everybody quotes is that a muted
autoplay is always allowed — so the first attempt at this muted the <audio>
element and tried again, and was refused. So was volume = 0. Measured, under
both of Chromium's restrictive policies:

    new Audio(src), muted = true    NotAllowedError
    new Audio(src), volume = 0      NotAllowedError
    <video>, muted = true           plays

The exemption is for <video>. That reads like a loophole and is not much of
one: a media element with no picture is an ordinary thing for a <video> to be,
and nothing else changes — same HTMLMediaElement API, same events, same
createMediaElementSource, same media session, still never in the document, so
there is no frame to lay out and nothing to show. playsInline is for iOS,
which would otherwise take a play() for a request to go fullscreen, and which
is also the platform where <audio> could never autoplay at all.

So a first visit arrives with the clock running, the marquee on the song and
the row saying playing. Silently, and the footer says which kind of playing it
is. The first press anywhere turns the sound on where the track has got to —
not from the top, because the clock and the marquee have been visibly running
and restarting would contradict what the listener has been watching. Joining
part-way through is what a radio is.

Every play() sets muted = false first, so being silenced once is not being
silenced for good: a press earns an audible attempt where an arrival did not,
which is why pressing next while silent simply comes up with sound. arm() is
still there for the case where even the muted attempt is refused.

The one press that does not mean what its button's face says is play, while
the deck is playing silently: there it means "sound", and the status line is
what asked for it.

Also: the two lists have icons. A note for the songs, a microphone for the
show, on the same lattice as the rest.

1073 route checks, 129 browser checks, types clean. The refused-autoplay
section went from asserting that the deck asks for a press to asserting that
it does not have to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@bjarneo
bjarneo merged commit 8f399c7 into main Sep 7, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant