The judge's query read all of entries (605 MB, 566 ms) on every wake; an
index on each feed's items by date, as the query asks, and the rechecks
in a half of their own. Calls ran one at a time, about 150 ms each, so a
new Signal's month of about 8,000 items took twenty minutes: six in flight
now. On the server's key everyone's Signals on an item share one call.
TypeSafe's token counts go to /metrics and each jev span.
An item's text is cut at 1,000 characters instead of 2,000: on 55 real
items of eight podcast feeds and six Signals, on the development key,
1,000 changed none of 330 verdicts (nor did the same request twice) and
saved 12% of the input tokens, 1,266 to 1,112 a call; 500 changed one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With signals_on_server set and TYPESAFE_KEY present, the judge asks Jev
about everyone's Signals on the server's key; otherwise each person turns
Signals on in Settings with a key of their own, which is now checked with
TypeSafe before it is saved. The Signals folder shows only when they are on.
The browser suite's daemon inherited the shell's TYPESAFE_KEY, so fixture
feeds were sent to the real TypeSafe to be categorised; it is blanked now,
and an empty key counts as none.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
--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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
#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>
$<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>
290 places the type-check could not see into, mostly parameters, now typed with web/src/types.ts
(feeds, items, files, accounts, log lines, the directory's rows and items). Two changes run
differently in form only: the seek and volume inputs divide Number(value) where they divided
the string, and esc's table has a name. Null checks are #236.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
web/src/types.ts mirrors FeedRow, EntryRow, EncRow, /api/me and PopularRow; types only, so
swc strips it and it is not in build.mjs's list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The buttons followed the feeds being checked, so they stopped between one feed and the next;
7ecbe82 held them for 600 ms over each gap. Now pressing one starts it, and scan_done stops
it only when it is the end of the scan asked for: scan_done carries `only` and `forced`, so a
scheduled scan finishing first does not stop a button whose scan is queued behind it. An
error event, a job that failed without a scan_done, stops them all.
A row's spinner stood in for its unread count from the moment its feed was checked; most
checks take milliseconds, so each count blinked as the scan went by. It now waits 300 ms.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A scan checks its feeds one at a time; in the gap between two the body class came off, the
icon snapped back upright and the spin restarted, many times a second with fetches ahead.
The class now comes off only after 600 ms with nothing being checked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The page reported NetworkError rejections from the live update's list refresh and the first
feed load, which had no catch, whenever the network was gone (a laptop asleep). A Cloudflare
502 page became a request's error message, since api() used whatever body came back. And
the trace id lived until the next tap, so background requests nine hours apart shared one
trace. The id now ends after a minute without a request; a play's position saves, every ten
seconds, keep it going.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The zone is in Cloudflare and the rays-unraid tunnel routes the name to ipx-directory, as it
does ipx-dir.sdf1.net, which keeps answering until every server using it has moved: a redirect
to another host would drop the Authorization header. Production ipx shares with the new name.
CLAUDE.md, docs/directory.md, the prod-check skill and the dashboard name it; the admin page's
placeholder and the settings' hint use directory.example.net.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Directory feed's page offers Check now and Remove to an admin whose token is rwa (the page
learns it from can_admin on the feed's page, asked of the directory only for admins). Admin,
Directory lists the servers sharing, from a new GET /v1/instances, and the blocked feeds by
title: a feed is often blocked because its address carries a key, so the address stays in the
directory. Unblocking or dropping a snapshot sends this server's snapshot again at once; since
directory's 403 reaches the page as 403, not 502.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The workspace stops holding one version. ipx keeps 0.12.1-dev and its vX.Y.Z tags; the directory
starts at 0.1.0-dev, tagged directory-vX.Y.Z, and the page at 0.1.0-dev in package.json, tagged
web-vX.Y.Z, which web/build.mjs gives the script for the logo's tooltip beside ipx's. crates/feed
is a library, not released. CLAUDE.md's release steps are for one service at a time.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
web/build.mjs builds a static site, web/dist, with the icons and typeface
beside the pages; Dockerfile.web serves it with Caddy, which passes /api
and /media to ipx on the same origin, so cookies, live updates and
Cloudflare Access work as before. ipx serves no page any more: build.rs
and every include_str! of the web files are gone, and its image is built
without node.
What ipx did to the page as it served it is kept another way. Caddy asks
ipx before serving a page (forward_auth): /api/page-check sends a browser
not signed in to /login and a non-admin away from /admin, and refuses them
/admin.js; /api/login-check sends someone Access vouches for past the
form; a page asked for with ?token= goes to /api/signin, which sets the
cookie. The theme and the admin link, once written into the page per
person, are set by web/boot.js in <head> from the theme cookie and the
last visit, before the first frame, and /api/me brings the version and
the account's theme from before.
Caddy strips the Cf-Access-* headers from every peer but TUNNEL_PEER, the
address cloudflared's requests come from, which is the boundary
trusted_proxies drew; ipx then trusts only Caddy's address.
The browser suite runs Caddy in front of its daemon, so it tests what
production would run. Not deployed: production's cutover is on #203.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ipx asks ipx-directory on its people's behalf, with the token an admin set
in the new Admin, Directory tab, so the page never talks to it and never
sees the token or a feed's address. /api/directory, /api/popular and a
feed's Directory page answer from it; subscribing takes the address from
the directory and goes through Add feed's steps. With no directory set
up, the Directory says so. The local directory code is gone; its privacy
checks are the snapshot builder's now, and their tests moved with them.
With sharing on, ipx sends its snapshot after every scan, whenever the
config changes (set_cfg) and every half hour: each public feed and how
many here subscribe, never an OPML or Patreon creator itself or anything
private. A feed can be marked private in its settings, for a paid feed
whose address does not give it away; anyone subscribed may mark it, only
an admin unmark it. The admin's category for a feed that names none goes
with it, which the directory uses only while the feed names none, so the
category an admin gives a blog still reaches the Directory.
The directory lists only feeds it has read as a feed: the first snapshot
after a start went before the first scan, when an OPML was not yet known
to be one, and the directory listed the OPML. It now fetches what a
snapshot brings in at once instead of within the minute.
The browser suite runs its own ipx-directory on SQLite; the Directory's
tests match feeds by title, since the directory names them by address.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A feed's settings stopped showing its folder in 0.10.3; the dialog that adds one
still asked for it. The API keeps its folder for scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
hls.js sends traceparent on its own requests (xhrSetup); a beacon, which
cannot set a header, and Safari's player, fetching the playlist itself,
carry it in the query, which the server reads where there is no header.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing the page did in the browser reached the logs: a DeadAir playback
failure on the phone was a toast and nothing more. POST /api/client takes
page errors, unhandled rejections, hls.js and media errors, the time from
play to sound and the length of a stall, and logs them as ev lines under
ipx::client, inside the request's span. Only those kinds are taken, the
text cut to 500 characters, and the page sends each at most once a minute.
The page's API requests send traceparent, a new trace id for each tap or
key, and the server's request span takes it as its parent, so one action's
requests are one trace in Tempo, the report included. The stub DOM the
smoke and native-bridge tests run in gains crypto and performance, which
every browser the page runs in has.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The save button fetched the original while the player played it shortened.
/media/{id}/deadair sends the original's ID3 tag and then the pieces of the
person's plan one after another, read as it goes: MP3 and ADTS frames each
stand alone, so that is a file any player takes, and nothing is stored, as
with the playlist. The tag stops at the first kept frame, since what can
follow it, the Xing frame, gives the original's length.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Below the box it read as a setting about something else. It sits after
the download options' fold, not in it, since DeadAir applies whether or
not the show downloads on its own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Pause it leaves" said how DeadAir works, with an "it" to decode, not
what you hear. Greyed out rather than hidden, so the choice can be seen
before DeadAir is turned on.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The four segmented buttons gave no sense that 0.2 to 0.5 s is one scale,
and stayed on screen with DeadAir off, where they did nothing. A range
input with a dot at each stop and Brisk/Relaxed at its ends says both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DeadAir made a shortened copy of each episode with ffmpeg: as much disk
again as the original, one pause length for everyone, and a minutes-long
wait after the download. Seeking past silences instead was tried on an
iPhone and left about 1.3 s of nothing at every cut (#178).
Now it finds a file's silences once (silencedetect, plus ffprobe for
each frame's byte position) and stores a plan for each pause a person
can choose, 0.2 to 0.5 s: the cuts, and the frames kept as byte ranges
of the original. /media/{id}/deadair.m3u8 hands this person's plan out
as an HLS playlist of ranges of /media/{id}/deadair.mp3, the original.
Tested on iOS Safari first (#180): no gap at the cuts, plays locked,
seeks exactly, and needs no ID3 timestamp in front of each piece.
Other browsers get hls.js (1.6.19, the light build), loaded only when
needed. Not where canPlayType says it can play HLS: Chromium answers
"maybe" and then fails on MP3 ranges with "stream parsing failed", so
only Apple's browsers play the playlist themselves.
The pause is per person and per show, on deadair_prefs.pause_ms, 0.3 s
unless chosen; 0.2 s, what the copies left, made speech sound rushed.
AAC in MP4 has no ranges a playlist can use, so only MP3 and ADTS AAC
are shortened (the library is 803 MP3s and no .m4a). The copies, their
table and #179's expiry go: the first start drops the table and deletes
the files it names, only those named name.deadair.*.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new download in a show with DeadAir on plays at full length until its
copy is made, and nothing said so. Each file now carries
deadair_pending while DeadAir is on for its show and the copy is still
to come (not for one heard through, which it passes over), and the
episode's line and details say "Cutting dead air…". The maker sends a
deadair_done event when it finishes a file, and the page takes the list
again, so the line becomes how much shorter the episode is without a
reload.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The switch, the button's tooltip, the toasts and the time saved now say
what the name already pictures, the dead air cut, instead of explaining
it as silences shortened each time. The caption says the voice keeps its
own pace, which is the part people doubt, and names the shows to leave
out: stories whose pauses are part of the telling.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Outlined bars against solid ones read the wrong way round at 20 px: the
outlines ran together and the off icon looked the heavier. The off icon
now has the gap in its middle, the on icon has it closed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A DeadAir switch in each show's settings, and the same switch beside
the speed for the show that is playing, drawn as a waveform with its gap
closed up, outlined when off and solid when on, with aria-pressed. A
file the server gives a copy for plays from /media/{id}/deadair. An
episode's line says how much shorter it is, and Settings says how much
DeadAir has saved you.
Turning it on or off mid-episode saves the place with a request it waits
for, not a beacon, then asks for the entry again from the new
GET /api/entries/{feed}/{guid}, whose position is in the clock of the
version it now plays, and carries on there. A copy not made yet plays
from the next time. Turning a show on wakes the copy maker, so the copy
is there in seconds rather than at its next pass.
The browser test plays a real MP3 with long silences, quiet.mp3, from a
show it adds and removes itself.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Asked for: swiping on from the last item in the reader showed "No more items" in faint text beside
it; the pane is now the theme's accent colour and says nothing, the colour saying it is the end.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>