Let Astro write the pages, on the field this branch draws - #33
Merged
Conversation
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
Pages is on
build_type: legacytoday — servingmainat/. This branch deletes the committed HTML frommain, so a merge without the switch leaves Pages rebuilding a branch with noindex.htmlin it.or Settings → Pages → Source → GitHub Actions.
No workflow can do this for itself:
actions/configure-pagesonly creates a Pages site that does not exist yet and leaves an existing one on whatever source it already has.deploy.ymlchecks 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:
cnameandhttps_enforcedare repository settings, andpublic/CNAMEships in the artifact as well.github-pagesalready allows deploys frommain, 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.pytemplatedindex.htmlthrough 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.xmlis byte-identical to the one that was committed@idacross all six page kindstheme()build.formatis'file', so/playlist/still-licensedis 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()andassignSlugs()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 aresrc/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.tsnow, in UTC.build-routes.pyhashedapp.jsand 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.jsneeded 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:backgroundbright_foreground, elseforegroundaccentselectionOver the 22 Omarchy themes installed on a machine, those four keys reproduce all 22 of the hand-copied entries in
SKINSvalue 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.orgset 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, sokoontz fixis a query and not a mistake; both sides are folded, soaurelienfinds 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 elsesupportsHtmlInCanvas()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,--acand--acHi, and on Rosé Pine half of it disappeared. Measuring that turned up something bigger than the meter.--g1and--g2are 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--g1was 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--g1at 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,lumonandretro 82come out exactly as before — and--g1stays ahead of--g2on 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.
:rootcarries 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 —--g1in 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 ofapplyTheme()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 intompvwithout care.They go through the same slug rule an address does now:
aurelien-omarchee-cest-la-vie.mp3. Thetitleandartistinplaylist.jsonkeep 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.mddocumented 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 sayreconnecting 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.jsis v6. The shell changed shape completely and that is whatVERSIONretires the old caches for: the v5 cache holds the previousindex.html, its stylesheets and its deck under addresses that no longer exist, and stale-while-revalidate would have handed a returning visitor the oldplaylist.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.3and 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 startwas 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:new Audio(src),muted = trueNotAllowedErrornew Audio(src),volume = 0NotAllowedError<video>,muted = trueThe 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: sameHTMLMediaElementAPI, same events, samecreateMediaElementSource, same media session, still never in the document.playsInlineis for iOS, which would otherwise read aplay()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()setsmuted = falsefirst, 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 withcrispEdges.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 testfails 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.pyistools/test-routes.mjs, overdist/, 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.deploy.ymlruns 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
HEADbefore 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) andfCrest.applyTheme()writes them out as--field-dim…--field-crestso CSS can reach them, andstyle.csscarries 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 takesdim,midorlitby 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.78Bayer to0.22jitter, 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 at0.34of 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()andapplyTheme()is touched, and the deck itself is unchanged.DESIGN.mdis 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