Commit Graph

522 Commits

Author SHA1 Message Date
33eb1f4db2 Signals in /metrics and on the dashboard; failed Jev calls are failed spans (#270)
The judge had spans but nothing in Prometheus: no count of items judged or
of how calls to Jev ended, nor whether the judge was still passing. The jev
span recorded an error field without the span's status, so Tempo's failed
traces left Jev out, and a timeout recorded nothing. Calls now send
traceparent like every other call out of ipx.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 02:55:37 +00:00
3b48f51a56 Home page: the Signals key sentence in the owner's words (#254)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:15:04 +00:00
55b0192df5 Home page: the Signals key sentence reads plainly (#254)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:08:21 +00:00
e07d27d9f2 Home page: no account section, and its screenshot gone (#216)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:06:44 +00:00
3532710b3d Home page: no What it does heading; Signals and DeadAir copy in the radio's voice (#216, #254)
Tune out the noise and All talk, no dead air, each a radio idiom the dial
at the top already sets up, written with /brand-naming-strategies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:04:24 +00:00
b56c97259d Home page: the features zigzag, each picture centred in its column (#216)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:00:24 +00:00
1025416181 Home page: a screenshot for Signals in place of the drawing (#254)
The drawn list's made-up titles read badly. The screenshot is a Signal
called Space on a scratch iPX on eight public feeds, with a stand-in
for Jev matching the Signal's words in titles, in Glass as the other
shots are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:53:49 +00:00
894e5e4711 Home page: the noise bars' widths in the stylesheet (#254)
The site's CSP is style-src 'self', which refuses style attributes, so
the bars would all have drawn at full width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:48:14 +00:00
0e21195e8d Versions after ipx 1.3.0, ipx-web 1.1.0 and ipx-directory 1.2.0: -dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:44:32 +00:00
3b41abd860 Home page: a section on Signals (#254)
Drawn in CSS like DeadAir's: the noise fades and the three items about
the words listened for stay lit. No screenshot, since one would need a
TypeSafe key on the scratch server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:37:34 +00:00
47ad68c7da Release ipx 1.3.0, ipx-web 1.1.0 and ipx-directory 1.2.0
Signals (#254) in ipx and the page, and the compression and tracing
work of #239-#253 in all three.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v1.3.0 web-v1.1.0 directory-v1.2.0
2026-10-16 20:37:33 +00:00
ae58e4653a Signals in the theme's accent tint again (#268)
Neither orange tile nor orange icon on the tint worked, so each
Signal's tile is back in the theme's accent tint, as it was before
#268. The Signals folder icon and the larger DeadAir tile stay. The
tile rules go above the initials' comment, which they had split from
its rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:24:18 +00:00
dc3ee016db Signals back on the accent tint, their icon or initials in orange (#268)
A solid orange tile was too loud. Each Signal's tile is the folder's
accent tint again, and its icon or initials are drawn in the orange of
the Signals icon's point: #ff6a1a in dark. In light it is the light
palette's needle, #c43e00, since signals.svg's #ff5500 on the tint is
about 2:1, too faint for initials (about 3.4:1 instead).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:21:44 +00:00
f916ca972a Signals solid in the orange of the Signals icon's point (#268)
--accent2 mixed a quarter into the ground came out salmon, and is pink
or blue in other themes. Each Signal's tile is now solid #ff6a1a, or
#ff5500 in light, the point of signals.svg and signals-dark.svg, in
every theme as the icon is, with the icon or initials in the logo's
navy (#0c2238, 5:1 or better on either).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:16:47 +00:00
7627af5bc8 Signals in the needle's orange, and a larger DeadAir tile in the play bar (#268)
Each Signal's tile, icon or initials, is tinted --accent2 in place of
--accent: the orange point of the Signals folder's own icon, so the
folder and what is in it read as one. The player's DeadAir tile goes
from 22px to 26px in its 30px button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:11:25 +00:00
fbe7629420 App-icon tiles for Signals and DeadAir, drawn as the logo is (#268)
web/signals.svg and web/deadair-{on,off}.svg, each with a -dark twin,
follow logo.svg's rules: square and opaque, the logo's gradient ground,
flat layers in its scale blues, the needle's orange, nothing thin
enough to vanish at 32px. Signals is the page's own glyph, waves going
out from a point, the point in orange. DeadAir on is the waveform
closed up with the needle at the cut; off has the silence still in its
middle, an orange flat line, and its bars muted, so the state is in
the shape as the old glyphs had it.

The Signals folder row and its page header show the Signals tile in
place of the glyph on an accent tint; the player's DeadAir switch shows
the on or off tile. tile() puts both variants on the page, and the mode
rule that picked the logo's now picks any .onlight/.ondark. The three
glyphs nothing uses any more are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:07:18 +00:00
e882904c11 Signals read an image's alt and title text, not the title alone (#267)
xkcd's posts are an <img> whose alt and title are the comic's words. text_of dropped tags whole,
attributes and all, so Jev was sent "Feed: xkcd.com / Title: Ground Effect", ground effect being
an F1 aerodynamics term, and the comic went into the Formula 1 Signal at 0.60. With the alt text
it scores 0.19. Alt and title are read once each, and once when they say the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:47:47 +00:00
f1a156c974 A Signal with no key links to typesafe.ai and to the key's field in Settings (#266)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:32:57 +00:00
f34423735a Signals' page copy says what happens, not that Jev does it; Auto spins while it picks (#263)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:28:52 +00:00
15bdce486d The icon picker's Jev button reads Auto (#263)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:23:13 +00:00
0c2c3a379e No + on the Signals folder's row, so it is like any folder's (#265)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:22:29 +00:00
3e4d67fd2f Jev is offered a brand's icon only when its name is one of the Signal's words (#264)
Font Awesome's Mandalorian icon has "disney plus" among its search terms, the only icon with
"disney" in them, so a search for Disney Parks' words offered it and Jev picked a Star Wars helmet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:20:34 +00:00
2ed8781604 Jev picks a Signal's icon, on the server's key, and each Signal counts its items (#263)
Jev chooses among options and cannot name one, so the page searches Font Awesome's index for each
word of the Signal's name, words and sentence, the best six each, and offers the likeliest 40.
POST /api/signals/icon asks a choice question on the server's TYPESAFE_KEY and answers with one
of those offered, never another. "Let Jev pick" in the icon picker, and a Signal made without an
icon gets one a moment after. Tried against TypeSafe: for AI Development, laptop-code 0.45 just
ahead of robot 0.40.

Each Signal's row says "12 items · 3 unread", as a feed's row counts its own, in place of its
words; /api/signals gives `items` beside `unread`, by the same rules as its list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:09:26 +00:00
d0ed953e84 Every Signal's tile in the folder's accent tint, not one by a hash of its name (#262)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:03:40 +00:00
4f1e7e23f6 The Signals folder's + before its count, so the counts line up (#261)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:56:12 +00:00
b18c92d1f5 A Signal's own icon, from all of Font Awesome Free, searchable (#261)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:36:08 +00:00
9e5dcbb8c4 Signals go straight through their queue, not 25 items every 5 minutes (#260)
unjudged() limits rows, one for each item and Signal, to 50, and the pass took the queue for
empty whenever the items numbered fewer than 50. With two Signals every batch is 25 items, so the
re-judge after #259 crawled at 25 items every 5 minutes. A batch is now full by its rows. A pass
TypeSafe stopped as malformed waits for the next wake however full it was, rather than asking again
at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:15:36 +00:00
ce485a82ac Signals judge one subject, with a sentence of your own, not any one word (#259)
Jev was asked "Is this item about any of these: ai, development, programming?", so any one broad
word was enough and the Signal's name, which says what is meant, was never sent. AI Development
held 54 items: Sega pulling a game remaster (0.90), a Surface Laptop review (0.95), a C++ tutorial
(0.99). It is now asked about one subject, called by the Signal's name, described by its words
and, when those are not enough, its owner's sentence. Scored against TypeSafe over those 54 with
"AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.":
5 stay, Linux kernel AI code review highest at 0.94; the remaster 0.06, the C++ tutorial 0.02.

signals.description is new, and its first start deletes every verdict, which answered the old
question, so everything in the window is asked again. Any change to a Signal's name, words or
sentence drops its verdicts, since each is part of the question. The queue query names the
Signal's sentence apart from the item's description, which share a row; the test caught the
item's winning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:12:29 +00:00
f8371f255d docs/signals.md: how Signals work, with TypeSafe's requests and answers and the API
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:57:05 +00:00
1ae91170fd Signal counts kept current on the page: after a read, and when Jev picks something out (#258)
The page fetched the Signals' unread counts only with the feed list, so reading an item, here or
in its own feed, left its Signal's badge as it was, and so did Jev adding one. The Apple Signal said
2 unread over a page with none. The page now fetches them again after a read or unread lands, and
on a new signal_hits event, which the judge sends once a pass for each person it found items for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:54:42 +00:00
20f53183ac Signals: Jev's request in the shape TypeSafe takes, and its reason kept when it refuses (#257)
Every call was refused with 422: a noul question's `criteria` is an object of what true and false
mean, and it was sent a sentence. The error kept only the status, from error_for_status, so the log
and the trace said 422 and not why; TypeSafe's body said "Input should be a valid dictionary"
at questions.3.noul.criteria. The body is now in the error and on the jev span, with the status.

A request refused as malformed (400, 422) ends the pass, since every other item's would be refused
the same way: the 50 warnings a pass were the same failure 50 times. The state is text now, as
TypeSafe's docs show it, rather than an object that sent null for a feed with no title, and the
questions are keyed signal_<id>. Checked against TypeSafe with the corrected request: 0.96 for an
item about the keywords, 0.02 for one that was not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:48:06 +00:00
8d93b58deb Signals folder under All Subscriptions, with its + on the folder's row (#256)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:39:04 +00:00
0fdd609791 Signals in a folder of their own in the feed list, as an OPML's feeds are (#255)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:14:45 +00:00
13cad18857 Signals' words: what matters out of the noise of your feeds (#254)
The dialog asks what to listen for, the page says what it picked out of the noise of your feeds,
and a new Signal says nothing has come through yet instead of showing an empty list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 16:58:27 +00:00
70990c70c7 Signals: feeds made from your subscriptions, picked by Jev (#254)
A Signal is a name and keywords. A task beside DeadAir's asks TypeSafe's Jev, on the owner's own
key, about each item of their subscribed feeds from the last 30 days and every new one: one call
an item, one noul question for each of their Signals still to hear about it, 0.5 or more a fit.
Every verdict is kept, fitting or not, in signal_verdicts, so nothing is asked twice, and new
keywords drop a Signal's verdicts to be judged afresh. A key TypeSafe refuses (401/402/403) is
passed over until its owner saves another, so a bad key is not sent the whole queue.

A Signal's items come through entries_where with an EXISTS on its hits, so filters, search, sort
and read state are an ordinary list's, and reading an item in a Signal reads it in its feed.

The page shows Signals under the places, with New signal, and Settings takes the TypeSafe key,
which is never sent back. The browser suite stubs Jev in the fixture server (IPX_TYPESAFE_URL).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 16:54:50 +00:00
30622fd3e1 CLAUDE.md: ipx reaches the directory at http://ipx-directory:8100
Set 2026-10-16 in Admin, Directory, where it was https://directory.ipxreader.app: every Directory
page and snapshot went out through Cloudflare and back down the tunnel, 170-340 ms a call, and a
Directory page took 150-200 ms; 29-54 ms now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:51:00 +00:00
1949f3e493 Compression across the board: directory answers, uploads, metrics, traces, the page (#253)
Measured 2026-10-16, uncompressed until now: the directory's answers (the feed list ipx reads,
603 KB of JSON, ~134 ms of a 159 ms Directory request), ipx's snapshot upload (~100 KB each half
hour), both /metrics (4.7 and 0.9 KB every 15 s) and every trace export to Tempo. Already
compressed: ipx-web's answers (zstd, #239), the home page and the directory's public pages
through Cloudflare (brotli, zstd).

- The directory compresses with brotli, zstd or gzip as asked, and unpacks a gzipped request.
- ipx sends its snapshot gzipped; the directory is deployed first.
- Both /metrics answer gzipped, as Prometheus asks.
- opentelemetry-otlp's gzip-http: OTEL_EXPORTER_OTLP_COMPRESSION=gzip compresses the export, set
  for all three in production's compose file; Caddy's exporter reads the same variable.
- web/build.mjs writes .br (quality 11) and .gz (level 9) beside each text file, and Caddy serves
  them in place (file_server precompressed): app.js 30 KB where zstd on the fly gave 37, app.css
  11 where 14. A .br or .gz asked for by name is refused, or /admin.js.br would be the admin
  page's script without the check that keeps it to admins; in a handle of its own, since a bare
  respond runs after the handles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:02:39 +00:00
3d94bce4f5 Directory requests traced whole: the call, its answer's size, the matching (#250)
A Directory page took 983 ms during a scan with 19 ms under spans: the directory's own span ends
when it has answered, and ipx's reading of the 458 KB answer, its subscriptions and catalogue,
and the matching of 1,471 listings had none. call() is a span now, directory_call, with the
answer's size; rows_for has subscriptions, catalogue and match steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:50:30 +00:00
cd7a9b8530 Apple Podcasts' top charts into the directory weekly, and its categories checked (#252)
With apple_country set, the directory fetches Apple's top 100 podcasts and the top 50 of each of
its 19 categories for that country (itunes.apple.com/{cc}/rss/toppodcasts), looks up each
show's RSS address (/lookup, 100 ids a call, three seconds apart, as Apple asks for no more than
about twenty a minute), and stores them as the snapshot apple-podcasts with no subscribers. The
refresher fetches them like any feed; the most-subscribed chart is unchanged. Apple's primary
genre goes in as the snapshot's category, used only while a feed names none.

The same weekly run reads Apple's genre tree (ws/genres?id=26) and warns when it differs from
ipx_feed's CATEGORIES; on 2026-10-15 they were the same, all 19 and their subcategories.

A syncs table keeps when it last ran: at start if a week has passed, again weekly, an hour after
a failure. keep_instances spares apple-podcasts, which no token holds, and no token may take
the name. A trace a sync, a span each answer.

CLAUDE.md: builds one cargo command at a time, the load looked at before and after, after a
build chained with a test compile took Tower past 200.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:21:48 +00:00
ca73b8136f Grafana: the connections panel shows in use and ready, not a pool that looks full (#251)
Since #241 the daemon keeps all 20 connections open, so the panel's open line sat on the pool's
maximum and read as exhausted; in use has been at most 4 in a week. It draws in use, ready
(idle) and the pool now, and the prod check reads idle the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:25:50 +00:00
6fde4b73cd Currently Listening and Pinned start from the person's own rows (#249)
coalesce(s.position, 0) > 5 and coalesce(s.flagged, false) hid from Postgres that a match needs
a row of entry_state, so it kept the outer join and scanned every item: Currently Listening read
all 574,000 (74,733 blocks from disk), 970 ms of its 1.3 s. s.position > 5 and s.flagged mean the
same, a missing row being neither, and let it start from the person's own rows: 2.2 ms, and
Pinned 32 ms to 2.3 (EXPLAIN ANALYZE, BUFFERS on production, read only). Unread keeps its
coalesce: no row is unread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:27:25 +00:00
cdf2c98f9f Currently Listening and the Directory open at once, or with a spinner (#248)
Both fetched on every visit and drew only when the answer came: Currently Listening took 1.3 s.
fetchPage keeps their answers by address, as the item lists are kept, sharing a request already
on its way; both draw from what is kept at once and again only if the refresh changed them, and
show the spinner with nothing kept. They are fetched ahead with the lists when the page loads,
and when the pointer or a finger reaches their row. Removing an episode from Currently Listening
forgets its kept copy, which would otherwise show it again until the refresh. The preload loop
takes a failed fetch as done instead of leaving an unhandled rejection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:24:25 +00:00
6640ca9612 Lists open at once from what the page keeps and fetches ahead, or with a spinner (#247)
A click on a feed, All Subscriptions or a filter fetched the list and only then drew it, so
nothing answered the click until the server did. The page now keeps the first page of each list
(the newest 40, by view, filter, sort and search), draws a kept one at once and refreshes it
behind, drawing again only if it changed. It fetches ahead: All Subscriptions and the twenty
feeds with the most unread once the page is idle, two at a time, and a feed or filter when the
pointer or a finger reaches it. A feed_row or a read forgets that feed's lists and All
Subscriptions'; the forgotten are fetched again behind. A list with nothing kept shows a
spinner if its answer takes more than 120 ms.

list_shown (#246) is reported when the list first shows, so a kept one reports its few ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:16:19 +00:00
62a3241139 Database connections kept open; a click's wait is measured (#241, #245, #246)
#241: on production, All Subscriptions' query runs in 31 ms, unread oldest-first in 30, the
feed list's count in 5, generic plans included (EXPLAIN ANALYZE, BUFFERS, read only), against
0.3-1.6 s in ipx. A new Postgres connection takes 80-330 ms to open, and a page load asks for
more than are idle: four of six slow loads came as the pool grew (3 to 6, 2 to 6, 3 to 5, 6 to
8), and 20 requests at once took a median 414 ms while the pool grew from 8 to 20. The daemon
and the directory's server now keep their whole pool open (min = max); a command such as `ipx
status`, and the tests, which share the server's 100 connections, open as they need.

#246: the page reports list_shown, the ms from asking for a list to drawing it, with the view
and filter, under the click's trace; background refreshes are not timed. Timings skip report()'s
once-a-minute limit, which is for errors in a loop. A dashboard panel and table, and the prod
check, read it.

#245: the load test's browse scenario subscribes by OPML and leaves the Directory, which is
ipx-directory's since #204, to it.

CLAUDE.md records the owner's leave to EXPLAIN (ANALYZE, BUFFERS) any query on production,
read only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:52:14 +00:00
8f19d88fc9 Compressed answers, a directory twice as patient, fewer whole re-reads (#239, #242, #243, #244)
Measured on 2026-10-15: the feed list and item lists went out uncompressed at over 100 KB; ipx
read 2.6 GB of feed bodies a day and the directory 3.9 GB.

- ipx-web compresses with zstd or gzip, leaving out /api/events, which a compressor would hold
  back, and /media (#239).
- A body identical to the last one (SHA-256 in feeds.body_hash) is taken as a 304, unless
  something is missing a read would fill or the check is forced; and a feed without a schedule
  of its own whose body has been the same waits a twelfth of that, up to four hours, beside the
  failure backoff (#242). 35 Patreon show feeds sent ~300 KB each hourly, about 250 MB a day.
- The directory checks each feed every twelve hours, not six (#243).
- reqwest accepts brotli and zstd too (#244).

#240 (cache the feed list's counts) is left open: at production's size on the test Postgres
that count takes 12 ms and All Subscriptions 32 ms, against 366 and 300 ms in production, so
the cause is production's database, not the query (#241).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:54:00 +00:00
dbfe7918ac ipx 1.2.1-dev, ipx-directory 1.1.2-dev, ipx-web 1.0.3-dev
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:33:45 +00:00
c957db7f61 ipx 1.2.0, ipx-directory 1.1.1, ipx-web 1.0.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v1.2.0 directory-v1.1.1 web-v1.0.2
2026-10-15 13:25:18 +00:00
4e0b5eed5d Failures and events go out without a private feed's key (#238)
A fetch that failed logged reqwest's words, which name the whole address: Patreon's auth key
went to Loki on 2026-10-15, and, since scan events go to every open page, to everyone's
browser too. #225 had taken queries out of the traces' address fields only.

Every event is redacted where it is emitted, for the log, the live stream and a terminal
alike: address fields lose their query and password, and so does any address in an error's
words (feed::redact_text). The artwork proxy's warnings and the directory's failed checks
the same. The error kept on the feed, shown to its own subscribers, keeps the detail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:18:08 +00:00
a9991545f9 Tests: two people on one file, each with their own DeadAir version, place and playlist
The DeadAir tests named for each person's clock used one account. Now three play one file at
once (DeadAir at two pauses, and the original) and each keeps their own place; and two fetch
the same /media/{id}/deadair.m3u8 at once and each gets their own playlist, no-store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:04:47 +00:00
60007d4e6a Page type-check: strict throughout, a typed $, null checks (#236)
$<T> returns the element type it is given and assumes the element is there: 122 lookups take
their type from the tag their id is on in the markup. A handler reads its target through a
cast, since a typed element will not take a handler whose event names a narrower target.
strictNullChecks found no bug the page had; its 94 places were values the code already handles
in ways the type-check could not follow (data- attributes the page writes, a map entry set the
line before, Share drawn only for an item with a link), each now said so.

isPlayable stays a plain boolean: as a type guard its false would have meant "no file", and it
is also false for a file that is not audio or video.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:50:33 +00:00