# How ipx was built The long-form record, newest first: what landed each day, what was wrong before, and what it cost to find out. [CHANGELOG.md](../CHANGELOG.md) has the short version, by release; this is where the reasoning lives. New write-ups go at the top. See [README.md](../README.md) for what the thing is. ## 2026-09-12 — Trimming the state database An audit of the database layer, with a read-only copy of production to check it against. The data was already clean: no tables or indexes left from older versions, 47 free pages after the column drops earlier the same day, and one stray `entry_state` row. The code had five things: - `migrate()` still added eight columns to any table missing them. All eight shipped in 0.2.0 and upgrades now start from 0.3.0 at the oldest, so the list and its loop went; the `retired` drop list stays, since a database coming from 0.4.0 still has the old read columns. - `created` on `users`, `subscriptions` and `sessions` was written by every insert and read by nothing. They joined `retired`. The old-database test now builds all three tables, foreign keys included, since `DROP COLUMN` on a table that references another was the part worth proving. - `Db::subscribed_feed_ids` had no callers, though its doc said the scanner walked it. `Db::subscriber_count` had one caller asking whether it was above zero, which `subscriber_counts().contains_key` answers. `Managed.orphaned` was selected and never read. - `users.created` came back the same afternoon, with `last_login` beside it. Nothing read it, but when an account was made and when it last signed in is what you want to know when tidying accounts, and it cannot be recovered later. Both existing accounts got their creation times back from the backup taken before the drop, and a last sign-in from their newest session in it. `last_login` is kept to the hour, because the proxy vouches for every request and that would otherwise be a write each time. ## 2026-09-12 — Cutting what had outlived its reason A whole-repo audit for over-engineering listed twelve things to cut, and all of them went. - **Upgrades from before accounts.** `migrate_opml_children` moved OPML feeds that old versions wrote into `config.toml` out to the database, and ran at every daemon start to do nothing after the first. Production ran it in 0.3.0; anything older has to pass through 0.4.0. - **Half of the adoption, and not the other half.** The audit called `adopt_existing_library` a one-time migration and it was cut whole. It did two jobs: copy the old read state into `entry_state`, which was dead, and subscribe the first admin to the whole catalogue while nobody subscribed to anything, which is how a fresh install's first account gets `config.toml`'s feeds. The browser suite caught it at once, signing in to an empty sidebar; `cargo test` had no idea. The second job is back as `adopt_catalogue`, with a unit test of its own. - **The dead `entries` columns.** `read`, `flagged` and `position` moved to `entry_state` with accounts. The adoption's copy was their last reader, but `record_entry` still wrote them, and still reset `read` when a title changed, which nothing looked at. Two bugs came from queries reading them. `migrate()` now drops them from an existing database (SQLite has had `DROP COLUMN` since 3.35), and a test builds an old table to prove it. On production each drop rewrote the 66 MB `entries` table, about four seconds apiece, so the first start took thirteen. An `ipx status` run in that window failed with `migrating schema`: every `ipx` command migrates when it opens the database, and it collided with the daemon doing the same. A failed `ALTER TABLE` changes nothing, and the database had been copied to `backup/` first anyway. - **`interval_mins`**, which `schedule` replaced. An old config that still has the key loads; the key is ignored, and the config test carries it to keep that true. - **Three dependencies.** `infer` was only asked whether a file is a torrent, and the check after it already looked for `d8:announce`, which is what `infer` looks for. `dirs` was three lookups of `XDG_CONFIG_HOME`, `XDG_DATA_HOME` and `HOME`. `tokio-stream` wrapped the broadcast receiver for the event stream; `futures_util::stream::unfold` does the same, lagging clients included. - **Two token generators.** The web token came from a copy of the session-token code, with a clock fallback on top. It uses `auth::new_session_token` now, and is 64 characters. - **The icon inlined four times**, 23 KB of base64 each, into both pages. It is `/icon.png` now, outside the sign-in wall with `/login`, since the sign-in page shows it. - Also: the `contrib/` systemd units from before the container, `Db::entries` and `Db::count_entries` that only the tests called, three `logbuf` visitors that repeated the trait's defaults, and unused state, a helper and dead CSS in the page. ## 2026-09-11 — A design pass on the web UI A review against screenshots of every view in all three themes found that Dark and Light read as a generic dark dashboard: one pale blue did every job, most labels were bold, and nothing led. The list it produced, in `TODO.md`, was worked through in one go. What was worth knowing: - **Amber means new.** Badges, unread dots and download bars take the icon's EQ amber; blue is left for the primary action and links. Light's amber was `#b06f10`, which gives white text 4.1:1, short of AA for 11 px bold. It is `#9a5f0a` now, 5.2:1. - **EQ bars mark what is playing.** Three `` bars stand at 60, 100 and 40 % and animate only while `body.playing` is set. The first version left the animation on but paused, expecting each bar to hold a different frame. The frames it held were within a pixel of each other, and on screen the bars read as three dots. Under reduced motion one rule drops every animation and transition, which leaves the bars standing. - **The focus ring was clipped.** `.tgroup` and `#topbar` both set `overflow:hidden`, so a ring drawn outside a toolbar button was cut off. Rings inside clipping parents are inset instead. - **The feed list could not be used from the keyboard at all.** Rows were `
`s and the triangle a ``, so Tab went from the feed filter to Sign out. Rows now take focus, the triangle is a `