diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2fb79c2..c10977d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,10 @@ The long form, with what was wrong before and how it was found, is in
## [Unreleased]
+### Removed
+
+- An unused icon glyph (`minus`) left over from before Unsubscribe settled on `circleMinus`.
+
## [0.5.4] - 2026-09-14
### Added
diff --git a/TODO.md b/TODO.md
index cbfb4d9..5a53b38 100644
--- a/TODO.md
+++ b/TODO.md
@@ -2,56 +2,48 @@
## Errors in the log
-From the production log and the feeds' stored errors on 2026-09-13. The Docker log only reached back
-to 12:32 UTC, so the list comes from `feeds.last_error`: 57 of 1,059 feeds. None of it is ipx's
-User-Agent; a browser gets the same answers.
-
-- [x] **Stop scanning an OPML's feeds once nobody subscribes to it.** 55 of the 57 are feeds from
- `davewiner` (lists.opml.org/davefeeds.xml). The list left `config.toml` about 14 hours before this
- was written, but its 922 feeds are still in the database and still scanned every hour, with
- auto-download on: `subscriptions()` adds every derived feed, and with no parent to copy from,
- `auto_download` falls back to `true` (`src/main.rs:937`). `docs/users.md` says a feed stops being
- scanned when its last subscriber leaves. Skip a derived feed whose parent is not in config, and
- when the last subscriber leaves an OPML, treat its feeds the way `sync_group` treats ones the list
- dropped: remove those with nothing downloaded, mark the rest orphaned. `remove_feed` and `ipx rm`
- both leave them behind today. (`src/main.rs`, `src/web.rs`)
-- [x] **Read feeds with a bare `&`.** kcpw has `https://kcpw.org/?post_type=post&p=125715`,
- and both feedland feeds have the same fault. Strict XML refuses them; browsers and other readers do
- not. When `feed::parse` fails, try once more with every `&` that does not start an entity written
- as `&`. Nobody subscribes to these three now, but the next feed like them will fail the same
- way. (`src/feed.rs`)
-- [x] **Say what came back when it is not a feed.** Thirteen errors read "not RSS (the input did not
- begin with an rss tag) and not Atom (...)". Each one checked was a web page: the feed moved and its
- old URL redirects to the site, or the domain lapsed. Say "got a web page, not a feed", and when the
- page links a feed (``), name it. That link found
- the new feed for om.co, ms.now, Letters of Note, the Daily Dot, Hell Gate, The Frame Lab and
- Daily Kos. A `202` with an empty body (British Antarctic Survey) should read as "nothing yet", not
- as a parse failure. (`src/feed.rs`)
-- [x] **Show a publisher's error in the UI.** Today a failing feed shows its raw error in red only
- once you open it (`web/index.html:877`, `:960`); the OPML view marks a failing child "error"
- (`:988`), and the sidebar shows nothing. Mark a failing feed in the sidebar too, and say whose
- problem it is and what to do, in plain words: a 404 means the publisher took the feed down or moved
- it, 401 and 403 that the site refuses ipx, 402 that the feed needs a paid plan, a name that no
- longer resolves that the site is gone, and a web page instead of a feed that it moved (with the new
- URL when the page links one). Offer Unsubscribe, or Use the new address. Keep timeouts, 5xx and
- 429 out of it, and flag a feed only once it has failed for a day: macmanx failed once and read
- fine an hour later. That needs the time a feed started failing, kept beside `last_error` and
- cleared when a check succeeds. (`web/index.html`, `src/db.rs`, `src/main.rs`)
- [ ] **Westenberg.** The only failing feed anyone subscribes to. The Hacker News stars list still
gives `joanwestenberg.com/rss`, which is a 404; the feed is now `joanwestenberg.com/feed`.
Nothing for ipx to fix; subscribe to the new URL directly.
-## Other Fixes and Features
+## Icons
-- [x] Remember which feed is selected and view (all, unread, flagged, etc) user as selected between visits. If unknown default to All Subscriptions
-- [x] When clicking any link it should open in a new tab
-- [x] In mobile (iOS) sometimes the top line items like the hamburger menu are not clickable unless you do a hard refresh — likely fixed (100dvh instead of 100vh), unverified on a real device; reopen if it still happens
-- [x] video files play as audio files, they should play as video.
-- [x] Move Light/Dark/Classic options to user settings. Include an Auto mode that uses system preferences for light/dark modes
-- [x] Below Popular, have a currently listening section to show what podcasts have been started and not finnished
-- [x] Update subscribe/unsubscribe icons to be circle-minus (unsubscribe) and circle-check (subscribe)
-- [x] If I'm on the Unread tab, and I click to read an item the entry in the list will disappear. it should remain until I click to another item.
+- [x] **Audit the icon set for consistency.** Checked every glyph in `ICON` (`web/index.html`)
+ against its source, weight, sizing, and every place it's used — both the `${ICON.x}` template
+ calls and the `data-icon="x"` lookups the startup loop wires up (`web/index.html:713`). Found one
+ real problem: `minus`, a bare uncircled minus glyph, was defined but never referenced anywhere;
+ `circleMinus` already covers the one place a minus-shaped icon is used (Unsubscribe). Removed it.
+ Everything else checked out: `flag`'s regular weight against `flagOn`'s solid is the standard
+ outline/filled toggle idiom, not a mismatch; icon sizing is one shared 16px base rule with three
+ narrowly-scoped overrides; `caret` is deliberately reused (not duplicated) for both the folder
+ triangle and the sort arrow, rotated correctly per context; play/pause swapping is symmetric.
+ (`web/index.html`)
## Directory Overhaul
-- [ ] Directory needs to be more functional, with categories and a more interesting layout. use /frontend-design to help
+- [ ] **Give feeds a real category, from the feed itself.** `ParsedFeed` only carries per-episode
+ `categories` (`src/feed.rs:24`); nothing at the channel level survives parsing today, so there is
+ nothing to browse the directory by. Parse `` (and its `` if
+ present) off the channel the same way entry categories already are, keep the first one a feed
+ reports rather than building a multi-tag facet, and add it to `FeedSummary` and the feeds table
+ via a migration. Most feeds will not carry one; a feed with none counts only under "All".
+ (`src/feed.rs`, `src/db.rs`)
+- [ ] **Expose category on `/api/popular` and `/api/directory`.** `PopularRow` (`src/web.rs`) is id,
+ title, image, subscribers, subscribed today; add `category: Option` once a feed carries
+ one, so the client can group and filter without a second request.
+- [ ] **Replace the flat alphabetical list with a category rail over an artwork grid.** Today
+ Directory is a single-column list of rows: a thumbnail, a title, a subscriber count, a subscribe
+ button (`renderListed`/`listFeeds`, `web/index.html:1649`). Put a horizontal row of category chips
+ above it — "All" plus whatever categories actually occur in the catalogue, never an empty one —
+ and turn the list itself into a grid of square cover-art tiles with the title underneath: podcast
+ art is designed to be recognized at a glance, the way the app's own iPod-screen styling already
+ leans on imagery over text. Keep the subscriber count as a small corner badge (the same treatment
+ `unread` counts already get in the feed list) and move the subscribe button to appear on
+ hover/focus instead of sitting on every row permanently. Selecting a chip filters the grid in
+ place; the active chip gets the EQ-amber underline (`--accent2`) the level meter already uses,
+ rather than a new color. No new palette and no new typeface — reuse `--bg`/`--fg`/`--accent`/
+ `--accent2` and the existing Lucida Grande stack so Directory still looks like the rest of ipx, not
+ a bolted-on card kit: no border, shadow or radius beyond what the app already uses elsewhere. On
+ phone width the chip row scrolls horizontally in place rather than wrapping, so it never pushes
+ the grid down. Popular can stay a plain top-ten list for now, or get the same tile treatment later
+ — worth deciding separately rather than folding into this. (`web/index.html`)
diff --git a/web/index.html b/web/index.html
index 331b805..d944de8 100644
--- a/web/index.html
+++ b/web/index.html
@@ -668,7 +668,6 @@ const esc = s => (s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>
const fa=(box,body)=>``;
const ICON={
plus:fa('0 0 448 512',''), // solid/plus
- minus:fa('0 0 448 512',''), // solid/minus
circleMinus:fa('0 0 512 512',''), // solid/circle-minus, for Unsubscribe
play:fa('0 0 448 512',''), // solid/play
check:fa('0 0 448 512',''), // solid/check