Commit Graph
45 Commits
Author SHA1 Message Date
Michel de Graaf da8ab45303 feat(tui): results across the top, with panes you can hide, zoom and Tab through
The results pane now has the whole width, where a long file name needs it,
and Searches, Downloads and Info share the row underneath. Tab and Shift-Tab
walk the panes, z zooms the focused one to the window, w hides it so the
others widen, and its number brings it back. Every list takes the same
navigation keys, the shortcuts bar wraps instead of cutting keys off, and ?
lists every key by pane, scrolling on a short terminal.

Follows up on #55.
2026-09-07 07:49:32 +00:00
Michel de Graaf 426639719c Merge branch 'develop' into feat/tui-results-navigation 2026-09-06 21:53:36 +02:00
Michel de Graaf aa61a1a434 feat: cancel a download from the TUI, the CLI, and the daemon
Closes #54.

A download can now be cancelled whether it is queued, in progress, or
paused. Cancelled is a terminal status: the store refuses to overwrite
it, the status channel relays nothing after it, the partial file is
deleted, and the transfer thread releases its socket at its next
one-second poll instead of the 30s read deadline. A queued download that
is cancelled declines the peer's later offer with TransferResponse
"Cancelled", and an inbound file connection for it is refused. A fresh
download of the same file evicts the dead row so it is not shadowed,
and a cancelled transfer is not re-queued after a restart.

Surfaces: Client::cancel_download, download.cancel over RPC, `transfer
cancel <user> <path>` on the CLI (which also stops an upload by that
name), and x in the transfers pane, where d dismisses the row.
2026-09-06 21:40:45 +02:00
Michel de Graaf 3193c73f5b feat(tui): scroll long result names and page through results
Closes #55.

While [2] Results is focused the Info pane describes the highlighted
result, so a truncated path can be read in full. h/l (or the arrow keys)
scroll the filename column by display cells, 0 and $ jump to either end
of the highlighted name, and Home/End, g/G, PgUp/PgDn and Ctrl-f/b/d/u
move through the list; the same keys work while a filter is being typed.

Ctrl chords are no longer read as their bare letter: outside [2] they do
nothing, where before Ctrl-d deleted a search and Ctrl-c opened rooms.
Space after a filter that matches nothing no longer indexes an empty
list.
2026-09-06 20:10:59 +02:00
Michel de Graaf 597f378f17 docs(website): drop soulfind server link from footer 2026-08-29 15:45:30 +02:00
Michel de Graaf d30d9fdf75 docs(website): move project links into LINKS 2026-08-29 15:45:30 +02:00
Michel de Graaf a9f4ab5e5b docs(website): list Player and Staccato, fix install pill overflow on mobile 2026-08-29 15:45:30 +02:00
Michel de Graaf eac340cdb8 docs: drop bot wording in favor of client 2026-08-18 08:39:04 +02:00
Michel de Graaf 1611b4baf3 docs: list projects built on soulseek-rs-lib
Add a 'Projects using soulseek-rs-lib' section to the README and a
matching section on the website community page, starting with seakarr.
Both point new projects at issue #12 to reserve a client minor version.
2026-08-17 09:46:06 +02:00
Michel de Graaf d11326f149 fix(daemon): ship the OpenRPC document inside the crate
docs/openrpc.json lived at the repository root, outside the soulseek-rs
package, so the include_str! in proto compiled from a checkout but not
from the published tarball - cargo publish failed verification and the
v13.0.0 release stopped after publishing the lib. The document moves
into the crate's own docs/, where packaging can see it; the generator
test and every link follow.
2026-07-29 17:34:29 +02:00
Michel de Graaf 78a608c4c3 feat(website): serve an install script that picks brew or the release binary
curl -fsSL https://re-invention.nl/soulseek-rs/install.sh | sh replaces the
brew one-liner as the front-page command: it works on Linux without Homebrew
by downloading the latest release archive for the platform, verifying its
sha256 and installing to /usr/local/bin or ~/.local/bin. Windows still gets
pointed at the msvc zip. scripts/test-install.sh covers target detection,
the brew fast path, checksum failure and real installs over curl and wget.
2026-07-29 16:22:05 +02:00
Michel de Graaf e4546e5940 docs(website): fold the docs page into five parts and drop the byline
The docs page carried three daemon sections in a row, each with its own
numbered head, so the one story the daemon tells read as three. They are now
sub-heads under 5 · the daemon. The agents section is gone, first run points
at /install instead of assuming Homebrew, and the layout section shows the
demo next to the pane descriptions rather than describing boxes nobody sees.

The footer no longer signs the project with a personal name, and the TUI
demo no longer shows a home directory that only exists on one machine.

Community picks up the sharing etiquette cards and SeeleSeek; install and
home lose a few em-dashes.
2026-07-29 11:24:18 +02:00
Michel de Graaf ab5cef8cfe docs: tell the whole daemon story, and stop denying it exists
An audit of the README and the site against the implementation found 38
places the docs were missing, stale, or wrong about daemon mode. Two were
outright false: the community page still answered "want a queue that
outlives the command?" with "slskd has that", and the home page promised
"no daemon, no API to stand up" fifty lines before the section that
describes both.

The download-box walkthrough now tells the truth about its sharp edges: a
pushed folder or share list is live-only on a remote daemon, whose own
config.toml wins at restart; `shares add` validates the path on the
machine you type it on; and a `get` that reaches its `--timeout` cancels
the daemon-side transfer. Its shell example also runs as written now — the
old block put a second command after a foreground daemon that never
returns.

The reference material catches up with the feature: `daemon` and its
subcommands join the command list, the config table gains the
daemon/daemon_token/--no-daemon rows, nine settings become eleven,
`daemon status` and `daemon token` get documented record shapes, exit 3
covers daemons, `queued` joins the serve statuses, the credential-free
count goes 13 to 19, and the action-command count 4 to 6.

And the surprises are written down rather than discovered: the daemon does
not sweep the wishlist on its own; Windows has no socket and so no
auto-attach; the attached TUI's settings form edits the daemon's folders
and rewrites this machine's config.toml; `serve` monitors a daemon's
uploads instead of starting a second server; `whoami` reports the
daemon's world, with --no-daemon as the way out. The OpenRPC section names
the six event methods and the session-wide download.set_dir rule.

Site typechecks, lints, and builds through prerender; every claim was
verified against the cited code before it was written.
2026-07-29 09:22:02 +02:00
Michel de Graaf 011eafaa7f feat(daemon): make every window a view of one shared session
Two TUIs on one daemon behaved as two separate windows that happened to share
a login: each showed only the transfers it had queued and the searches it had
run, and neither said it was attached to anything.

The status bar now names the daemon, and both lists are views of the session:
transfers and searches appear in every window, dismissing one dismisses it
everywhere, and a window opened later starts from what is already running
rather than blank. message read likewise starts from the conversation the
daemon collected instead of only what arrives while it runs.

Search state needed the daemon to say when a query went out. The window a
search collects for belongs to whichever client started it, so without that
every other window showed a running search as finished the moment it appeared
— which is what it did. The summary carries a file count too, so a window has
the right number before it has pulled the results across.

Adds Client::forget_search, which the library had no way to do: a resident
client otherwise holds every result set it has ever seen, and a window that
dismissed a search had no way to say so.

A review of the first cut found five defects, all fixed here and all covered:
syncing ran locally too, where it cost a full copy of every result set per
frame and deleted the completed-download history restored from disk; a
transfer could be listed twice by racing its own registration; clearing all
searches did not reach the session, so every row came back; a search dismissed
elsewhere never left; and a wishlist search reported as finished the instant
it went out.
2026-07-27 20:58:49 +02:00
Michel de Graaf 01152d91f6 feat(daemon): say what to do next on startup, and prove multiple clients agree
Starting the daemon printed 'ready' and nothing else, so the token and the
next step were both somewhere else. It now prints what other commands can do
from here, and when --bind is given, the token itself with the two config
commands a remote client needs. All on stderr, so stdout stays empty and
daemon still composes in a pipeline.

Adds the guarantee daemon mode is for as a test: eight clients connected at
once are each answered correctly, none has its replies crossed with another's,
and the daemon still counts all eight. Plus 250 connect-and-drop cycles, well
past the connection cap, to catch a per-connection resource that is not
released — the shape a daemon that works for a while and then refuses
everybody would take.

A connection the daemon drops before it can be served now logs why at warning
level. A client only ever sees 'the connection closed', so that line is the
only account of the reason.
2026-07-27 14:39:31 +02:00
Michel de Graaf 2a906df1b5 feat(config): remember which daemon to use, and document the download box
A remote daemon could only be named with --daemon and --daemon-token on every
invocation, which made the one workflow daemon mode is most useful for — a
headless download box driven from a laptop — a matter of retyping a 64-character
token. Both are now config keys, so it is two 'config set' commands once and
plain commands thereafter.

Fixes a bug that setup exposed: the up-front daemon status probe read the token
from the command line only, so with the token in the config file it failed
silently and every command that names a path or a server fell back to this
machine's defaults — whoami claiming downloads land in the laptop's home
directory when they land on the box. The regression test was checked by
reintroducing the bug.

Documents the download-box workflow in plain terms across the README, the
website and the agent skill, and says plainly that the schema is open: OpenRPC,
served by the daemon itself, so an Android, iOS or web remote is something
anyone can build without asking.
2026-07-27 14:11:51 +02:00
Michel de Graaf 3f914c4425 docs: put daemon mode on the website and tighten the README
The website made three claims daemon mode falsifies — "No TTY, no daemon",
"no daemon, no MCP server, no API key", and "no GUI and no daemon mode yet"
under what-this-is-not. All three corrected.

Adds a docs section and a home feature card, both leading with the reason
rather than the mechanics: the server allows one session per account, so
parallel one-shot commands either displace each other or mean a throwaway
username per run, which is account churn on infrastructure other people
maintain. A daemon is one login for as long as you work.

The README section said that three times over and buried the reasoning below
the mechanics; rewritten to state it once, up front. It now also covers which
commands stay local and that shares add reaches a running daemon, which was
meant to land earlier and silently did not — the edit anchored on text that
only existed in SKILL.md.
2026-07-27 14:04:14 +02:00
Michel de Graaf 7435d48b32 perf: raise transfer defaults and stop uploads sleeping on their slot
The slot cap merged from master defaults to 2 uploads, and the CLI capped
downloads at 5. Those are the numbers you pick when your own bandwidth is the
scarce resource, but a Soulseek transfer is paced by the peer on the other end
— usually a few hundred KiB/s — so what fills a modern link is many transfers
at once, not faster ones. Upload slots go to 10, downloads to 20. The queue
itself stays: privileged users being able to jump it is a condition of being a
tolerated third-party client.

serve_file also slept half a second after the last byte so the downloader could
drain before the socket closed. With a cap that is time no other peer can use,
and 64 uploads over 10 slots paid it six times over. A half-close says "that is
the whole file" and TCP still delivers everything written before the FIN.

Measured with 64 waiting peers: 2 slots 24.2s, 8 slots 6.2s, 32 slots 3.1s; at
10 slots the linger alone was 5.4s to 3.05s.
2026-07-26 18:25:50 +02:00
Michel de Graaf 38a231ffd1 docs(website): untap michel/tap in the uninstall transcript
`brew uninstall` leaves the tap behind as a git clone, so the section that
promises to remove it completely was still short one command.
2026-07-26 16:44:11 +02:00
Michel de Graaf 8482c47149 fix(website): give Homebrew users an uninstall command, share the link markup
The macOS install route recommends Homebrew, but the uninstall section only
ever offered `cargo uninstall`, so anyone who followed the recommended route
had no documented way to remove the binary. Adds `brew uninstall`.

install.tsx also had ReleasesLink defined to wrap an external anchor, then
inlined that same anchor four more times anyway - including one that rebuilt
the releases link by hand, byte for byte, 290 lines below the component that
already rendered it. ExtLink takes the href and covers all six sites here and
in community.tsx. Rendered markup is unchanged.
2026-07-26 16:17:22 +02:00
Michel de Graaf 2a051c7064 chore: add a code quality baseline
Three findings were never real. parse_request is 34 lines, not 426: the
`'{'` char literal in it reads as an opening brace to the line counter, which
then runs to EOF — 610 + 426 lands on line 1035 of a 1034-line file. The
duplicate-block hits on home.tsx and install.tsx match the punctuation between
entries of a data table, not logic; the records repeat the field names their
type demands.

Each is silenced at its own site with the reason, so the rule keeps working
everywhere else. Nothing is switched off in config.yml — a `rules:` entry there
would blind the rule across the project. `ci.failBelow: 100` makes the baseline
enforceable: the check exits 1 on a single new warning.
2026-07-26 16:09:34 +02:00
Michel de Graaf 2e9cd6b860 refactor(website): split the hero terminal and the page components
HeroTerminal was a single 367-line component doing three separate jobs:
simulating the transfer queue, holding the per-search result sets, and
rendering. It is now useTransfers, useResults and useHeroDemo behind a
presentational component. The dead shellRef went with it.

The home, docs and install pages each returned one long JSX block; every
section is now a named component, so the page body reads as an outline.

The prerendered HTML is byte-identical for all five pages, and the extracted
transfer reducer matches the original over 200 ticks.
2026-07-26 15:40:58 +02:00
Michel de Graaf 534b09a20a feat: install tab completion for bash, zsh, and fish (#16)
`completions install` generates the script from the same clap definition
the binary parses with, so it cannot drift from `--help`, and writes it
where each shell already looks. fish scans its own completions directory
unprompted; bash and zsh get one marked `source` line appended to their rc
file, because nothing puts a per-user directory on their search path by
default. `completions uninstall` takes back exactly those lines and leaves
the rest of the file alone.
2026-07-26 12:18:53 +02:00
Michel de Graaf 72f2e31a2c docs: point the library snippets at the 8.x lib
v8.0.0 is on crates.io, so the Cargo.toml examples in the readme and on the
install page were a major behind.

Replace the 5.x upgrade note with one that does not name a version, so it
stops going stale every release.
2026-07-26 11:51:48 +02:00
Michel de Graaf abf83fa30a style(website): cut the padding from the page copy
Remove three cross-page duplications: the "what --help cannot say" list
appeared on both home and docs, the config precedence rule twice on
install, and the soulfind link twice in adjacent community paragraphs.

Drop the em dashes, the adverbs, and the rhetorical triads while there.
2026-07-26 11:41:48 +02:00
Michel de Graaf e2c2b5063a style(website): tighten the agent-skill copy 2026-07-25 11:09:50 +02:00
Michel de Graaf e79250c337 fix(website): bump the library install snippet to the 7.x lib 2026-07-25 10:55:49 +02:00
Michel de Graaf 3ff51c2799 style(website): widen the mobile page gutter to 24px
The wordmark and the hero mark sat 18px off the edge, close enough to read as
flush against it on a phone. The nav bar and Wrap carry the same gutter, so
both move together.
2026-07-25 05:39:56 +02:00
Michel de Graaf 96e6bcdb8a feat(website): route the OS links to per-platform install steps
Clicking macOS, Linux or Windows on the home page landed on the file-path
cards, which say where config lives, not how to install. Step 1 now carries
one card per OS at those anchors: brew on macOS, cargo on Linux, cargo or the
prebuilt .exe on Windows. The path cards keep their own -paths anchors.

The hero logo drew into a fixed inline width, so the mobile height overrides
shrank the mark inside a box sized for the desktop one. An aspect-ratio lets
the width follow, and the mark is now larger and centred against the title.
2026-07-25 05:35:01 +02:00
Michel de Graaf dab17774ab docs: use recorded terminal sessions for both demos
The README demo is a fresh recording of the v7 TUI, and the agent
section now shows a real agent session — loading the skill and driving
the CLI — in place of the hand-written terminal mock.

Both are asciinema recordings rendered to SVG at 10x, with frames
coalesced at 20fps to keep them small.
2026-07-25 05:07:37 +02:00
Michel de Graaf 430a985152 fix(website): give the wordmark cursor the gap it was drawn with
The cursor sat 0.06em after the text, close enough to read as a stray glyph.
The wordmark SVG puts it 15.4 units after a 27-unit block, so 0.57 cursor
widths, which is 0.33em at the component's 0.58em cursor.
2026-07-25 03:38:03 +02:00
Michel de Graaf 06459ddced docs: use the wordmark at the top of the README
The mark is a glyph; the wordmark reads "soulseek-rs" outright, which is what
a README header wants. Both tones ship so prefers-color-scheme can pick one.
Named for the background they sit on rather than their Figma tone, because the
existing logo-ink/logo-light pair means the opposite of what it sounds like.
2026-07-25 03:34:44 +02:00
Michel de Graaf 9c966672af feat: install via homebrew (#9)
Add a release job that regenerates Formula/soulseek-rs.rb in michel/homebrew-tap
from the archives the binaries matrix uploads, and lead with
`brew install michel/tap/soulseek-rs` in the READMEs and on the website.
2026-07-25 03:18:53 +02:00
Michel de Graaf cd41688803 docs(website): explain what the skill unlocks, and document config.toml
The agent-native copy described what the skill contains rather than what it
buys you. A new home-page section shows the shape of the request that becomes
possible — describe the outcome, let the agent write the pipeline — and names
the specific knowledge --help cannot carry: which JSON keys a record has, that
exit 4 means widen the query, that a remote path goes back byte-identical,
that --follow never returns.

The install page gains a reference for all nine config.toml settings with
their defaults and an example file, next to the paths that were already there.
The list is the one `config set` accepts, checked against FileConfig::KEYS.
2026-07-25 02:27:47 +02:00
Michel de Graaf 17e4bf9fd3 docs(website): lead with the agent-native story
`skills install` becomes the featured card on the home page, in the wide slot
the grid gives its first entry: one command hands a coding agent the JSON keys
of every record, the exit-code meanings, and the --json rule, with nothing to
clone because the skill ships inside the binary. The docs page gains a callout
and the command in the shell listing.

Also catches the site up with the scriptable command surface the last two
releases introduced: `get`, `shares add`, `serve`, `room list`, `message send`
and `--json` replace the flags and spellings that no longer exist.
2026-07-25 02:24:58 +02:00
Michel de Graaf c2dd227cee chore: strip the comments this branch added
Removes every comment introduced on this branch: website/ in full, pages.yml
in full, and only the blocks added to ci.yml and release.yml — their
pre-existing comments are untouched.

Two directives survive because they are load-bearing rather than prose: the
triple-slash vite/client reference, and nothing else.

Two comments were carrying behaviour that the code did not state, so the
code now states it. useCopy's rejection handler clears the copied flag
instead of sitting empty, and writeTheme returns whether the write actually
landed instead of swallowing the failure in a bare catch. isExternal drops
its regex for two startsWith calls, which reads the same and no longer
hides a `//` inside a literal.
2026-07-25 01:29:55 +02:00
Michel de Graaf ba92e75fd3 fix(website): stop the hero mismatching its own prerendered markup
Reading prefers-reduced-motion in the state initialiser made the first
client render disagree with the server for anyone who has it on: the server
always serialises the searching state, the client painted the resting one,
and React bailed out of hydration with #418 on every load. The preference
now applies a tick later, from the opening-run effect, so the first render
matches the HTML and the resting state arrives immediately after.

The header's home link was 19px tall — the most reached-for control in the
nav, and the only one under the 24px target minimum, while every nav link
beside it was already 44. It gets a 44px box.

The demo's search field also carries an id, name and type, which clears the
"form field should have an id or name" issue and stops browsers offering
autofill on a fake terminal input.
2026-07-25 01:21:53 +02:00
Michel de Graaf 1eef464677 refactor(website): cut the stock patterns out of the design
Audited against the AI-design catalog. The world stays — the terminal
aesthetic, the oxide accent, IBM Plex Mono are all chosen, and the audit
cleared them. What went is the handful of defaults that had crept in
underneath it.

Eight feature cards at identical weight said every feature mattered equally.
Search is what the client is for, so it takes a double-width cell and the
grid drops to three up; eight cards plus that one double still comes out
square.

Each glyph sat in a 34px bordered square — the stock icon-in-a-chip
treatment that ships with every component library. The glyphs are the real
TUI's, so they now sit inline like the prompt markers they are.

Callouts lose the coloured left rule. The catalog puts it plainly: a
coloured left border is about as reliable a sign of generated design as an
em-dash is in prose. The tone already lives in the label, which was saying
the same thing twice.

Thirteen arrow glyphs were typed into labels and link text. The
leaving-the-site mark belongs to the control, not the copy, so Button draws
it once, aria-hidden, and only when the link actually leaves. The "read
more" arrows on internal links were pure flourish and are gone.

The hero also drops "blazingly fast Rust", which TONE-OF-VOICE.md lists
under banned, no exceptions.
2026-07-25 00:54:49 +02:00
Michel de Graaf 310149c718 fix(website): draw the keycap edge with a shadow instead of a heavy border
A 2px border on one side of a rounded box thins as it turns the corner, so
the keycap's bottom edge tapered exactly where a real key looks solid. An
unblurred 1px shadow sits under the full curve instead — same weight, no
taper — which is the look the design system's `.key` rule describes.

Also records the one detector finding that is not a defect: the single-font
warning on index.html. tokens/typography.css calls monospace
"non-negotiable: it's a terminal application", hierarchy comes from three
weights and a five-step scale (the rule's own stated exception), and the
second family it asks for is already there — --font-forum, a system stack
the scanner cannot see because it needs no @font-face. Scoped to that one
file so the rule keeps working everywhere else.

config.local.json holds this machine's hook consent, so it is gitignored
rather than shared.
2026-07-25 00:44:58 +02:00
Michel de Graaf 74ad230150 fix(website): clear WCAG AA across both themes, and stop the hero shifting
Contrast was measured, not eyeballed: 41 foreground/background pairs across
both themes and the terminal surface, each against the darkest (light) or
lightest (dark) surface it actually sits on. Five failed. Links on manila
read 3.64:1 and the footer's "No telemetry. Ever." — a claim the site goes
out of its way to make — sat at 2.86:1.

The accent needed splitting rather than moving. #c1502e clears 4.5:1 as a
button fill behind white but only 3.99:1 as an 11px eyebrow on vinyl, so
--accent keeps the brand hue for fills and a new --accent-text carries the
readable cut. The terminal gets the same treatment dark emulators give their
palettes: alarm and oxide brighten inside .tui-content so "Failed" is
legible next to its glyph.

Reduced motion no longer nukes everything. A blanket 0.01ms also kills the
transitions that answer a click, which is feedback rather than movement;
looping and travelling animation stops, the rest caps at 80ms. The TUI
already skipped its opening run and still does.

The hero reserves its box with aspect-ratio from the first paint. It was
prerendered at full unscaled height and collapsed once JS measured, moving
everything below it by ~145px. Measured shift is now 0.

Also: the webfont loads non-blocking (display=swap meant the first frame
never waited for it anyway), footer link groups nest under a heading instead
of sitting level with page sections, and eight tokens nothing referenced
stop shipping.
2026-07-25 00:40:13 +02:00
Michel de Graaf 8b8383afdf feat(website): drop the tmux bar from the hero demo
The demo is the client, not the terminal it happens to run in. Removing the
bar takes the tmux window list, session name, host and clock with it, plus
the three --tmux-* tokens that existed only to paint it.

The scale floor goes too: the terminal now shrinks to whatever width it is
given instead of stopping at half size and scrolling sideways, which makes
useFitScale's minScale parameter and the media query feeding it dead weight.
2026-07-25 00:20:46 +02:00
Michel de Graaf 723700753a feat(website): sync headline copy from the design
The headline drops "Blazingly fast, in Rust" and leads with what the thing
is, lowercase, the way you type it. The sub-heading loses "with no window
and no mouse", which the headline already implies.
2026-07-25 00:10:56 +02:00
Michel de Graaf 4c332de583 fix(website): point canonical and og:url at the custom Pages domain
GitHub Pages serves this account's project sites from re-invention.nl, not
michel.github.io, so every prerendered page was advertising a host that does
not serve it.
2026-07-24 23:59:30 +02:00
Michel de Graaf f60aaac0a8 feat(website): sync the latest design changes
Headline now leads with agents alongside people, and the second callout
swaps sharing advice for what the shell subcommands make possible — cron, a
script, or an agent driving the client with no daemon to stand up.

Mobile gets the design's newer treatment: the eyebrow and the sub-heading
step aside so the headline and the install button are all that precede the
terminal, nav links and the button row centre, the footer keeps two columns
with the brand spanning both, and under 520px the platform strip drops its
labels to a centred row of icons. That last one makes the icon the only
thing naming the platform, so it carries an aria-label now rather than
being hidden from assistive tech.
2026-07-24 23:58:49 +02:00
Michel de Graaf 33bfc34dc8 feat(website): add the project site and deploy it to GitHub Pages
A Vite + React + TypeScript + Tailwind v4 site under website/, built from
the soulseek-rs design system so the site and the TUI stay one product.
The colour tokens in src/index.css are the same values as
soulseek-rs/src/ui/styles.rs; change one, change both.

The hero is a working recreation of the TUI: it plays one scripted run and
then takes the keyboard, or lands on the resting state directly under
prefers-reduced-motion. Its sample data is invented or public domain, per
the tone-of-voice guardrail against naming copyrighted material.

Static output rather than an SPA shell. `bun run build` compiles the client,
compiles the same app for Node, then renders every route in src/lib/routes.ts
to its own file with that route's title, description and canonical URL. A
direct link to /install therefore answers 200 with real markup instead of
GitHub's 404 page. routes.ts is keyed into App.tsx by literal path, so adding
one without the other fails typecheck rather than shipping a page whose body
says "not found" under the right title.

Theme is resolved once by an inline script before first paint and adopted by
React through useSyncExternalStore, so there is no flash and no hydration
mismatch on the toggle. Light mode re-cuts the semantic hues darker: phosphor
and tape read on vinyl but wash out on manila.

CI and release skip website-only pushes, and Pages skips Rust-only ones. The
two Rust workflows share one paths-ignore list so they can only skip
together, which keeps the release ci-gate from polling for a run that was
never queued. That gate also now treats a cancelled CI as "keep waiting"
rather than a failure, since cancel-in-progress cancels the older SHA on
every back-to-back push.
2026-07-24 23:42:41 +02:00