Commit Graph

105 Commits

Author SHA1 Message Date
b43ba89778 Load tests with k6, for what many people at once do to the server (#133)
The browser tests drive one person against a handful of feeds, one request at a time, and cannot
show what production's load does. node tests/load/run.js builds a release binary, serves 1,500
generated feeds from itself, starts a daemon of its own under /tmp/ipx-load, seeds a hundred
listeners with thirty feeds each and four downloaded files, and runs four k6 scripts, with
`ipx status` -- the healthcheck -- run every second and failing the run at its 5s timeout:

  browse     25 people at once: feed list, All Subscriptions, a feed, a search, the Directory,
             a feed's page in it
  listening  100 players saving positions every second and marking items read while scans write;
             each reads its own state back, which must be as it left it
  media      50 listeners seeking: every range checked byte for byte against the served file
  signin     a flood of wrong passwords while others browse, and the gap between refusing a
             known name and an unknown one

Each budget is about twice what it measures now. Getting here found #135 (SQLite waiting for the
disk after every write, a first scan of 1,500 feeds estimated at 50 minutes, now 27s), #136
(SQLite's single connection, left open), #137 and #138 (fixed in the commit before this). k6 is
installed from its own signed apt repository by /src/install.sh, outside this repository.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:38:20 +00:00
697e907c86 Keep the feed list's icons when it redraws, rather than load each again (#134)
Checking every feed made all the feeds' icons in the feed list flash while it ran. A check sends
each feed's new row as it reads the feed, and renderFeeds, run once a frame while they come in,
emptied the list and built every row anew, every <img> with it: each icon went blank and was
loaded and drawn again, many times a second through a scan of 150 feeds.

The rows are still rebuilt, but the images already drawn go into the new ones in place of the
fresh copies, matched by their markup, so only an icon that has changed is made anew. A browser
test checks the rows are new and the images in them the same elements, and fails without this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:37:35 +00:00
ef8e2526f2 Show what a feed says it is on its page in the Directory (#130)
A feed's page in the Directory (#128) had its cover, category and latest items, but not the
feed's own description, which Apple's show page leads with: iPX read every item's description
and threw the channel's away, and the feeds table had nowhere to keep it.

It is parsed now, RSS's <description>, or iTunes' summary when that is empty, or Atom's
subtitle, kept in feeds.description (kept when a later read has none, as title and image are),
and sent, sanitized, with the items from /api/directory/{id}, which is now an object, not a
list. The page shows it as plain text under the header, three lines of it, with More when there
is more. A description that only repeats the title is left out.

feeds.description is the first column added to a table that already exists. create_missing
looks for it with a SELECT and runs the ALTER only when it is missing, since it runs on every
open, the healthcheck's included, and an ALTER's lock is what made that time out before. The
same once-only step forgets every feed's ETag and Last-Modified: a feed is read whole only when
it has changed, so its description would otherwise wait for its next item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:46:22 +00:00
b86062b97a Keep a feed's items and files to the people who subscribe to it (#129)
Routes that take a feed or an enclosure id did not check who was asking. Anyone signed in could
read any feed's items through GET /api/feeds/{id}/entries, a paid feed's included, with the
addresses of its files, which can carry the subscriber's key: the Directory leaves such feeds
out for that reason, and this route handed them back to whoever guessed the id, a slug of the
title. In production it answered 25 items of a feed the asking account does not subscribe to.
/media/{id} served any downloaded file by its sequential id, POST /api/enclosures/{id}/download
and /api/feeds/{id}/download-latest queued any feed's downloads, and DELETE
/api/enclosures/{id}?force=true deleted any file.

Each now answers 404, "you do not subscribe to that feed", unless the person subscribes to it.
A feed inside an OPML has a subscription row of its own for everyone subscribed to the OPML, so
that holds for those feeds too. Found while adding the Directory's feed page (#128), which has
its own route that answers only for listed feeds and carries no files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:19:38 +00:00
95acde3046 Lay the Directory out as Apple's is, with the most subscribed in it (#124, #126, #128)
The Directory was one grid of every listed feed, 1,472 in production, under Podcasts and Blogs
tabs, a wall of about 30 category buttons and a sort menu, with a picked category's
subcategories appearing as a second row of buttons that looked like the first. It read as a
list to scroll, and the two rows were hard to tell apart.

It opens now on the ten most subscribed feeds, ranked, and the categories as tiles, each with
three of its shows' covers fanned in its corner on the tint its initials would get, so the
shows colour it and no theme's palette changes. A category has a page of its own: its most
subscribed, its subcategories with how many each holds, and its grid. See all is every feed,
as the grid was. Podcasts or Blogs holds across every page. Popular, the ten most subscribed,
had a place of its own in the feed list; it is the Directory's first section instead, and its
key, g p, is gone. /api/popular stays for scripts.

The search box finds a feed in the Directory by name (#126). It said "Search items…" there and
did nothing, since loadEntries returns early for a place that lists feeds.

A feed you do not subscribe to opens a page of its own (#128): its cover, category (a link to
that category's page), how many here subscribe, a subscribe button and its latest twenty items,
each with its title, linking to the post where it has one, its first lines, date and length.
Before, a click on it did nothing; only its + did. The items come from GET /api/directory/{id},
which answers only for a feed the Directory lists, so a guessed id reaches nothing private, and
carries no file or its address. The feed's own description would belong at the top, but the
database does not keep one.

Every tile and chart row takes the keyboard, as the feed list's rows do. Feed text made plain
keeps a space at a line break or a paragraph's end, which glued "2010)Recorded" together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 16:19:38 +00:00
00bd58ac9d API tokens a person makes for scripts and agents to act as them (#123)
The API took a session cookie, a proxy's word or the shared admin token, so a script or an
agent working for one person had to sign in with their password and carry the cookie, or be
given the admin token. Settings now makes named tokens, ipx_ and 256 random bits, sent as
Authorization: Bearer. A token is its owner and no more. Only its SHA-256 is kept, in the
new api_tokens table, with when it was made and last used; it is shown once and revoked from
the same list. An unknown or revoked one gets a 401 rather than falling through to a cookie.

Cloudflare Access still stands in front of the tunnel, so from outside a token needs an
Access service token beside it; docs/sso.md says how.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 00:15:17 +00:00
cc17b1ddab Sort the Directory by name either way or by subscribers (#122)
The Directory always listed A to Z, as the server sends it. A menu beside its filters now
sorts it A to Z, Z to A or by most subscribers (then by name), in the page, without asking
the server again, and the choice stays while the pane is redrawn, as the filters do. The
blurb no longer says A to Z, since it may not be.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:54:32 +00:00
02a46bb500 Browse the Directory by category, then subcategory, as Apple does (#118)
The Directory had one row of chips holding whatever each feed's category was, a category
(Technology) or a subcategory (Tech News, Video Games) side by side: a podcast's own
<itunes:category> is stored as its subcategory, and Jev's answers (#117) are often
subcategories too, so after the first forced scan the row held 22 chips. /api/directory now
gives each feed's Apple category and subcategory, worked out from Apple's list, and the
page shows the categories, then a picked one's subcategories on a line of their own. A
category that is not Apple's, one an admin typed, stands as a category of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:33:26 +00:00
d238681ec1 Name an item without a title from its own text, not "(untitled)" (#116)
RSS 2.0 makes an item's title optional, and some blogs leave it out on purpose: Scripting News
titles almost none of its posts. Fifty rows of "(untitled)" said nothing about any of them.

entryName gives an item its title, or the opening of its text (HTML read through DOMParser, an
inert document that loads nothing; cut at a word near 120 characters), or its file's name, or
its show and date, with a flag for a name that is not a title. The list sets that one in the
regular weight, as the text it is rather than a heading; the reader leaves out the heading so
the post starts with itself; the player, the lock screen, Currently Listening, the native shell,
Share and the queued toast use the same name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:46:47 +00:00
a00516687a Keep artwork on disk and serve every image from iPX (#111)
The page loaded artwork from each publisher's server, or through /api/art, fetched every time,
for http-only hosts. Nothing was kept, every visit asked every publisher, and artwork went when
a publisher's server did.

- The page draws every image a feed or item names from /api/art. The first time, iPX fetches
  it (only an address a feed or item names, only an image, up to 5 MB, within the feed timeout)
  and keeps it in art/ beside the database, under a hash of its address with its type beside
  it (src/art.rs). Later it comes from disk, which marks it as used.
- art_cache_mb, a server setting on the admin page, 500 by default, caps what is kept: the
  sweep before each scan drops the least recently shown until it fits. 0 keeps nothing, and
  artwork is fetched through iPX each time. Settings saved before it get the default.
- Served from iPX's own address, someone else's image must stay an image: nosniff, and a CSP
  with sandbox, so an SVG opened on its own runs no script as iPX.
- The fixture server sends .jpg as image/jpeg, which /api/art requires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:50:46 +00:00
54d827f655 Time out a hung feed, serve the precomposed touch icon, and store http artwork on https (#108, #109, #110)
#108: the HTTP client had no timeout, and scans handle feeds in order, so a hung server held
every scan. Dreamwidth answered 504 after 60-67 s for a day and each scan took 70-80 s instead of
15. A feed fetch, and a Patreon creator's show list, now gets 30 s from connecting to the last
byte (feed::FEED_TIMEOUT); the client gets a 10 s connect timeout, which bounds a download's
start but not a long download.

#109: iOS asks for /apple-touch-icon-precomposed.png first when the site is added to a home
screen; it was a 404 and the only non-feed warning in the log. It serves the same icon.

#110: the page is https and loads no http. Artwork on http came through /api/art (#90) even
when its host serves https too. A scan now tries each http artwork host on https once per feed
(feed::prefer_https) and stores the https address where the host answers with an image,
rewriting that feed's stored items from the same host (Db::secure_images). 4 of the 5 hosts in
production do; cdn.thesecretcabal.com presents another name's certificate and stays on
/api/art.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:31:50 +00:00
c7f13eea2f Send a changed feed's row to the page instead of it reloading the list (#105)
After a feed was checked, failed or downloaded a file, and after every item read, the page
fetched /api/feeds whole, about 60 ms for 160 rows, though one row had changed. The live event
stream now knows who is connected and, after an event that changes a feed, sends that person
its row (feed_row), built by the same code as the list (feed_rows, with Db::feed_list asked for
one feed). Marking an item read answers with the feed's row. The page puts the row in place
and redraws once a frame. A routine skip of a feed not due sends nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:41:43 +00:00
79bc006a30 Add only what is a feed or links one; refuse the rest (#102)
Adding an address looked for the feed a web page links and, finding none, added the address
as it was: every check then failed, and the sidebar called it a feed that had moved. cnn.com
is one; its page links no feed. find_feed replaces feed_behind_page: the address is added if
it is a feed or an OPML list, the feed its page links if it is a web page that links one (and
that is a feed), and otherwise the add is refused with the reason, from the web page (400, the
dialog stays open) and from `ipx add`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:06:04 +00:00
e9f2832e1e Show a failing feed on its artwork, in words, and grey (#93)
The mark was a 12px "!" in the sidebar's margin, told apart by --bad alone; a dark theme's --bad
is a pale pink, and at that size it vanished. It is now a solid disc on the artwork's corner, the
subtitle says what is wrong in place of the counts, and a feed failing for a day or more has its
artwork greyed out. Lightness and words carry it, so no theme's palette changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:17:46 +00:00
00f6293b95 The refresh button turns while its feed is checked (#78)
The feed events already put a spinner on the sidebar row, which a phone
hides. The same state now sets scan-this (the open feed, or a feed in the
open folder) and scan-any (any of your feeds) on <body>, and the refresh
icons turn under them. On <body> because the feed page is redrawn as items
come in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:50:41 +00:00
a5de4ae06c Toggle state in the icon's shape, not the theme's colours (#74)
4ed2d59 drew a pressed toggle in each theme's accent colour; the themes'
colours were not to change. Back as they were, pinned rows included. The
read button carries its state in its shape instead, as the pin does with
outline and solid: a tick when read, the envelope when not, where before
it showed the action (the envelope on a read item).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:38:59 +00:00
4ed2d59311 Toggles show their state the same way everywhere (#74, #75)
A sweep of all 24 theme palettes, measuring each icon's drawn colour,
found pinned in three colours: accent in the feed list, the text colour
on an item's row, and uncoloured on the toolbar, beside the title and on
the feed page. The read button showed the action (an envelope on a read
item) beside a pin showing the state. Now each toggle shows what is, with
aria-pressed, and a pressed one is the accent colour; Classic needs its
own rule, as its buttons set their colour at higher specificity. The
contrast test checks the accent on the button grounds, where it now draws.

Directory's Subscribe button carried the Subscribed tick; it is a plus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 23:36:23 +00:00
f57f824535 Each browser keeps its own theme, in a cookie (#69)
The theme was kept on the account, so every browser signed in as the
same person got the same one: no Glass on the phone with Dracula on the
desktop. It is now the ipx_theme cookie (<theme>.<mode>), written by
theme.ts, and read by the server to draw the page in it from the first
frame as before. /api/me no longer reports or takes a theme, and
set_theme is gone.

A browser with no cookie yet is sent the theme the account kept, and
takes it as its cookie on that first load, so nobody loses their choice
in the move. users.theme and theme_mode are only read now, for that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:27:20 +00:00
7490a3a9ac A spinner after pulling to refresh (#68)
Letting go of a pull removed its note at once and showed nothing else;
the sidebar's scanning spinner is hidden on a phone. With no sign the
check had started, people pulled again, and again. A "Checking for new
items" pill with a spinner now sits under the top bar until the request
is sent and two seconds have passed, and a pull meanwhile does nothing.
It lives outside #list, which a feed's render rebuilds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:23:31 +00:00
5be629427a /api/status, for Homepage's dashboard (#67)
The iPX tile on Homepage was a bare link: nothing in ipx gave a summary a
customapi widget could read. /api/status serves what `ipx status` prints
(feeds, items pending, files downloaded), from the same function the
control socket answers with, plus the version. It sits behind sign-in
like the rest of /api; Homepage sends the shared [web] token as the
ipx_token cookie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:10:05 +00:00
bf785299b0 No logo in the phone's top bar (#66)
The logo moved into the top bar beside the add-feed button (#60). On a
phone that bar is tight: the logo squeezed the search box down to a few
letters, and with no hover there its version tooltip showed nothing.
Below the phone breakpoint it is left out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:53:34 +00:00
d8db785681 The tab's icon follows light and dark mode (#64)
The logo on the page switched with the mode (#63), but the tab's icon
was always favicon.png, the light logo. web/favicon-dark.png is
logo-dark.svg at 128px, served beside it, and the theme script points
the icon link at whichever matches data-mode, so it follows the theme
the account chose, not only the system. The sign-in page, with no
account, picks by the system's with two media-bound links.
/favicon.ico, which a browser asks for on its own, stays the light one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:12:11 +00:00
f9c9c2b7cc Modern in the logo's colours, and the logo in the page's mode (#63)
Modern's palette was sampled from the 2004 iPodderX icon: a neutral navy,
its screen blue and amber EQ bars. It now takes the new logo's colours,
the dark half from logo-dark.svg (navy ground, #8fc2ea scale, #ff6a1a
needle) and the light half from logo.svg (sky ground, #2f6aa0 scale, the
needle taken down to #c43e00 so white on it clears AA). The pending amber
and the error red moved apart from the needle's orange, and the sign-in
page's copy of the palette follows.

The pages always showed logo.svg, the light variant, even in a dark
theme; logo-dark.svg was never served. It is now, and the app and admin
pages show whichever matches data-mode, dark until the script says light,
as the palette is. The sign-in page, which has no account's theme, picks
by the system's with <picture>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:05:44 +00:00
e16dace9c0 On the Unread tab, a swipe back goes to the item just read (#50)
selectEntry took each read item out of the list the moment you moved on
from it, so the item was not there for the back swipe (or k) to reach: it
went to the one before, or to the list if the item had been first.

Items read while turning from one to the next (a swipe, j and k) now stay
in the list until the reader closes or another item is picked from the
list, and a background refresh keeps them as it keeps the open one.
Picking a row still drops the item left behind at once, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:36:29 +00:00
ae900b82ca Name the icons by their contents in the pages (#57)
/favicon.png, /apple-touch-icon.png and /logo.svg are kept a day under
fixed names, so after the new logo went out, curl through the tunnel and
browsers still got the old one. The pages now ask for them as
/favicon.png?v=<hash>, the way they already ask for app.js and app.css,
so a changed icon is a new URL for every cache on the way. /favicon.ico
cannot carry a query, as browsers ask for it on their own; it keeps the
day.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:11:03 +00:00
7796566dfc A logo drawn from the radio's screen, to app icon guidelines (#55)
The 2004 icon is a whole radio on a transparent background and not
square, so the tab icon was padded and iOS painted the home-screen icon
on white. web/logo.svg is the radio's screen alone, its tuning scale and
orange needle, laid out as Apple's app icon guidelines ask: opaque and
full-bleed (the system cuts its own corners), a gradient background and
flat foreground layers with hard edges, no highlights or shadows of its
own, nothing thin enough to vanish at 32px. Each layer is a <g>, ready to
split out for Icon Composer. web/logo-dark.svg is the same layers
recoloured.

favicon.png (128) and apple-touch-icon.png (180) are renders of it. The
pages show it from /logo.svg, served outside the auth layer for the
sign-in page, with an app icon's rounded corners. The 2004 icon stays at
/icon.png for anything outside that links to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:31:16 +00:00
f2fde8cc75 Swipe between items like turning pages (#52)
On a phone the reader is fixed over the item list, and the swipe from #49 moved it alone and
faded it to 40%, so the list showed through it and in the strip it uncovered, and again as the
next item slid in from the far side. Now a layer beside the reader, #dpeek, holds what is really
there: the next or previous item, drawn by the same detailHtml the reader uses, with 16px of the
page's background between them; "No more items" past the last; or, swiping right from the
first, a dimmer over the list that lifts as the reader, shadowed along its edge, is drawn off.
On release the swipe carries on from where the finger left it, over 120-250ms by how far is
left, and the neighbour becomes the reader in place; with reduced motion it switches at once.

A touch starting within 14px of the left edge is kept from Safari, which otherwise takes it as
Back and leaves the page mid-swipe. 14px because the back button starts at 16.

Offsets are rounded to whole pixels: at a fractional offset the seam between the reader and its
neighbour drew a stray light line.

The design had the list shift a third of the way across as it is uncovered, as iOS does. #detail
lives inside #shell, and a transform there makes it the containing block for the fixed reader,
so that part is left out; the dimmer and shadow carry it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:46:07 +00:00
a573a07ed5 Glass panels catch the light at their edges (#51)
The glass surfaces had one 1px highlight along the top, which read as flat. They now have a rim
lit from the top-left and a fainter one on the far edges, as box-shadows, and a sheen from the
top-left corner as a background layer. A pseudo-element would have been the usual way, but the
detail pane, file list and dialogs scroll, and an absolutely placed layer in a scroller scrolls
away with the content.

Refraction was looked at and left out again: bending the live page needs backdrop-filter: url(),
which Firefox and Safari lack, and the WebGL libraries (liquid-glass-js, liquidGL) bend a
snapshot of the page that goes stale on a list that scrolls in its own pane.

The sheen lightens a dark panel under its text, so tests/contrast.js now checks every text colour
on a panel under the sheen at full strength. That capped it at 7% in dark mode; 9% put --faint,
--accent and --bad under AA.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:25:24 +00:00
380a552913 Share a feed, an item or a file (#48)
A share button in the feed's header, beside an item's "Open the original", and on each file.
It uses the Web Share API where the browser has it, which is the share sheet on a phone, and
copies the link where it does not. A file is shared by the publisher's address, not /media/,
which only someone signed in here can open.

Paid feeds carry the subscriber's access in their address, Patreon's in a token, so sharing one
gives the subscription away. An address that looks like that asks first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:17:52 +00:00
bdda9b3d2e Block lists: words that hide items and keep them from downloading (#47)
Each person has a list for every feed they read and one per feed. An item whose title or text
holds one of the words, matched as whole words so "ai" does not hide everything that "said"
anything, is hidden from them and, since the scanner now keeps each subscriber's filters
separate, is fetched only if someone else still wants it.

Whole-word matching is not something LIKE can do on both SQLite and Postgres, so the matches are
worked out in Rust into a `hidden` table whenever a list changes, someone subscribes, or a scan
brings in new items, and the queries only look that table up. Both new tables are tables rather
than columns because create_missing adds tables but never columns. Hidden counts as read for
the reaper and for "others still want this file", since whoever it is hidden from is as done
with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:13:03 +00:00
868dd673f3 Swipes take a longer drag and slide the reader across (#49)
At a flat 60px, a thumb scrolling slightly on the diagonal moved on to the next item by
accident. A swipe now has to cover a quarter of the reader's width, and never less than 100px,
and the reader follows the finger while it is down, so it is plain before letting go whether it
will move on. It slides off on a swipe and the next item slides in from the other side; a short
one springs back. The CHANGELOG also gets back the [0.8.3] link a stray edit had broken.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:04:30 +00:00
Ray Slakinski
9d1492b388 Hand playback to a native shell (#45)
CarPlay and Android Auto cannot render a web view. Both are template
surfaces, and the only audio they will control is the host's own AVPlayer
or ExoPlayer -- so an app that is "the web UI plus CarPlay" is really "the
web UI whose audio engine is native", and the page had no way to give
playback away.

web/src/native.ts replaces the playback surface of the page's media element
with one that forwards to the host and synthesises the events back. Nothing
in player.ts changes: it only ever speaks to the element, so the player bar,
the row buttons, the EQ bars and the keyboard shortcuts keep working as they
did. Video stays in the page, since CarPlay is audio-only and a native video
layer under a web view buys nothing. In a browser none of it installs.

Position and read are the host's to write. player.ts has been bitten before
by a stale position -- one left paused in another tab saved its older place
over where you had got to -- and a backgrounded web view is exactly that
tab: frozen, holding a time from minutes ago, while the host plays on. So
the beacon becomes a request for the host to save its own clock.

tests/native-bridge.js is what holds the two ends together, and it earned
its place immediately: the src setter called removeAttribute('src'), which
the shim's own override turned into a stop() that switched it back off one
line after enabling it. Silent, and only visible in a car. The stub DOM
moved to tests/dom-stub.js so that test and page-smoke share one harness
rather than two copies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 18:49:53 -04:00
ad15ae3dfe Glass theme, after Apple's Liquid Glass (#43)
Translucent panels with backdrop-filter blur and saturation over a soft
coloured wash, light and dark, going solid under prefers-reduced-transparency
and prefers-contrast: more. The sticky filter bar and column headings are
frosted, since the list scrolls under them.

Text on a see-through panel lands on whatever the wash is behind it, so the
palette's hex values alone no longer say whether it clears AA. contrast.js
now samples the wash as the browser composites it on three viewport shapes.
It caught the first light palette at 3.7:1 for faint text, and a tinted
selection that failed everywhere; both were changed.

Left out: SVG displacement-map refraction, which Chromium alone applies to a
backdrop and only on fixed-size shapes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:45:01 +00:00
c1187a7926 Verify Cloudflare Access's signed token before trusting the proxy
The proxy sign-in believed Cf-Access-Authenticated-User-Email from any
address in trusted_proxies. On Tower that address is the Docker gateway,
so any container there could name itself anyone (docs/sso.md said as
much, and CLAUDE.md listed it as a known gap).

With [web] access_team and access_aud set, a proxied request must also
carry a Cf-Access-Jwt-Assertion that verifies against Cloudflare's keys
(RS256 only, this application's audience, the team's issuer, not
expired), and the name comes from its email claim. The keys are fetched
at start and again when a token names an unseen key, at most once a
minute, so made-up key ids cannot make every request a request to
Cloudflare. While the keys cannot be had, proxied sign-in is refused;
password and token sign-in are unaffected. Both settings empty, nothing
changes.

jsonwebtoken does the checking, on the aws-lc-rs backend already in the
tree through rustls. Tests sign with throwaway keys in tests/data: a
valid token, another app's audience, expired, a forged signature, HS256,
alg none, the refetch limit, and keys that cannot be fetched. Checked
live on a scratch daemon: the header alone and a forged token got 401,
the admin token still signed in.

vouched_name takes the peer and headers rather than the request: a
&Request held across the new await made the auth middleware's future
unsendable, as a body is not Sync.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:38:25 +00:00
768d02c840 Catppuccin, Gruvbox, Solarized and High contrast themes
Each in light and dark. The published palettes missed AA in 19 places,
mostly Solarized and Catppuccin Latte, so each failing colour is moved
the least distance, toward black or white, that clears every pair it is
drawn in. Solarized dark's base0 had to rise to base1 to read on base02,
so its dim sits between base2 and base1 to keep three steps of type.

tests/contrast.js checks every palette against the pairs the page draws,
and a border that matches the ground it sits on. Its first run caught
Classic's links at 3.7:1 on the source list and Modern's faint at 4.3:1
on inputs; both are tuned. A failed toast moves to --panel, since the
error colours are tuned for the page's grounds, not --raise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:24:56 +00:00
35b57fa997 Settings and the shortcuts list close from the corner
Both have nothing to confirm, so their only button was a lone X at the
foot of the card, below the fold of a long Settings card. A dialog with a
confirm keeps Cancel beside it at the bottom, where the pair belongs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 01:45:02 +00:00
45cbd3a239 The scan spinner takes the unread count's place
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 01:21:49 +00:00
905dfa0b02 A spinner on the feed being checked, not toasts; check only your own feeds
The scan's events reach everyone, so every browser showed "<feed>: N new" and
"Scanning…" toasts, and refreshed, for everyone's feeds. Now a feed's row, and
its folder's, carries a spinner between feed_start and its done, skip or error;
the list refreshes only for the reader's own feeds; the scan toasts are gone, and
"Downloaded" is said only for a file on screen.

"Check every feed" from the web UI sent a scan of every feed on the server.
Command::Fetch takes an optional `feeds` list -- those feeds and the feeds
inside any OPML among them -- and the web fills it with the asker's
subscriptions. The schedule and the CLI send none, meaning every feed.

Closes #37.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 01:17:40 +00:00
f09bb4a11c Revert pinned items rising to the top of their list
Sorting by the pin column, or the Pinned tab, was enough. order_sql loses its
pinned_first option, pinning no longer reloads the list, and the tests and
changelog line for #35 go. The NULLS FIRST/LAST ordering from the Postgres work
stays.

Closes #36.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 22:08:37 +00:00
c99e17bd80 A pinned item goes to the top of its list
order_sql takes pinned_first, which puts coalesce(s.flagged, 0) DESC ahead of
the chosen sort, so pins lead every list in whatever order is asked for and on
every page of it. Not when sorting by the pin column itself, where the direction
is the point, and not for Currently Listening. Pinning now asks for the list again
so the row moves at once, instead of redrawing it where it stood.

Closes #35.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 17:51:38 +00:00
aeb686163b A separate admin page: server settings, accounts and the log
/admin, with Server, Accounts and Log sections chosen by the URL's hash. The
server sends the page and /admin.js to admins only (anyone else asking for the
page goes back to the app, and the script is 403), and removes the header's link
to it from everyone else's page rather than hiding it. The API keeps refusing
all of it to non-admins as before.

Settings becomes personal: theme, OPML import and export, and the schedule and
download folder to read. The server fields, the Users dialog and the Log dialog
move out of dialogs.ts into admin.ts.

The CSS moves out of index.html into web/app.css, which both pages load as
/app.css?v=<hash>, served immutable like the scripts. The smoke test checks both
pages.

Closes #19.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:28:28 +00:00
2d158a4540 Pin a feed to the top of the feed list
subscriptions.pinned, per person, set by PATCH /api/feeds/{id} {pinned} and
returned as FeedRow.pinned. Kept out of Sub, which the scanner merges into its
policy; set_subscription names its columns, so saving a feed's settings leaves
the pin alone (tested).

Pinned feeds come first in the list, a pin before the name and a rule under the
block: a pinned folder with its feeds under it, a feed from inside one lifted out
of it. The pin button is on both the feed and the folder page.

Closes #33.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:14:47 +00:00
fc425bffa6 Play/pause test: play without decoding the fixture
The fixture file does not reliably decode in the test browser; the load error
paused the player, which rightly turned the buttons back to play, and the test
failed in the full run. The test now fakes play and pause, events included, so it
checks what the buttons do and nothing else. The previous commit went up with
this test failing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:09:26 +00:00
42a1136e2e Every play button for what is playing shows pause, and pauses it
Only the player bar's button changed; the files pane's, the row's and the
toolbar's kept showing play while it played. play() now pauses when asked to play
what is already playing, which makes each of them a toggle, and syncPlayButtons()
repaints them on play, pause and ended and whenever the list or reader is drawn.

Closes #34.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:01:03 +00:00
53341264a7 On a phone, no "No files" box above an item that has none
The files sit over the text on a phone, so an item without any showed a box
saying so before its text. Nothing is shown now; the desktop files pane already
hid itself when empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:50:36 +00:00
9c16408d04 Keep the theme on the account, not in the browser
- users.theme and users.theme_mode, added by migrate(); GET /api/me returns them
  and PATCH /api/me saves them, refusing anything but a plain name and
  light/dark/auto, since index() writes them into the page's <html> tag.
- The page arrives with data-theme and data-choice already on <html> (and
  data-mode unless Auto), so it is drawn in the account's theme from the start.
- A theme a browser kept in localStorage goes up to the account once, the first
  time an account with none loads the page.
- Saves go one at a time, each with the choice as it stands: sent all at once, a
  quick run through the list could land out of order and keep a theme passed on
  the way. The browser test caught it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:33:59 +00:00
3b00e2721a Touch gestures: pull to check for new items, swipe between items
- Pull the item list down from its top: checks the feed (or every feed, on All
  Subscriptions) for new items, which arrive as they do from the scan button.
  overscroll-behavior keeps the browser's own pull-to-reload out of it.
- Swipe the item you are reading left for the next, right for the one before,
  or back to the list from the first. A vertical move is a scroll; something
  that scrolls sideways, or takes typing, keeps its own swipe.

Touch events only, so a mouse never sets them off.

Closes #22.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:13:28 +00:00
fc09e8a6b7 Favicon, level file icons, and a feed error mark in the triangle's column
- The logo as favicon, squared up (it is 128x121), at /favicon.png and at
  /favicon.ico outside the auth layer, where a browser asking on its own got a
  401; an apple-touch-icon on white (#32).
- An item not yet downloaded had its download bar on a line of its own under the
  file icon, lifting the icon above its row's; the bar now sits under it without
  taking space (#31).
- A feed error is Font Awesome's exclamation, hung in the margin where a folder's
  triangle is, in the same column; a folder holding a failing feed has its
  triangle turn red.

Closes #31, #32.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 14:09:38 +00:00
5483355021 Serve the script as /app.js, cached until a deploy changes it
The page loaded its script inline. It now names /app.js?v=<hash> (login.js for
the sign-in page), the hash of the script's contents: the script is served
immutable for a year and the page no-cache, so a browser fetches the script
again only when a deploy changes it and so its name.

Also fixes a race in the mark-everything-read test: it waited on a badge that
was seldom 0 to begin with, so a mark-unread still in flight could land after
the read-all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:03:25 +00:00
e2969bcee8 Themes: Dracula, Material, Adwaita, Flat Remix, Paper, Nordic, each light, dark or Auto
The theme picker lists Modern (the old Dark and Light), Classic and six new
palettes, from Dracula's spec (with Alucard), Material 3's baseline scheme,
libadwaita's CSS variables, Flat Remix's _colors.scss, Paper and Nord. A second
setting picks Light, Dark or Auto where a theme has both; Classic and Paper do
not, so it is hidden for them.

The page gets data-mode, light or dark, and Auto is worked out in theme.ts from
the system, so each palette is written once instead of again under a media
query. Every new palette clears WCAG AA for text on its backgrounds. An old
ipx.theme of dark, light or auto carries over as Modern.

Closes #27.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 12:53:25 +00:00