Capture's routes (downloads, files, public watch links) and the token list no longer build SQL, call
the session or set a model's fields: reads are downloads/queries, writes the unit's service (service,
folders, links, quota), and each write flushes for the handler to commit. Whose a row is goes
through userscope (owned_by, owned_row), which owned_or_404 now uses too.
The fence holds Capture below its routes as well: it reads only Catalog's and Plex's tables, writes
no other unit's, filters owners through userscope and commits only in its jobs and the upload
ingest. A test per service use case, among them that the tray lists only the caller's files.
The nine Capture tables (files, jobs, folders, shares, links, quota, cookies, profiles, library
resume points) leave app/models.py for the unit's own module; app.tables registers every unit's
tables for the migrations, the test database and the ownership check. No schema change.
The fence is switched on unit by unit: each route file's database work (SQL built, session data
calls, model attributes set, unit tables imported) is pinned at today's count and may only shrink.
A core route may not reach into a unit's tables. Every model has an owner.
- Seven looping pulses (a running job's dot, the scheduler's progress bar, a queued download, the
download tray, a playlist upload, a channel's full-history action, the setup wizard's test mail)
ran regardless of prefers-reduced-motion, which docs/DESIGN.md promises to respect: each is
motion-safe now, held by a contract test.
- The a11y suite's "settled" wait took every aria-hidden pulse for a loading skeleton, so the
Scheduler legend's running dot, which pulses for good, kept it waiting until it timed out. A
skeleton bone now says it is one (data-skeleton) and the wait looks for that.
- The scheduler keeps one registry of the runs this process has open, for the interval jobs and the
ad-hoc runs alike. A row the table still says is `running` but no run here has open (its outcome
failed to write) reads as interrupted, as the next start's sweep will close it: no pulsing dot
beside "No run recorded yet".
- Migration 0109's downgrade sums a still-running run's quota events into its spend before the link
to them is dropped, as the startup sweep would.
- A test holds that a dialog closing onto the same address leaves an open log line open.
- The channel-backfill row says "running" from the runs this process has open, not from the table:
a row whose outcome failed to write no longer reads as running until a restart. The run still
going leads the row, so a newer run's outcome never sits under the older one's channel.
- Migration 0109's downgrade carries every run's trigger, spend and start in the audit row, and the
upgrade restores them: a manual run whose admin was deleted stays manual.
- Back/Forward is read by the page's one popstate listener, both halves in one render; the server
log adopts its half during that render, so no read goes out with one entry's filters and the
other's range.
- The server log's failed facets use the other tabs' words (the menus may be short); the accounts
message shows only when the menu really lacks them.
Backend
- A run's outcome write sits outside the job's own try: a database blip after a run that
succeeded no longer records it as failed (the row waits for the next start's sweep).
- An interrupted run keeps what it spent (the sweep sums its quota rows, as finish does).
- The Scheduler's ad-hoc row says running while any backfill runs, not only the newest.
- recent_runs reads each job's last runs off its index instead of ranking the whole table.
- An interval change is stored with its audit row in one transaction, then rescheduled.
- One API instance per database is the stated contract (.env.example, jobruns).
- Turning full history on for a channel already complete records no run.
- Migration 0109 round-trips interrupted and ad-hoc runs; the run words' DB CHECKs are
test-held to the enums; the account filter is one helper for the four activity reads.
- An audit row says how many of its request's log lines are still kept.
Frontend
- An "All" period reaches every row still kept (runs and admin actions outlive 90 days).
- Back/Forward is followed in place by the server log: a closing dialog no longer turns
Live off; a pivot still opens a fresh log.
- Runs moved over from the audit log read their one-liner in the reader's language.
- Filter choices or accounts that failed to load say so beside the filters, with a retry.
- An old server-log link keeps its account, text and range; a remembered retired page
opens its tab.
- A request whose lines are gone says so instead of linking to an empty list.
- The log lines and the history rows share one row frame, and the server log one list
frame with the other tabs; an empty list is announced; each day's quota is readable.
The run list showed the quota spend as a bare figure and, at the largest text size on a 1024-wide
screen, its fixed columns left the outcome column no width at all. The outcome now keeps 10rem and
the trigger column gives way; the audit list's summary keeps the same floor. A run that stored no
log line says so instead of linking to an empty list. An API token revoke and a token's file work
rendered the server's English line under the Hungarian UI; both are rebuilt in the reader's
language, and the test holds the server's wording.
Seen in the browser, not in the page's own tests: the navigation provider rewrites a retired page id
to `activity` before the page's chunk reads the URL, so `?page=audit` landed on the Runs tab. The
retired-page map now carries the params that open the successor's part (`at`), and the provider
writes them as it rewrites the page — on load and on Back alike.
A list time is relative with the exact second on hover (the DESIGN copy rule); a run moved over from
the audit log, whose start and end are one instant, shows no length instead of an invented "1 sec".
The audit log is called "admin actions" everywhere it is named (the clear button and its confirm, the
Configuration group, the cleanup job, which also prunes the run history its description left out).
The audit log, the server log and the admin quota view were three pages (the quota one a Stats tab),
each with its own filters, and the background runs had no list at all. Now one page ("Napló" /
"Activity") reads all four, each still its own table on the server: a shared bar (text, a period of
1 h to 90 days defaulting to 7, an account) and each tab's own filters, the whole view in the URL.
The old page ids open their tab.
Server side: the audit log and the new run list (`/api/admin/runs`) filter and page like the server
log, through one keyset helper (`routes/_keyset`) the log now shares; an audit row keeps the request
it was made in (migration 0111), so its log lines are a pivot away, as a run's are (`lrun`). The
quota breakdown takes since/until and one account. The Scheduler rows get a history button to their
job's runs.
Also fixed on the way: re-saving the value in force on the Configuration page staged its override
without committing it (the commit sat inside the audit branch), so the save was lost.
An interval override was a row of its own table beside the one store every other admin knob uses; it
is now the `job_interval_<job>` sysconfig key (migration 0110 copies them over and drops the table).
The Scheduler page stays its one editor: the Configuration page and the setup wizard neither list nor
write those keys, since a write there would skip the reschedule.
Each ConfigSpec now states `applies` (required, no default). Every key is `live`, held by a scan that
finds no read at import time; a `restart` key is refused until the page can say so. A second scan
requires a reader for every key on the Configuration page — `sponsorblock_default` had none since it
was added, so it leaves the registry, the translations and the store.
A row's one dot said how the LAST run went, and an interrupted run fell through to "idle" — a grey dot
that read as "no run recorded". The row now leads with its last three runs, newest first, each named
for a screen reader and in its tooltip (how it went, when, who started it); an interrupted run is a
ring, set apart from a skipped run and a failure, and its line says the server restarted under it.
A channel's full-history backfill gets its own "Ad-hoc runs" block: its runs, its channel and what
it fetched, no interval and no run button — it needs its target. AD_HOC_JOBS names every such job;
the backend-id contract holds their names in both languages, and run_ad_hoc refuses any other id.
The one-channel deep backfill ran as a FastAPI BackgroundTask: what it fetched, what it spent and
how it failed reached only the log. It is now a one-shot run on the scheduler (`channel_backfill`,
trigger ad_hoc, actor = the user, target = the channel) with its quota tagged to the run. Its
per-channel exclusion with the scheduled backfill and the two-run cap are unchanged, and a run
they turn away says why in its result instead of returning nothing. A failure is no longer
swallowed: the run records it.
The scheduler snapshot carries each job's last three runs (one windowed query), so the page can
show an interrupted or skipped run instead of the latest outcome alone.
What a job last did lived in the API process's memory and was gone at
every restart; the audit log kept a run only when it was manual, failed
or changed something. Now each run is a job_runs row written at its
start and its end in its own session: who started it, its outcome as
data, what it spent of the YouTube quota (quota_events.job_run_id) and
the lines it logged (log_events.run_id, filterable in the log console).
- a run a stopped process left open is marked interrupted at startup
- "run now" is a one-shot job on the scheduler, which now always starts
(interval jobs only with SCHEDULER_ENABLED); the manual actor is reset
after the run, since pool threads are reused
- the dashboard's last run comes from job_runs, so it survives a deploy
- runs leave the audit log: migration 0109 moves the old job_run rows
over (and back on downgrade); the audit page's run folding goes
- job_run_retention_days (default 30) prunes old runs in audit_gc
Pixel-measured over every scheme x mode x backdrop: the item count fell to 3.8:1 (dark too: its 8%
fill let the toolbar glass and the thumbnails under it through), Channels' tabs to 4.1:1 and the
current Files row to 4.4:1. Light --muted moves 15% toward --fg in every scheme; the badges, the item
count and the current rail row paint opaque bg-tint* fills mixed into the surface. The token contract
now asks 6:1 of --muted on the bare page and checks those three sites use the tints.
Under a busy machine (the gate's lanes side by side, 12 busy cores) these went red at random: a press landed while the button was still busy, an answer was settled before it was asked for, a page grew past one wait's budget, a lazy chunk was transformed inside a 1 s wait, focus was taken a frame after the press. They now wait for the condition they depend on, preload the chunks they open, and the tests that do heavy work (whole-repo scans, the type-aware linter, module resets) carry an explicit budget naming that work. The whole suite four times under 12 busy-loop processes: red 4/4 before, 0/4 after.
--list prints the lane names, --only "<lane>" runs that one lane, so a runner can schedule the lanes itself and a red lane can be re-run on its own. An --only run reports that lane, never the gate, and an unknown name exits 2 instead of passing with nothing run; the contract test holds both.
- The queue holds an upload it sees land done while this tab's upload still waits for its answer, so
the dialog's notice is the only one whichever comes first; another device's upload follows after.
- One `useCopyFeedback` for every Copy beside a value (token, share link, set-password link): Copied
for a moment, a notice a screen reader hears, a warning when the clipboard refuses; the token
dialog's two Copy buttons are named by what they copy.
- The share dialog's link list shows loading, and a load error with Retry, never "no links" — and no
New link beside a list it has not seen.
- The settings texts say that signing out elsewhere or a password change revokes the API tokens.
- DESIGN.md logs the folder-link decisions; the design lab keeps only the chosen renderings of the
Links, Tokens and Files rounds.
- A password reset or change, "log out everywhere" and a revoked untrusted password revoke all of the
account's API tokens too (audited): one minted from a stolen session no longer outlives the answer.
- A bearer token or link grant with a non-ASCII character is refused (401/403), not a 500.
- Making a token's grants or a folder link holds the folder, so a racing delete cannot fail the
foreign key; a link's view count is one atomic UPDATE.
- A folder link's card counts exactly the files its page lists (one `links.folder_files`).
- A file with no extension is served without a trailing dot.
- 0106's downgrade names a Hub folder so it never collides with one of the user's folders.
- The speed baseline reads share tokens through the app (the database keeps them hashed/encrypted).
- Tests: the token scope on the real session stack, the folder poster, the Hub refused for a link,
0106's downgrade data step, 0107 without a key; docs/comments follow (three folder kinds, folder
links, key change, the cleanup job's token warnings).
A /watch link may now open one of its owner's own folders (migration 0108: folder_id beside job_id,
exactly one set; a folder's links go with it). The page shows what is in the folder when it is
opened, newest first: a video or a song plays in the same player a file link has, above the list,
each keeping its own resume point; a document only downloads, never opens as a page. A file moved
out stops opening through the link at its next request. Password, expiry and stream-only work as on
a file link; the unfurl names the folder and counts its files, never their names. Source folders
and the Hub cannot be shared.
The folder menu gets "Copy link" and "Share with a link…" (the Share dialog's link half); the rail
marks a folder a live link opens.
Two fixes in the Share dialog on the way: revoking the last link no longer mints a new one at once,
and the new-link form goes back to downloadable after making a link.
A /watch link's token is the credential, and it sat in the database as written. Migration 0107
replaces the column with the token's SHA-256 (the lookup) and a Fernet-encrypted copy (so the owner
can copy the same link again); every existing address keeps working, and the downgrade decrypts
them back. Without a copy this key can read, a link still opens but its address cannot be shown:
"Copy link" then takes the newest link that has one or makes a new one, and the Share dialog says
so in that row instead of a broken URL. Making, changing and revoking a link are audited, never
with its token or password.
An upload that came back done was announced twice: by the upload dialog ("File uploaded") and
again by the tray, which saw a job turn done ("Downloaded: …"). The dialog now marks its job as
announced, so the queue keeps it for the tray but does not say it again. An upload the worker
converted first is still the tray's to announce, now as "Uploaded: …".
A finished job's notice offered "Open" while Files still listed the folder without it (its own
poll runs every 15 s): the tray now refreshes the library the moment it sees a finish.
Settings gains an API tokens tab: a row per token (name and prefix; each
folder with its rights; last use and expiry, "expires soon" marked),
revoked from its menu behind a confirm. A new token is the Hub exchange in
one click, or folders and rights picked per folder (no Write where an
upload cannot land); expiry 30/90 days, 1 year or never (warned). The
token shows once in the same dialog, with Copy and a ready curl line.
The Hub folder closes the Sources in the Files rail, takes uploads and
moved-in files. A token about to run out warns its user once, a week
before (the Files GC's sixth pass); the notice opens the tokens tab.
A bare <img> sent a Referer that Google's image CDNs refuse and had no retry and no fallback, so a
Home shelf card showed the browser's broken-image mark. ui/RemoteImage gathers what Avatar, the
channel banner and the download rows had each hand-rolled: no Referer, up to three retries past the
browser's negative cache, the caller's fallback (by default the card's empty frame) instead of the
broken mark, the failure remembered by URL. Every picture now goes through it; a lint rule refuses
a bare <img>.
A build cache kept the upgrade layer for as long as nothing above it changed, so a publish shipped
curl and librsvg a release behind fixes already in the Debian archive (the CVE gate refused it).
The release build passes the day; empty, the default for a self-hosted build, leaves the cache alone.
The Plex player's ⋯ → Settings closed the menu (its entry went back) and opened the panel a task
later, before that back() had landed. The panel's push was the entry the landing back() took, so
the books counted one entry more than the history held, and closing the panel by a click outside
it stepped back over the player's own entry: the player closed.
A push now waits while a back() of ours is in flight; the popstate that lands the last one
reconciles again.
A user can make a token for a machine of theirs (`/api/tokens`, signed-in only): `sfl_` + 32
random bytes + a checksum, only its SHA-256 kept, shown once. Sent as a Bearer header it is
honoured on six `/api/files` routes only — folders, list, file, hash, upload, delete — and there
only in the folders its grants name, with the rights they give (list / read / write / delete).
One check, `FilesCaller.may`, in every route; a folder or file out of reach answers like a
missing one; the files shared with the user are never in reach. A token beats a cookie on the
same request.
The Hub preset makes the user's one Hub folder (a new folder kind) and grants it every right.
Refused tokens are slowed down per client, a runaway machine per token; last use is stamped at
most once a minute; making and revoking a token is audited, and a token's uploads and deletes
name it in their audit rows.
- past the list's cap a search waits in a skeleton, never showing the newest files' 'no match' for a
file the server is about to find; a sort keeps its stand-in rows
- the viewer keeps the file it was opened on when a server answer replaces the rows under it
- a drop another zone took still clears the page's drop overlay
- 'The first N items' only for a server sort; the past-the-end notice no longer claims the file exists
- a failed server search is tested to say so
- set_retention and delete_own re-read the folder under its lock and use that row (an unfetched ORM
result never refreshes the object); a folder gone meanwhile is refused (409), and the route audits
the retention actually replaced
- a finishing download is stamped only while it is still in the folder (and keep) it was read with:
a move or a folder delete landing meanwhile sends the read round again, so no file is done with no
expiry
- the eviction pass counts the disk before each candidate and skips one asked for since it began
- docs: source builds get their own volume-copy snippet
- race tests assert the lock wait; one built on a committing delete never raced
- Escape closes the file viewer again: its layer sits above its dialog's, so it takes the Escape.
- The page's drop guard leaves a drag the open Upload dialog's zone accepted alone (it ran last and
refused that drop).
- A capped list's server sort or search keeps the rows on screen until its answer is in (no skeleton
under the pressed header), says "The first N items" when that answer is cut too, and an arrival is
decided on the rows shown: one past the end of a cut list is said to be so, not gone.
- The search an arrival clears is taken at once.
- Tests: confirm names asserted per confirm, a fitting text pages under the arrows, a deleted file
leaves the tray's finished list.
- GC: the expiry warning marks each file conditionally on the expiry it saw, one commit each (a file
deleted meanwhile no longer fails the run; one re-timed keeps its fresh expiry unwarned); eviction
counts the disk again when a candidate was freed meanwhile, so it never evicts for space already free.
- Folders: a retention change locks the folder and its files, and a finishing file reads the retention
under a share lock, so neither misses the other; a folder delete locks the files it takes home (a
file moved out meanwhile stays put, one deleted meanwhile no longer fails the delete).
- Upload: the folder is checked before the probe and held only once the job names it.
- Dedup enqueue locks the cached asset, so a release meanwhile yields a fresh one instead of a 500.
- Mount check reads the kernel's mount table instead of comparing devices (the vfs storage driver gave
a mounted volume the device of /), and only inside a container; the API also logs a fresh, empty
volume in the library's place.
- Compose files fall back to DOWNLOAD_HOST_PATH; the upgrade notes cover the from-source pull.
- Folder routes call owned_or_404 directly.
- A library past the list's cap is searched and sorted on the server; the count says "the newest".
- Each folder and each search opens on its first page (DataTable pageScope, useCardPager scope).
- A file dropped where the page takes none never reaches the browser (which would unload the app).
- The viewer's arrows wait while a layer opened from it is on top, and scroll a wide text line;
a text shows its first 5000 lines and says where the rest is.
- Confirms name the file (no more "your downloads"); the asking menu rows end in "…".
- The tray names a running step beside its bar, shows a pause mark for paused work, polls slowly
while only paused work waits, and forgets a finished file that was deleted.
- An unchanged rename sends nothing; a paste opens the download dialog only with nothing on top;
the link box never replaces what was typed.
- Honest states: folders that failed to load, a file no longer there, the storage figure; the rail
says "nothing downloaded" only when there is nothing.
- Focus after deleting the open folder and when the format form opens; the format name's label.
- ui/Meter (one danger line for both quota bars) and ui/RenameField in the platform layer.
- Copy fixes, stale comments and the speculative hub key; a contract test for the list's
parameters; e2e: the Files page and the paste intake.
- The queue keeps a done job by when it became done (ready_at), not by its last write: renaming,
keeping or moving an old file, or the GC's expiry warning, no longer brings it back as just finished.
- GC expiry and eviction re-read each file under the asset→job locks the user routes take, and commit
one file at a time: a delete, a re-download or a keep made while the GC runs is honoured, never a
second release that frees a file another user still holds.
- A file records the origin it was placed under (download_jobs.source_key, migration 0105): an edit
whose parent expired still goes back to its folder, not to Uploads.
- A folder a file is being put in is held (FOR SHARE) until it lands; a delete of it waits and takes
that file home too, instead of failing on the foreign key.
- The hash route reads at most two files at once (429 past that) and holds no connection meanwhile.
- 0104's backfill dates old files by their request date only: no notice prod wrote names its job.
- The old DOWNLOAD_ROOT still finds the files; a files root that is not a mounted volume stops the
worker and is logged by the API; the volume-copy recipe looks the project-prefixed names up.
- Stale comments about the /downloads mount and the Downloads page.
A download that failed for days and then went through sat days down the library, under its
request date, and read as missing. A job now records when it became done (`ready_at`), stamped
where the expiry already is (`folders.stamp_done`, `finalize_if_running`) and on an insert that is
already done; a check constraint holds every path to done to it. The library sorts and dates its
"Added" by it; the queue keeps the request order.
The migration dates a file from before by its "ready to watch" notice where the inbox still holds
one, else by its request date, where it already sat.
yt-dlp asks for a bug report on an extraction failure it did not expect: the site answered in a way its extractor does not read (measured on a TikTok link: fails on every attempt under yt-dlp 2026.07.04, downloads under 2026.08.19). That failure read as "Something went wrong" with no retry; it is now its own category, site_changed, with a Retry in the failure window. A contract test holds every failure category to its copy in both languages.
- GET /api/files/{id}/preview: an allow-list on the stored extension (downloads/preview.py) picks
image / PDF / text; served inline under `CSP: sandbox; frame-ancestors 'self'`, the type from the
table; HTML and SVG arrive as plain text; a text is its first 1 MiB (X-Preview-Truncated), sent
as UTF-8 whatever it was written in (BOM, UTF-8, else cp1250); a NUL means binary: 415
- the list says which preview each file gets (asset.preview); FE/BE contract on kinds and header
- the viewer (shared dialog shell): ← / → through the list's such files in its order, Save, open in
a tab, the file's menu; one stage height for every kind; a no-preview card offers the file
- the name, the card's picture and the menu's Preview open it; an image up to 8 MB is its own
thumbnail
- The Files list starts at the scroller's top: the padding above it let the sticky header ride up
12px on the first scroll, then stop.
- Users -> Storage lists every user with their quota, not only those already holding a file — a
quota is set before anyone downloads.
- On a wide Files list only the "…" holds a place; Copy link, Share and Save come up over the date
while the row is hovered or holds the keyboard (a card keeps them in its line). Keeps is narrower.
- The Downloads module, its rail icon and badge are gone. A bookmark, the remembered page and an old
history entry that name it land on Files (urlState.resolvePage); `g d` goes to Files too.
- Users page: a Storage tab — the disk in numbers, every user's use against their own limit with
their file count, every user's downloads; aligned tables, one menu per row (edit / reset quota,
open source, copy, the owner's quota). The admin storage rows carry quota + file count, the admin
job list its owner.
- Settings: a Downloads tab — the download formats in one list (duplicate a built-in, edit or delete
your own) and the site sign-ins, moved from Account.
- "View" on an added download, a clip or a converting upload opens the tray on that row (unfolded,
back if closed; a finished one opens as a file; under a player it opens without taking focus).
- The download dialog opens on the format used last.
- DataTable: `pager={false}` for a short list, `cardRow` to give a lone "…" its own card line.
- Welcome and README speak of Files; a new Files screenshot.
- Files toolbar link box (paste button, Enter); a lone http(s) link pasted onto any page opens the one
app-wide download dialog, which now names the link; the palette's "Download from a link…" focuses
the box on its history entry.
- The download tray, bottom right on every page: what waits, runs, is paused or failed (reason +
Retry, the queue's right-click / "…" menu), and what finished in this tab as "Done · Open" until
closed; folds to a pill (per-account pref); closed, it stays shut — across a reload — until the
work changes or something finishes. Back to top steps up over it, the page end keeps clear.
- A finish is announced once: "Downloaded: …", whether a phone plays it, Copy link + Open (toasts
and notices may carry a second action).
- The inbox's ready notice opens the file in its folder: download_done carries job_id + folder_id.
- GET /api/downloads is the queue only (in flight, failed, finished in the last 10 minutes), with
each row's source key.
- Own folders are made, renamed and deleted from the rail (right click, long press,
Shift+F10) or the "…" beside an open folder's name; any folder's retention is a
submenu (7/30/90 days, until deleted). Deleting confirms where its files go.
- List or grid per folder, sharing one sort; the grid is the one media card.
- A file dropped anywhere on the page opens Upload with it, bound for the folder
on screen.
- A link (?item=) or the upload notice opens Files on the file: its page turned
to, the keyboard on it, a ring round it.
- An in-place name field left by Enter or Escape hands the focus back to what
replaced it (the row, the entry, the "…") instead of dropping it on <body>.
- The table's saved page size is per account.
Files is the one library: every done file, the ones shared with me, or one
folder's, picked in a side rail (sources, own folders, the storage meter) and
mirrored to ?folder=. One table with every kind of value in its own column;
Copy link, Share and Save in sight, every action on a right click: play,
cut, rename in the row, move to an own folder or back home, keep, details,
re-download, delete.
The Downloads page keeps only the queue until the tray replaces it. The old
/api/downloads library routes are gone: list shared, upload, the details
PATCH, delete, the file and the usage now live under /api/files, and a
listed file says which source it came from. The job's "expired" flag is gone
with the state it described.
/api/files is the library's home: list a user's done files (by folder, searched, sorted, or the
ones shared with them), rename / move / keep / delete a file, make, rename and delete own folders
(a deleted folder's files go back to their source folders), and set how long any folder keeps a
file — done files shift by the difference, never closer than the GC's warning window.
An upload can name its folder (an own folder or Uploads), checked before the body is read and again
before the file lands; it is hashed while it streams, and any file can be hashed on first ask. The
download says the hash (Repr-Digest) once it is known; a new file at an asset's path forgets it.
Uploads, deletes, keeps, moves and folder changes are audited (ids only). The fileless "expired"
rows of the old retention are deleted by migration 0103: an expired file now leaves the library.
The shared route helpers moved to routes/files.py; the old /api/downloads library routes delegate
to the new ones until the client moves over.
The Files library starts here. A user's every download, clip and upload now
lands in a source folder made per origin (YouTube, Plex clips, uploads, one per
other site), decided once in downloads/folders.py at every place a job is made.
How long a file stays moves from the shared asset to the user's job: stamped
from its folder when it is done, never for a file the user keeps. The GC warns
each user once, then takes the expired file out of their library; the cache cap
never evicts a kept file, and an evicted one leaves every holder's library.
The media mount is /files now (FILES_ROOT, FILES_HOST_PATH, volume
siftlode_files); docs/self-hosting.md says how to carry an old volume over.
Migration 0102 places every existing job and carries each done file's expiry
over from its asset.
safe_abs_path takes a missing path and answers None, which drops one baselined
type error.
The Files page replaces the Downloads page: list rows with one column per value kind and a
right-click menu, an app-wide download tray, a done notice that copies the link. Picked
variants are logged in DESIGN.md.
On a Windows host a burst of test traffic can drop a loopback connect now and then (every port,
any program — measured on the dev desktop). The browser resends it and only waits; Node (libuv)
sends a loopback SYN once and fails at ~300 ms with "connect ETIMEDOUT". `e2e/retryConnect.ts` wraps
Playwright's request context so that one error is tried again (0.5 / 1 / 2 / 4 / 8 s, the system's
own retry waits) while every other failure goes straight to the test; global.setup's health check,
login and /api/me and speed.measure's share probe go through it. Unit-tested; e2e 61/61.
A light theme's accent as a fill on the always-dark chrome was ~2.8:1 (Slate), under the 3:1 a
control needs. A native checkbox and slider tint with `accent-chosen` (the accent elsewhere, the
media amber under `media-marks`), and so does the skip button's countdown. The progress bar plays
white with an amber head: amber there marks a place, as the intro / credits markers do, and an amber
played part made a marker ahead of the head read as already played (owner's choice of three).
The underline named `--color-chosen`, which is resolved once on :root, so it stayed the accent under
`media-marks` (seen in the browser: fuchsia on Slate light); it reads `--chosen` where it is used
now. An icon button pressed or with its menu open tinted its glyph with the accent; it takes the
chosen colour — the accent everywhere but the player chrome, where it is the media amber.
yt-dlp's `max_filesize` only stops a plain HTTP file that announces its size: YouTube's fragmented
formats never meet it — measured on localdev, a 718 MB download ran to the end with 1 MB left and
put its owner four times over the cap. The download now re-reads what is left once its file is made
(after the phone-safe pass) and refuses one larger than that as the owner's refusal; an editor clip,
which nothing checked, is held the same way, as a player clip already was.
A light theme's accent as text on the always-dark chrome was 2.84:1 (Slate). `--chosen` is the
accent unless a scope names its own; `media-marks` (was media-focus) points it, with the focus
ring, at the media amber. A chosen track stays white with an amber tick (MenuCheck reads
`text-chosen`); a chosen settings tab or toggle is white with an amber underline (MEDIA_CHOSEN).
The theme contract test holds the token, the tick and the player components free of accent text.
yt-dlp refuses a file announced over `max_filesize` by logging one line and returning with no file
(measured, and pinned in test_formats against a local server): the worker found no output and
failed the job as `postprocess`. `formats.watching_size_cap` now sees that line through our yt-dlp
logger, and the download (and, should it ever happen, a YouTube clip's range fetch) fails as the
owner's refusal with DOWNLOAD_TOO_BIG_MESSAGE, classified `quota_exceeded`. The download's quota
copy says it does not fit in what is left — true at a full cap too — instead of "Storage full".
A pre-existing type error the change touched is fixed (baseline down by one).
- useTimecodeField holds a time field's draft for its caller, which places TimecodeError (role
alert) in its own flow under the fields' row: it no longer lies over the crop row taking its
clicks, runs out of the editor's card, or joins the field's <label> name.
- The editor's remove-cut buttons sit in a row under the track at their cut (trackLeft), off the
cut's grab area, where a press drags it; RangeTrack's adornment slot is gone.
- media-focus re-points --focus to the media amber on the Plex player, the YouTube stage and every
bg-media-chrome surface; a contract test holds each such class string to media-focus or
media-scope, the field's focus edge to the focus token, and --color-focus to the inline theme.
- An upload's quota headline says the converted file does not fit, not "Storage full".
- DESIGN.md: the tokens paragraph says what is re-pointed where; the decision row measures all
three colours on the chrome.
- A converted upload over the space left (not zero) fails with CONVERTED_TOO_BIG_MESSAGE, not
"storage is full"; it classifies as quota_exceeded by the phrase it shares with the clip's.
- The public watch page says "preparing" only for a job a worker will take: a suspended owner's
queued job waits for as long as the suspension lasts, so the link says the video is gone, as
for a paused rebuild.
- The worker's claim docstring names the restart case: a running job put back in the queue waits.
TimecodeField drops a draft when the value moves under it (another segment selected, a nudge), so
leaving the field never commits it to another cut; it is invalid only once offered, with a line
under it saying how to type a time. On the always-dark chrome media-scope points every focus ring
at --media-mark and errors at the new --media-danger (a light theme's accent and danger were under
3:1 there). A track whose dragged handle unmounts ends the drag on the pointer's release instead of
locking up; a remove refused while clips are queued leaves no focus to take later; each remove-cut
button names its cut and is a 24px target. DESIGN.md records the media tokens; stale comments fixed.
A suspension now blocks the user's downloads as quota.resolve already advised: the worker leaves
their queued jobs unclaimed until it is lifted (the confirm says so). A cut clip larger than the
remaining storage is refused with its own message (not "storage is full"), and a clip's quota
refusal reads as a clip's in both languages. The cancel poll compares to JobStatus.RUNNING.
- A media amber (`--media-mark`), the same in both themes, for what marks a place on the always-dark
chrome or over a video: the clip strip's playhead and focused end, the crop frame's ring, Plex's
intro / credits markers. A light theme's warn was a dark amber there, under 3:1 (contract-tested).
- The editor's remove-cut button rings inside itself (the track clipped its ring), and once it goes
with its cut the focus moves to the cut beside it, or to Split here, not to <body>.
- A mark on the very end seeks a moment short of it; while a clip is marked the end of the item
neither closes the player nor steps the queue, and Plex's auto-skip waits.
- Plex: Escape and Back peel what is over the picture (info, up-next, the countdown) before the
clip; Back under a dialog is the dialog's; no range opens while the length is unknown.
- YouTube: the focus goes back to the scissors the user can see, else the player; the panel keeps
its name, Loop and focus when the view is maximised or restored under it.
- A typed time stays in its field until taken, then shows where it landed; unreadable stays marked
invalid; a decimal comma is read (ui/TimecodeField, the editor's fields too).
- RangeTrack: a drag is its pointer's, a cancelled drag reports final, an adornment sits beside the
slider, a caller-chosen focus ring (amber on the clip strip). CropFrame: a drag is its pointer's.
- The editor's remove-cut is a real button at the cut's foot; a segment's keep toggle sits over the
cut's hit area.
- The cut file is measured against what its owner has left, for a YouTube range (whose fetch never
reads yt-dlp's size ceiling) and for a Plex clip (which fetches nothing), before the fetch and
again before it is placed; a refusal leaves the asset pending for a Retry.
- A silent Plex file is cut silent; a file the probe cannot read says so.
- A second job on a failed or renamed clip gets the same YouTube diagnosis and the fetched title.
- The "too long" refusal names the server's cap; a contract test holds the client's copies of the
clip limits to clip.py; the shipped player clients are pinned against the installed yt-dlp.
- One HDR transfer set and one rating_key lookup; job status words from JobStatus; the display-name
guard sees subscript writes and the clip route; stale comments and types follow the clip job.
The handles reach 10 px into the frame from every side; a frame at its minimum on a small picture
(the Downloads editor at 800 px: 41 x 23) was then nearly all handle - 74 of its 920 px still moved
it. Below 64 px on its shorter side the frame takes 12 px corners and 8 px edges: 262 px move it.
The press is held back from the page (no text selection, no play / pause underneath), which also
kept it from focusing: after a drag the arrows still went to whatever had the focus before, and
Escape did not reach the frame. The press now focuses the frame or the corner it grabbed (an edge
focuses its frame).
An optional crop in clip mode: a Crop toggle puts a frame on the picture (free, 16:9, 9:16 or 1:1;
the whole picture stays the default). The frame is sent in fractions of the box it was drawn in,
with that box's shape; the worker fits the real picture into the box by its display aspect (sample
aspect and rotation aware), drops the part over the bars, and crops first in the filter chain. A
frame covering the whole box is no crop at all, and one off the picture is refused by name.
The crop frame is a ui primitive (CropFrame) shared with the video editor, keyboard-operable.
YouTube: the wide side panel shuts while clipping and comes back after; maximised, the clip panel
docks in the stage. Plex: the picture sits above the clip panel so the frame lies on it.
The backend refuses a clip with a structured reason, but the client showed every one as the generic
"couldn't make the clip": each reason now has its own message in both languages, held to the
backend's reasons in both directions by the contract test. A segmented toggle's and the priority
stepper's buttons had no type, so inside a form one of them submitted it.
Both players get a scissors in their control row (Plex: the control row; YouTube: the actions and
the docked bar), `c`, and `[` / `]` to mark at the playhead. The clip panel lives in the player, so
the watched video is the preview: an overview of the whole item and a zoomed strip around the
selection whose ends are sliders (arrows step a tenth, Shift a second), Start / End fields with
nudges and "to the playhead", the length, a looping Play selection, a name, Cut. Escape closes the
panel before the player and the focus goes back to the scissors. A cut is a toast with a Downloads
link plus the usual Ready notification.
- ui/RangeTrack + RulerTicks: the shared, domain-free time track (regions, keyboard-steppable
handles with a 1.5rem hit area, a playhead, a background slot, over a window of the item). The
Downloads editor's cuts moved onto it, so they are keyboard-steppable now.
- lib/clipRange holds the marking rules (1 s to 10 min, the server's), lib/timecode the editing
timecode the editor's private tc/parseTc became.
- keymap: a symbol typed through AltGr answers its bare-symbol row - on the HU layout `[` is
AltGr+F, which arrives as Ctrl+Alt; letter and Mod rows still never match Ctrl+Alt.
- The YouTube stage sits in a wrapper with the panel, so the maximised stage's blur escape matches
at any depth.
- media-scope: fields on the always-dark player chrome read white in a light theme too.
A clip fetched its range signed out, so a video behind YouTube's per-video "not a bot" wall could
never be clipped while its download went through with the user's cookie - and the failure copy told
the user to add a cookie the clip would not use. The clip asset is private by construction
(`clip.asset_ref` names its user), so `usable_clip_cookie` attaches the user's YouTube cookie on that
identity, and a failed range fetch now carries the download catalog's diagnosis
(`youtube.bot_wall` / `youtube.cookie_rejected`) instead of a bare category.
POST /api/downloads/clip takes a source (a YouTube id or a Plex rating key), a range of at most
ten minutes and, for Plex, the audio track being heard. The clip becomes a per-user asset in the
library like an editor clip, shareable as soon as it is ready. A YouTube clip fetches only its
range (yt-dlp download_ranges: 3-7 s whatever the video's length); a Plex clip reads the library
file in place. Both then go through one re-encode: frame-exact at the marks, H.264 at most 1080p
with HDR tonemapped to SDR, stereo AAC, no chapter track, .mp4.
Job kinds are a JobKind StrEnum now, and Re-download refuses every kind but a download.
The primary player client, android_vr, was still offered 1080p and 2160p, but every fetch of
those formats died with HTTP 403: it now needs a stream PO token the bgutil sidecar cannot mint.
The fallback set then served 360p only, so every download finished at 360p without failing.
tv_simply is offered the full ladder and its fetch completes (measured: 1080p, and a localdev
download through the pipeline reached the default profile's 720p). yt-dlp 2026.8.19 marks
the clients that need such a token, so it stops fetching into the 403.
Turning ready, the pointer leaving the video, the window regaining focus, a wheel over it and
leaving fullscreen each put the focus back on the card unconditionally, so a viewer who had
tabbed to a control right after opening started over from the first button on the next Tab.
They now take it back only when it sits in the iframe or off the card.
int() refuses a decimal run past 4300 digits, so one such reference in a summary raised out of the sync and the text migration. Any number past seven significant digits is beyond U+10FFFF, so it becomes U+FFFD like any out-of-range reference; leading zeros are dropped first. The rails test's comment now says what it guards: both rails agree with the rollup.
Focus had scrolled the clamped box itself; opened, the link dropped by that much, out of a scrolling panel's sight. DESIGN states where Close sits in the stacked layout; a stale test comment goes.
Send order let a refetch read before an unanswered mark committed and drop it. A reset's position 0 is written again after a refetch that set out before the server took the reset. The player-session refetch also waits for the player's closing checkpoint, which the player now tracks (progressFlushes); refetched first, the list put the older position back.
The ↻ intent was spent as its fetch set out, so a watched toggle's resync cancelling that fetch re-ran it as a re-read of the old draw. It now lasts until the latest deal answers. The button spun on every re-read of the same titles; it now follows the deal. Comments that said the Surprise draw is never refetched are brought in line.
Both Continue-watching rails built a show's card state inline; they now take it from rollup.show_state, so a show part watched with nothing to resume offers Play alone there too. A named reference counts only when its whole name is an HTML5 entity: html.unescape still decoded the legacy "¬" inside "¬ice;". Tests cover the grid's in-progress-but-not-resumable case through its SQL and both rails.
Show cards carry resumable (ShowState, from the same rollup) so a part-watched
show whose next episode is untouched offers Play, not a Continue and a Restart
that do the same thing; plexStarted is the one rule for cards and menus. The
Surprise rail's key sits under the discover root: whatever refreshes the rails
re-reads the draw (keep=) with its watch state now, kept past the 5-minute gc,
and only the reshuffle deals. Closing the player refreshes the Discover rails.
A show card's play acts on the last click only, and only where it was made.
With the side panel shut the title heading was gone, and the dialog it named
went unnamed: the strip carries an sr-only copy. A link below a clamp's cut
stayed tabbable out of sight; reaching it now opens the text, measured from the
text's top since focus scrolls the clamped box. DESIGN describes the modal as
built, with log rows for its chrome and the wheel rule.
html.unescape also decodes the legacy forms without a semicolon, so prose like
"AT¬ice" came out as "AT¬ice". The show and collection sync paths are
now tested too.
A reset wrote a position mask over the cached list, so a replay's saved
position stayed hidden: a reopen resumed at 0 and its first checkpoint wrote
over the progress. The reset now writes 0 into the cache, where the player's
closing patch writes the next one. Overrides carry their write order, and a
landing refetch drops only those written before it set out. The player
session ends after the modal unmounts, so a clip auto-marked watched as it
closes still refetches the list.
The rail's query was always stale, so Back from a card's player or page remounted it into a new
draw, as did a return to the window: the card just looked at was gone. It now stands until the
reshuffle (or another scope, language or filter) asks for a new one.
A show card's picture opened the show page everywhere but Home, whose shelves carried the
resolved `next`. Now every Plex picture plays: a show asks `GET /api/plex/show/{id}/next`
(the in-progress episode, else the first unwatched, else S1·E1 — the rule Home and the show
page's Resume follow) on the click, fresh each time. A started show shows Continue + Restart;
its title, an ⓘ and the menu's "Open show" row open its page; one with no episodes opens there.
The question is asked per show, never carried on every card: three probes a show would cost a
grid page up to 180 queries. The hover preview asks it once it opens and names the episode
("Next: S6·E16 — Title"). Related shows on a show page play the same way.
Back from a channel page hands the card its focus again after a mouse trip;
revealed on focus-within, its hover row stayed up under no pointer until a
reload (owner). The row, the Play layer, the Continue + Restart pair and the
global .reveal rule now show on hover or on :focus-visible on the card or
inside it, so Tab and the arrow keys still reveal it.
Marked watched in the player (by hand, or by the player at its end), a card
left an Unwatched list at once, then came back as "new" when the player
closed, until a reload. The player's closing position patch writes into the
cached feed; the feed took the bumped dataUpdatedAt for fresh server data,
dropped its optimistic overrides, and nothing refetched. The overrides now
clear only when a refetch lands, and a player session that changed a state
refetches the list and its count by the view's filters on close, once its
writes have landed.
Under "Open the channel" a video card's menu (the right-click on its
channel name included) adds the channel to the feed's channel filter, or,
when it is already in, takes it out. Channels OR together, so it widens a
filter that names others, as "Show in feed" does. Not on a channel page,
whose view is that channel already, nor in a live YouTube search or on Home.
- The full-height previous/next strips beside the card are gone: the panel's
transport row and Shift+arrows step on their own, and the video gains the
7rem the strips' gutters took.
- Close is one icon in the card's top-right corner (the wide panel's head,
the stacked card's title row).
- The panel's description is cut to three lines with More / Less
(ClampedText now takes the linked text as children).
- Wide, the panel shuts to a 2.75rem strip (close, open, previous, next) for
a bigger video; it stays shut while the queue steps on, and a video opened
from a card starts it open. Focus moves to the counterpart toggle.
- The wheel has one meaning per place: over a panel (or the stacked card)
with something to scroll it only scrolls, never falling through to the
volume at the end; over the video, or a panel that fits, it is the volume.
- A long channel name is cut on the panel's line instead of running the
20rem panel into a sideways scroll.
The media variant's hover darkened its body from 60 to 75% black, which no
one could see on a poster; a rail's scroll arrow over a card could not be told
from the card's own Play under it. Its hover is now a 2px accent ring over an
80% body (owner pick B), on every card control, Plex and YouTube, and the rail
arrows. DESIGN.md B3 names it beside the edged pills, with its log row.
A card title lit like a link but played the item. Now the picture plays and
the title opens the page of what it names: a movie's or an episode's info
page, a show's page, and for an episode standing as its show (Recently
watched) the show. Every poster and episode card carries it (grid, rails,
person, collection, season, related, Home), a card with no page keeps a plain
title, and the season page gains the menu's Details row too.
The episode page had no way up: the detail now serves the show's and the
season's rating keys, and on the page the show title opens the show and
"S1·E25" the season (Back returns to where the page was).
The rail draws three unseen titles per kind under the sidebar filters; on a
small filtered pool every reshuffle dealt the same few again. The redraw now
sends the draw on screen (exclude=), the server deals the rest of the pool
first and tops up from the excluded titles only when the pool is short, so a
small pool still fills the rail. (The two discover.py baseline entries went
with the rewritten draw.)
A Plex agent can hand a summary over as HTML (<p> paragraphs, á
entities); the info page and the hover card showed the tags. One cleaner,
app/plex/plaintext.py, now runs on every summary and tagline the sync, the
Hungarian overlay and the rich-detail fetch store, and migration 0101 cleans
the rows written before through the same function. Paragraph breaks survive
as newlines, which the info page and the hover card now keep.
Rows that just mounted join the menu's navigation list a render after the status effect ran: a row
focused that early was unknown to it, and the arrows did nothing from it (seen live after a keyboard
Retry). The retry test now arrows on from the row it lands on.
When Plex made the collection and only the read after it failed, the kept row had no members: the
title showed unticked in the collection it had just been added to, the collection empty, and the
name stayed in the field for a retry that could only be refused as taken. The seed's row now names
the collection (child_count 1), and the panel clears the field on that refusal as on success. Also
held: the panel's lines sit beside its list, and a keyboard Retry lands on the first row.
The loading / failed-load lines and their Retry sat inside role=menu, which may own its items only:
the three surfaces now open with `listInside` and the rows sit in a PopoverList. A keyboard Retry
unmounted the focused button and left focus on <body> with the menu open, the arrows dead: focus is
put back on the first row, else the field, else the new Retry. The channel tag editor said a failed
tag load as "no tags yet"; it reads the catalog query's state now. The playlist panel's name and the
tag editor's error state are held by tests.
The rollback dropped the new row: the next sync recreated it read-only, out of every 'add to' list, and the duplicate-name check let a retry make a second same-named collection on Plex.
The row menu called the title's action list without the anchor the panel opens beside, and the optional parameter dropped the row silently. The anchor is now required, so every host menu decides.
Three hand-copied panels had drifted: Add to playlist said a failed load as 'no playlists yet'. ui/MembershipMenu now holds them: a loading line and an error with Retry instead of a blank or an empty list, the up/down arrows step from the name field into rows that arrived after the menu opened, and the panels opened from a menu carry their own accessible name. A write that landed flips its tick at once, so a second tap before the refetch undoes it instead of repeating it; a collection edit also refreshes a show's own page.
A direct-played file's own subtitle stream (WebKit lists an mp4's mov_text) was switched to hidden and drawn under any choice, Off included: only the player's <track> is kept hidden and read, other subtitle tracks are switched off. The lift was measured in screen pixels and applied inside the x1.25 windowed player, and re-measured off a box carrying its own (or a mid-slide) transform: an untransformed outer box is measured, the distance divided by the scale, and a cue that just appeared is placed, not slid from where the last one was left.
hls.js emptied the cues of every text track on the element when a manifest
loaded or the media attached, our own <track> included, so a VTT that loaded
first showed nothing for the session; its subtitle and caption controllers are
no longer built. A remounted default <track> was switched to showing by the
browser after the hide had run, drawing the cues natively under ours; the hide
now follows every added track and mode change.
"Add to collection…" opens a panel beside the card: the title's library's
manual collections tick it in or out, and a name + Enter creates a new one with
the title in it. It used to take the Collections page, a new collection, its
name and a title search, then another search per title. A lean endpoint lists
the collections with whether each holds the title. Admin only, as on the
title's page.
Also: a collection seeded with a show is made as a show collection (Plex type
2). Measured: as a movie one it came out empty and refused the show after.
On an iPad the bar covered the text whenever it was up: the browser places a
native cue by its line, which nothing animates. The track now stays loaded as
hidden and PlexSubtitles draws the active cues, measured against the bar and
lifted above it in 200 ms, back down when it hides. The viewer's size, colour,
background and outline settings style the text directly. Also fixes a test
typing slip from the search pill change.
On touch the 44 px key cap and Go filled a 44 px pill edge to edge and the
placeholder was cut. The key cap is left out on touch (⌘K still works), the
pill grows to 52 px so a ring shows round Go, the box's floor counts the
controls beside the text, and Go drops to its icon by the column's width,
not the screen's.
Brave on iPadOS switches element fullscreen off, patches requestFullscreen with
a video-only stand-in and fakes fullscreenEnabled, so the Plex fullscreen button
and the YouTube docked bar's did nothing. Support is now read off
Element.prototype and the buttons are left out without it (owner). A stand-in
that returns a boolean no longer throws. A test's bare matchMedia stub no
longer outlives its describe.
On an iPad a tap's synthetic mouseenter opened the Tooltip; WebKit then took the
tap as a hover and swallowed its click, so every rail button needed two taps,
and with no mouseleave from a finger the caption stayed up. Tooltip now opens
for a mouse and a key's focus only (owner: no captions on touch).
iPad WebKit kept a scrolled container's mask stuck to its content: at the
bottom of a scrolled page the last strip, a table's pager, sat under a dark
band. With nothing to fade there is no mask; isolation keeps its layer.
On an iPad a drag on the video scrolled the page behind the player, and in Brave
dvh did not follow the toolbar, so a strip of the page showed under it. The
page is now locked without overscroll on a black root while the player is open,
and the player is sized from its fixed box. The time row keeps the position,
the length and the end clock; the countdown is gone (owner).
On a narrow card (a tablet, a zoomed window) the unwrapped row of name, meta and
actions left the meta a one-word column. The row now wraps: the meta keeps at
least 12rem, and the actions move down together.
Measured: across the full width YouTube needs only its seek bar (up to 96 px
from the bottom); the 140 px is the fullscreen button's, at the right end. A
full-width 140 px band handed clicks on the picture to the iframe, which then
takes the keyboard focus and our shortcuts with it. The layer is now two boxes,
hidden from assistive tech (a mouse surface; the keys are its shortcuts), and
the tests render the big view too, where the docked bar sits under the iframe.
The click layer over the embed left the bottom 22 % of the stage free. YouTube's
new embed keeps its fullscreen button 80-128 px above the iframe's bottom, so on
a stage under ~580 px (a small window, a browser zoomed to 150 %) the layer lay
over it and a click paused instead. The cutouts now have px floors (56 top, 140
bottom) and are measured against the iframe's own box.
The cards, the up-next card and the mark-next label printed a question
mark where Plex has no number; the player's header already showed the
title alone. One helper (episodeNumbers/episodeName) for every place.
The stop-URL contract passed with the two ids swapped and read the backend
from the working directory; nothing called a real beacon's onSettled or its
library pass-through; the kept-point rule was held by its constants only.
The auto-skip countdown and the up-next card pushed an entry without a
user gesture (Chrome's Back button skips those), and one pushed under the
open shortcuts sheet sat above it, so Back closed the hidden layer.
Leaving the Plex player while one of its layers was up (the info panel when
the film ended, the countdown under the back arrow) landed as a user Back:
the overlay listener spent it closing the top layer and the player stayed.
useHistorySubview's open/back now go through closeOverlaysThenNavigate,
like setPage. The RECLOSE comment no longer cites the shared Plex entry.
Chrome does not hand the page the Escape it spends on leaving fullscreen (checked with a real
keypress: the first Esc leaves fullscreen, the second closes the player). The guard defended a case
that does not happen, under a comment saying it does. This reverts 121ebeeb.
The page gets the Escape keydown with the video still the fullscreen element. When that element is
the dialog's own content (the library player), closing the dialog took the player away with the
fullscreen. A dialog opened over something else that is fullscreen still closes.
ItemProgressIn repeated ProgressIn's config, fields and bounds; it now adds only `final`. The
threshold comment points where the constants live now, and plexProgressBeacon's comment is back
on it.
- The client's resume thresholds equal the server's (read from both sources).
- The session stop's URL matches the route the server serves it on.
- The player stops its server session by its item and its own id on an unload and on close,
and keeps it when the page only goes into the back/forward cache.
All the layers shared one history entry, so the first Back spent it on one layer and the second
left the player with the stats readout (or the countdown) still up. Each layer now holds its own
entry; Back closes the one opened last.
A poll already out when the player closed answered with the point the server held before the
closing beacon, over the row's patch; Continue then opened there and wrote it back. The close now
cancels the listings' fetches before patching and reads them again once the beacon has landed.
- The engine tracks the request out per item: A, then B, then back to A sent a second request
for A beside the one still out.
- A hardware transcode superseded during its fallback answered with the NEWER session; the losing
request's player then stopped it as stale. It now answers with its own (dead) session.
- A replaced hls.js instance's late manifest no longer marks the player ready or plays the
outgoing media.
- A pause given after the answer stands: the manifest and the first canplay read the live intent.
- The debounced A/V-offset restart is dropped on an item change instead of restarting the
previous item under the next one's title.
The portal host followed the fullscreen element into it. When that element is the dialog's own
content (the library player's <video>, by its f key or its own button), the move is into the host's
own descendant, which the DOM refuses: HierarchyRequestError in a layout effect, and the crash
screen replaced the app. The host now stays where it is then — the content already shows.
Vite's own answer was a bodiless 500, which the app reads as a real server fault and shows the blocking error dialog; a 502 is the passing connection loss it is.
Chrome keeps 50 entries a tab, and when the list is full a new entry drops the
oldest one the page left without a user gesture, which can be the page's own
entry under a sub-view or an overlay. A programmatic back() then left the
document; once it landed on an older entry whose restored Plex player started
playing on its own. Every programmatic back() now asks the Navigation API first
and, with no entry of this document below, settles in place: the sub-view turns
to its root, an overlay's entry drops its mark, the channel page and the YouTube
search clear.
An item can carry several credits blocks with mid- and post-credit scenes between
them, and Plex flags the one that really ends it. The player left at the first
block. Now the Plex-final block moves on (by the auto-skip or the Skip button), an
earlier block is only skipped to its end by the button, and with no final flag the
last block ends the item by the button alone.
A cached file that autoplays from 0:00 fires its one playing event before the resume hook
subscribes, so the hook never counted it as played and reported nothing - not on pause, not on
close. An element already playing when the hook subscribes now counts as played.
Plex, library and /watch tell the operating system what plays (title, line, picture) and take its
play / pause / skip / seek through one hook (lib/mediaSession); the Plex player drives it with its own
seek and clock, so the lock screen shows the film's position, not the stream's. The YouTube player is
left alone: its iframe already publishes all of it, and the OS routes to the frame that plays.
The library and /watch players get the basic keys (lib/mediaKeys, the keymap's mediaPlayer rows) and
open where the viewer stopped (lib/resume): the library keeps the point on the server, per user and
library item (library_states, POST /api/downloads/{id}/progress, listed back on both listings), the
shared link in this browser. A started library row continues, its menu can start it over, and its
picture carries the cards' progress bar. The resume rule (too early / near the end = from the start)
is one function shared with the YouTube route.
The library dialog opens focused on its panel (Modal initialFocus "player"): on the ✕, Space closed
the film. While the media element itself has the focus, Space and the arrows stay its own controls'
— the browser acts on them whatever a listener does, and one Space played and paused.
The ⋯ menu, the tracks menu, the tuning panel, the info overlay and the up-next card were a flag
each, so two could stand on top of each other, and Back/Escape were written out three times. They
are one `panel` value now; the stats readout stays a separate toggle beside it. One `back()` peels
the countdown, the panel, then the stats, then leaves — for the arrow, ⌫, Escape and the mouse
Back alike. The mouse Back now also closes the ⋯ menu instead of leaving the player.
PlexPlayer's session logic (generation, pending play intent, clock ownership, session offset, reclaim
budget, listener teardown) moves into lib/hlsEngine.ts, a plain class with hls.js behind lib/hlsPort.ts
and its own unit tests.
- A seek, track change or reclaim asked for while a session request is out replaces the queued
target; it is sent once the in-flight answer settles. A burst of 20 presses past the buffer was 20
requests and 20 ffmpeg spawns; it is now 2-3, and plays again ~2 s sooner.
- The player stops its server session when it leaves the item, closes or the page unloads
(POST /api/plex/stream/{rk}/session/{sid}/stop, identity-aware). A left transcode used to run to
the end of the film first.
- A restart asked for after a new session attached but before it played no longer reads the emptied
media as the viewer's pause.
- The fast join gives each ffconcat entry its own media-only format whitelist, so a library file
can no longer be opened as a nested concat list (the list's whitelist was handed down to it).
- A file the guarded probe cannot read is a download-only file, not a guess from yt-dlp's info.
- A conversion's or an ingest's commit that raises is re-read in a fresh session before any file
is removed: a commit that landed keeps the file its row now names.
- An original that cannot be removed after conversion is logged; the upload stays ready.
- An upload whose conversion is cancelled or deleted midway is freed once nothing holds it; a
paused one is kept.
- A shared link says "preparing" only while its video's job is queued or running.
- The admin storage view counts every file on disk ("Files on disk"), not only ready ones.
- A clip's failure title, unknown failure and cancel dialog, and a clip's and an upload's delete
dialog speak of what the job made; an upload's quota refusal explains that the original is kept.
- /watch announces "preparing" as an alert and moves focus to the new Retry after an action.
- The raw-source contract tests read files from disk (rawSources.testkit)
instead of import.meta.glob, which knip counted as importing every src file —
its unused-files lane could never name an orphan. knip now names
src/watchMain.tsx as an entry (registered in docs/gates.md).
- vite build fails when index.html or watch.html is not emitted.
- The watch-entry import walk follows dynamic import() too.
- audit_metrics.py's plan-code patterns are held equal to the guard's.
- CrashBoundary is domain-free: it moves into components/ui.
The 36rem floor guards the stacked layout's width/height feedback loop; the
wide side-panel card has no such loop, and on a short wide window the floor
made the video taller than the card, cutting off YouTube's control bar. The
floor now applies to the stacked layout only; e2e covers a 1400x380 window.
- Cancelling an upload asks "Delete upload?" and says the uploaded file is
removed from the server with it.
- The phone-safe conversion's real ffmpeg % shows as a bar ("Converting for
phones"), not an indeterminate pulse.
- A failed upload is titled "The upload isn't ready", with its own reasons
(convert_failed, retryable; upload_missing) instead of download copy.
- The inbox says "Upload ready to share" for a converted upload.
The public watch route answers video_preparing for an asset that is pending or
downloading, and the page shows "being prepared — try again" with a Retry
instead of "this link is gone". watch.html ships one lang; the page now sets
documentElement.lang to the language its strings were picked in.
- Every ffmpeg/ffprobe over a library file names the demuxers it may use
(edit.INPUT_GUARD; a downloaded thumbnail edit.THUMB_GUARD): a playlist-type
demuxer would read local paths named inside a user's upload.
- A re-encode ends on an even frame (edit.EVEN_FRAME): an odd 4:4:4/RGB source
failed its conversion; a clip stores the frame its file has.
- An upload counts against its owner's cap from the moment it lands (queued, or
kept after a failed conversion) — the footprint, the GC's cap and the admin
view read one ON_DISK predicate — and a conversion that outgrows the cap is
refused, keeping the original.
- The converted name keeps _a{id} (a dotted bare name once lost it and two
uploads shared a file); a converted file placed but not committed is removed,
and the orphan sweep enters .uploads; the LRU cap never evicts an upload.
- Ingest judges the extension the file is stored under; a failed conversion is
convert_failed / upload_missing, reports the "converting" phase, and notifies
as an upload. Job statuses in routes/downloads use JobStatus.
Two layout bugs since the player grew a side panel, both reported from prod.
Maximised (F) on a wide window, the video was a thin line across the screen: the side-panel layout
aligns the stage `self-start`, and Chrome applies a flex item's self-alignment to an absolutely
positioned box too, so the fixed stage took its content's height — 0 px. The maximised stage now
stretches between its insets whatever the layout asks of it.
On a short window under 1280 px (a laptop at 100%, a 1080p screen at 175%) only the side strips were
left: the card narrows so the chrome under the video fits, but that chrome grows as the card narrows
(its text wraps), and the loop ran the card down to 2 px. The video now never goes narrower than
36rem to make room; past that the card scrolls.
Measured before/after (localdev, real YouTube iframe): 1097×520 open 0×0 → 610×343; 1920×911 maximised
1920×0 → 1920×911; 1440×760 and 1366×650 maximised fill the window; leaving maximise restores the card.
Held by an e2e test at both sizes (red on the old code: 0 px wide, and 911 ≠ 0), green on Chrome,
WebKit and iPad.
A link serves an asset once it is ready, so the contract is pinned on both sides. The code: every
function in app/ that marks something ready must run the rule (`_ensure_phone_safe`, or the
upload's own `playable.plan`) — an AST scan names any that does not, and fails blind if it stops
finding the four paths known today (download, editor clip, upload conversion, upload kept as is).
The bytes: the files an iPhone + iPad refused (HEVC tagged hev1, AV1, Matroska, MP3 inside an
.mp4) and the ones other browsers cannot be trusted with (VP9 + Opus, 10-bit H.264, Opus in Ogg)
go through the upload route, the worker's conversion and the public file route; what the link
hands out must be served as mp4 / m4a / mp3 and pass the rule.
Seen red: a planted ready-marking function in service.py is named; with the conversion step
short-circuited, all seven files fail (on the rule, or on being served as quicktime / webm / ogg).
A /watch link is opened by whoever it was sent to — signed out, often on a phone on mobile data —
and it booted through main.tsx: every app provider, the app's i18n (every locale of every area),
the flag sprites, ~392 KB gzip before the player could start. It now has its own HTML entry,
watch.html → watchMain.tsx, which imports WatchPage and what it renders; the server hands every
/watch/ path that page (the OG block injected as before), and vite dev does the same.
Measured on the build: /watch JS 290 → 87 KB gzip, CSS 104 → 19 KB; the app's own bytes unchanged.
Two leaks had to go for that: lib/format imported the app's i18n for one "just now" string (the
relative-time formatters now take `t`, like the file's other t-formatters), and the boundary's
crash copy came from the same i18n (CrashBoundary takes its copy; ErrorBoundary is the app's
wrapper, the watch entry gives it the page's own strings). The page's popover tree went too: it
renders no popover.
Held by: a contract test walking the entry's runtime imports, naming the path to anything only the
app may load (red on a planted i18n import in lib/format), and holding watch.html's head equal to
index.html's; a DB-lane test that every /watch/ path gets the player's page with the link's OG tags
and app paths get the app (red on the old routing). Screenshots at 375 px, EN and HU, video / audio /
password / gone / wrong password / unlocked: identical to the old entry's, bar the video's buffered bar.
A share link is opened on whatever the friend holds. Measured on an iPhone and an iPad: AV1,
Matroska, HEVC tagged hev1 and MP3 inside an .mp4 fail there; Chrome and Firefox add HEVC and
10-bit / 4:4:4 H.264. The old check judged the codec NAME only and ran on downloads only, so a
10-bit H.264 passed, an upload went out exactly as it came (an iPhone's HEVC .mov, a webm, an mkv)
and a fast trim kept its source's codecs.
One rule now, `downloads/playable.py`: video is H.264 8-bit 4:2:0 + AAC in .mp4, audio-only is
.mp3 or AAC .m4a; `plan` says what must change, `build_cmd` re-encodes only the failing stream (or
remuxes). The worker applies it to a download, an editor clip and — new — an upload: media that
fails the rule is queued (asset pending, job queued) and the worker converts it, keeping only the
converted file (owner's choice). Until then the original sits at rel_path, so Retry starts from it
and a delete removes it; an upload that lost its last holder mid-conversion is reaped by the GC.
Every libx264 run (edit, concat, conversion) now pins `-pix_fmt yuv420p` — without it a 10-bit
source came out High 10. The download cache signature moves to v5 so a cached 10-bit asset
re-derives. The upload toast says when the file is still converting (HU/EN).
Tests: the plan for every measured class; real ffmpeg turning a 10-bit Matroska + Opus and an
mp3-in-mp4 into files the rule accepts (red without the pixel-format pin); the queued upload's
delete, cancel-then-GC, failed conversion and real worker conversion (the cleanup tests red
without their rules); the toast (red without the branch). Live on localdev: 9 fixture uploads
converted, every served file probed H.264 High yuv420p + AAC, or mp3 / m4a.
A plan's decision ids (`D1`, `D4`, `D6-A`) cited in 11 comments and test titles named a document
no reader of this repo has, and the guard had no pattern for them. It has now (mirrored in
audit_metrics), with cases for each form the citations took and for the look-alikes it must leave
alone (a JPEG marker, "3D"); the 11 lines say their why without the id. Case test seen red without
the pattern, the scan red on a planted `D9:`.
It spread the event's snapshot, which still carried the sfNav stamp lib/history's own listener had
just taken off the entry, so a later landing there (an overlay closing) refocused a stale item. It
now spreads the live entry (test red on the snapshot). Also: format.test's comment named a test
image that never runs vitest; it is the gate host's locale.
The promotion revoke guessed from today's SMTP state whether the password was trustworthy, and ran
only when the account was adopted. Both guesses failed: a row verified while SMTP was off stays
"verified" after SMTP is configured, and a later sign-in matched by Google identity promoted the
account with no revoke at all. Either way a stranger's password and session could become admin.
Now one rule, where the promotion happens: when ADMIN_EMAILS turns a non-admin account into an
admin and it holds a password, the password goes and every session ends; the owner, signed in
through Google at that moment, sets a new one in Settings. An account that already is admin keeps
its password. Tests: the activated squat, the SMTP case, the later sign-in (all three red without
the rule) and an admin's own sign-in.
The chat cut removed the only WebSocket and CI went before it, but comments still leaned on both:
`?account=` was justified by the socket, job polling by an in-process WS registry, the e2e flows as
"proxy-safe (none use the WebSocket)", and pyrightconfig named a CI container the engine contract
test never checked. Each now says what holds today (the wallet gate, no push channel, the two
contract test halves, the image build's `npm ci`, the gate).
The sprint alternative matched only `(S6)` or `S3e2`, so the commonest purged form (`# S6:`,
`S3's`, `"""S6 —`, `S1 justified`) and every phase code (`P1`) would come back unflagged; the phase
codes had in fact survived the purge in 16 lines (Plex client sections, the upload's header shape),
now reworded around them.
One pattern now covers epic, roadmap, sprint and phase codes; the named exceptions are TV episode
notation, a fixture's season or episode title (`title="S1"`) and ruff's `E4/E7/E9`, and a ruff rule
like `S110` does not match. The case test lists the purged forms and no longer claims every form.
audit_metrics copies the definition (ASCII, as JavaScript's \b is) and reports it as
plan_code_lines / plan_code_files.
The fallback rendered the feed but left the history entry and the address bar on the dead page, so
a Feed click was a no-op (the same-page guard), filter mirroring kept `?page=messages` and the nav
diagnostics logged a page that does not exist. The entry is now replaced with the feed's, keeping
every other URL key (test red without it).
The never-activated rule left one order of events open: on an instance without SMTP, a stranger
who registered an ADMIN_EMAILS address (or a sibling spelling) and was then activated by another
admin kept their password and live session, and the owner's first Google sign-in raised that row
to admin, the stranger's cookie with it. Activation vouches for access, not for who chose the
password, and without SMTP nothing else ever proved the mailbox.
When ADMIN_EMAILS promotes an adopted account that holds a password on an instance without SMTP,
the password goes and the session epoch is bumped. With SMTP the verified flag is a proven mailbox,
and an account the sign-in does not promote keeps its password, so the "your password was cleared"
detour stays off the default path. Tests: the activated squat (red without the rule; the squatter's
session is signed out) and the SMTP case that keeps the password.
The registration language is read today (preferences.language, the mail language); the Modal and
the players order against popovers through layerStack, not the FloatingTree; the upload's auth
dependency is current_user; the module of access tests covers onboarding and access, not only the
canonical email. The dev proxy's WebSocket upgrade served the removed messages socket and goes; the
test compose header says a pure-logic run needs SIFTLODE_TEST_REQUIRE_DB=0; the speed measure no
longer reports an unreachable database as "no share link".
- planCodes: the quote and slash lookarounds let the commonest form through (a docstring or test
title opening with a code, a slash pair); the two real exceptions (an episode titled "E1", ruff's
E4/E7/E9) are now named and cut first, with cases that fail on the old pattern. audit_metrics
counts by the same definition.
- button-primitive: both file lists live in the rule body, out of sight of the snapshot and the
register; each is now pinned to its exact set of ui/ files (red when either is widened).
- check.sh --brief shows a short log whole and keeps the line before each hit, so ruff's rule and
eslint's file path survive the excerpt.
- lint-changed runs prettier over every changed frontend file, as the gate does, not only code.
- CLAUDE.md and gates.md no longer claim the basedpyright baseline was found harmless: it was never
triaged, and the reportOptional* entries are the likeliest real bugs.
0098 deleted the reset job's run rows but not the scheduler_interval rows that changing its interval
wrote; with the job's label gone they rendered as a raw id. The delete now takes every job-targeted
row for it, and a test seeds both kinds below 0098 and upgrades (red on the old filter). The test
has its own file so the removed-features guard can name it.
A history entry pushed while the Messages page existed still names it after a reload onto the new
build; popstate took sfPage unchecked and the shell's module lookup threw. It is now validated with
isPage, like every initial-load path already was.
Without SMTP, registration marks the address verified with no proof, and an ADMIN_EMAILS /
ALLOWED_EMAILS address gets no invite row to gate it. So a stranger who registered such an address
(or a sibling spelling) before its owner kept their own password when the owner's first Google
sign-in adopted the row, activated it and made it admin, and could then sign in with it.
The adoption now treats a never-activated row like an unverified one: the password goes,
password_revoked_at is stamped and the session epoch bumped. An account already let in keeps its
password. The helper is renamed to say what it now judges (revoke_untrusted_preset_password).
test_google_callback.py drives the callback's account-matching branches through the real route,
none of which a test ran before: the squat (red on the old rule), adoption by mailbox, the
sub-collision and unverified-address refusals, the refused sign-in recorded in Google's locale, and
the link flow's conflict, mismatch and attach answers.
planCodes.contract.test.ts scans every tracked text file and every new one not yet added (comments,
docstrings, test titles, lint messages alike) for an epic or roadmap code (`E28`, `E9.5`, `R14`) and
for a sprint or review round cited on its own (`(S6)`, `S3e2`, `round 4`, `deep-review`). Not
`E2E`, not a ruff rule family (`E4/E7/E9`), not a TV episode (`S2 · E5`, a test episode titled
"E1"), not `S3h` (the media-card decision docs/DESIGN.md is headed by). Markdown and the Alembic
migrations keep their history and are not scanned.
One sweep test rather than an ESLint rule plus a ruff one: the codes sat in Python, TS, CSS, shell,
TOML, YAML and lint messages, and no single linter reads all of them. Seen red on a planted
backend/app file (all three plan-code lines named, its allowed line not).
audit_metrics.py counts epic codes by the same definition (it used to miss `E9.5` and `R6`), and
CLAUDE.md's "comment the why" rule names the test.
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").
Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.
Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).
One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
The seven gallery shots dated from 2026-07 and still showed the old
labelled sidebar with the removed Messages item. Retaken at the same size
(1200x675 @2x, dark) from the localdev stack with the E2E account (no
personal account name on screen), filled for the shoot with a copy of a
real account's subscriptions, playlists and finished downloads, removed
again afterwards.
The DB lane provokes constraint errors on purpose, and their ERROR: lines
from the testdb container came first in a red pytest excerpt, ahead of the
failing test (seen in E35 S3). The excerpt now skips the testdb-N lines.
Buttons, text fields and native controls, glass surfaces, the native
<select> and network calls are each refused by a lint rule whose message
names the primitive to use (button-primitive, input-primitive,
surface-role, no-restricted-syntax, no-restricted-globals). Their table
rows repeated that, so a change had to be made twice. One paragraph now
says they are lint-held and lists the primitives. The rows nothing
mechanical holds stay, as do ORM and current time, now test-held.
CLAUDE.md's canonical table names SQLAlchemy 2.0 select() and an inline
datetime.now(timezone.utc), and both had drifted: five db.query(...) calls
remained in routes/auth.py and routes/me.py (seven before the demo went),
and four private now helpers stood in sync/videos.py, utils.py (imported
as _now by explore and maintenance), scheduler.py (_now_iso) and the e2e
seed. All are the canonical form now.
Ruff cannot ban a method call on an instance or a function by name, so
tests/test_canonical_patterns.py reads every app module's AST and fails on
a .query(...) call or a now/_now/now_utc helper — seen red on a planted file.
E34 read the standard-mode findings and none was a real bug, so working the
baseline down as work of its own buys nothing. The lane is unchanged: it
fails on a new error, and on a run that tightened the baseline without the
commit. CLAUDE.md and the register row say so.
gates.snapshot.json listed ~800 file paths under their config, so every
new or deleted frontend file regenerated it (31% of commits, E34 S2) and
the regen diff buried the gate changes it exists to show. Now a scope is
named by path (scopeOf: src, src tests, ui, lib tests, …), the snapshot
holds one effective config per scope (2345 → 1456 lines), and a new test
fails when a file's config is not its scope's.
Not narrowed: a new file in a known place changes nothing (it gets its
scope's config, checked), a glob that moves one file into another config
fails (seen: an override on Feed.tsx → "src: src/components/Feed.tsx"), a
scope whose rules change is a snapshot diff, and a file no config lints
is still a register miss.
Both lanes printed a coverage total with no threshold (owner decision E32
D4). No real hit as a lane: the one time the number found anything (5 bugs,
E32) it was read by hand, and since then it was a line nobody acted on. It
was also the most expensive part of the vitest lane, and the run that
flaked in E35 S1. The vitest lane runs without --coverage, the pytest lane
runs plain pytest; docs/development.md gives both on-demand commands, and
the register row says why.
Two rules looked at the same <button>: button-primitive refused a raw one
outside the control primitives, focus-safe-disabled refused a raw
`disabled` one everywhere but FocusSafeButton and AnchoredPopover. They are
one rule now, with the same reach: outside the primitives a raw button is
refused (a disabled one with the focus message); inside a primitive and in
a test only a raw `<button disabled>` is, and the two native-disabled owners
are named in the rule rather than in a config override. One override block,
one rule entry and two register rows go; the snapshot loses a config group.
focusSafe.contract.test.ts pins every case, the primitive and test ones new.
BOX_PX and GRID_MIN_PX were two esquery patterns with two messages for one
rule (DESIGN.md: a px box does not scale with --font-scale). They are one
alternation now, with one message that names both fixes. Nothing it refused
before passes now; pxBox.contract.test.ts pins both shapes unchanged.
Both came out of the E28 claim audit (0521149c named z-levels, e13d93a3
static labels as flat Badges) after the audit itself had fixed the sites.
No real hit since: 0 matches in every lint run from E28 to E34 (S2/S3
measurement). Each still cost a probe test (zLayer.contract, the edged-label
half of hoverAndLink.contract) and a place in the gate snapshot. The design
rules stay in DESIGN.md (Tokens: no ad-hoc z-index; Static vs pressable),
with a decision-log row saying the selectors went and why.
react-hooks/exhaustive-effect-dependencies, memoized-effect-dependencies and
rule-suppression were switched on in E32 "for the plugin version that wires
them up". Read in the plugin source and probed (E32 review): the first's
validation is compiled off, nothing emits the second's category, and lint
mode hands the third no suppression rules. 0 hits by construction — a rule
that cannot report reads as coverage it does not give. The comment keeps
their names and why, so a plugin upgrade can re-check them.
The production pass (7ed2ac4e, E32) asked "is this export used by what
ships?". No real hit: its only findings were 37 exports a test imports,
answered with 36 `@internal` JSDoc tags (E34 S2) — test-only exports, not
defects. Since then it has only ever asked for another tag. The tags go
with it (36, no longer read by anything), and so do the production-only
`!…!` exclusions in knip.jsonc and their register rows. Plain knip (unused
files, exports and dependencies) keeps running in the gate.
sourceSize.contract.test.ts, its test kit and backend/tests/test_source_size.py
held 13 FE / 9 BE file ceilings and 64 FE / 32 BE function ceilings. No real
hit (E34 S2/S4): its history is its own birth (751e1ae5), two rounds of
fixes to its own logic (e432dc27, d1df76e8), the snapshot move (182ec311)
and ceiling edits inside feature fixes (5806adda, df4a79a1, and the S2/S3
cuts here). It caught no defect, went red 8 times on its own limits and drew
5 review findings about itself. The numbers stay visible: audit_metrics
reports file size and complexity, and an epic compares before and after.
docs/gates.md: the two size rows go; the C901 row says where it is read now.
Its pattern also matched "demote" and the Hungarian "Demokratikus", so
the footprint read 5 after the demo was gone (and 56, not 53, in the S1
baseline). It now uses the removedFeatures contract test's pattern.
The demo account — one shared user that whitelisted emails entered from
the login page, reset on a schedule — goes end to end (owner decision,
E34): demo.py and the reset job, users.is_demo and demo_whitelist
(migration 0098 deletes the demo user, its audit rows, the reset job's
override and notices, then drops the column and table; the downgrade
restores the empty schema), the /auth/demo entry, the admin whitelist and
reset, the Demo tab, the banner, the login page's "Try the demo", and
every is_demo branch in routes, userscope, scheduler and the UI.
require_human only ever refused the demo account, so its 87 uses become
current_user (owner-approved); require_write_scope keeps its own check.
The login page's "or" divider now shows only beside the Google button:
without the demo entry under it, an instance without Google would have
said "or" to nothing.
A contract test (removedFeatures) fails on the word anywhere in the
backend, frontend, e2e or scripts — seen red on a planted hit. Size
ceilings tightened for the modules that shrank; admin.py is back under
the limit.
E2EE direct messages, the chat dock, the Messages module, the message
WebSocket and its realtime registry, and the messages / message_keys
tables (migration 0097; the downgrade restores the empty schema). Prod
held two welcome notes, no message between people and one key — never
used (owner decision, E34). WebSocket now has no owner module: the lint
bans it everywhere. Privacy policy updated: it no longer promises E2EE
for a feature that is gone.
POST /auth/request-access had no caller left: the login page's form was
gone, and registration and a refused Google sign-in already land in the
same admin approval. A third public write path with no protection of
its own (owner decision, E34).
Its one guard moves to where every path passes: upsert_pending_invite no
longer opens a pending row for an address the env already lets in. The
registration path lacked it — registering b.ob@gmail.com beside an
env-granted bob@gmail.com opened a pending row, and is_allowed answers
from the invite rows once any exist, so the real owner was locked out.
The tests that drove the endpoint now drive registration or the shared
function.
Under a full coverage run on every core, a test file's first cold import
(0.6-0.8 s alone) outran the 5 s case timeout and the half-done import
failed every later case in the file: the vitest lane went red in 2 of 5
runs with no defect. A beforeAll warm-up in the 4 files of that pattern;
5 of 5 full runs green after.
The scheduler row read 'quota reaped 0' — the raw key, humanised — because the new result key had no
label (found in the browser check; nothing tests result-key labels).
It always said a live search spends the shared API quota, while the default source (the website's
own endpoint, scrape) spends none — the owner avoided a free search because of it. /api/me now names
the running search_source, and the tooltip names the cost under it: none for scrape (the enrich step
costs a unit or two), 100 units a page for the official API.
quota_events had no retention: one row per API spend plus a zero-unit row per live search, kept
forever. The daily history cleanup (audit_gc) now also prunes it past quota_retention_days (a new
Quota setting, default 365 so the admin usage view's longest window stays whole; 0 = forever). The
floor of two days keeps today's rows, which the per-user search cap counts.
The admin page's download_worker_concurrency and log_level were stored but never reached the worker:
it read the env once at boot (worker.py) and never looked again. The worker now re-reads both every
10 s (_sync_knobs): extra loop threads start at once, surplus ones retire after their current job, and
a changed level is applied to the process. An unreadable database keeps what already runs.
The Gitea Actions gate repeated, after the merge, what a release already runs
before it, and its runner shared the git host's container: one run there
starved Gitea of memory until it stopped answering. The lanes now live in
scripts/check.sh, run the same by the release dispatcher and by hand, and the
gate-contract test holds its lines (the ruff and pip-audit checks read it
instead of the workflow). The two audits only CI ran join the gate: pip-audit
over the lock (pinned in the test image, blocking at any severity) and npm
audit of the production dependencies. The engine contracts drop the CI
containers; the register records that pushes have no server-side gate.
A form dialog opens on the body's first control, and the composer's first
control is the Edit / Preview switch above the form: the focus landed there,
at open and when a retried load brought the form, and typing went nowhere. A
form can now mark the field to open on (data-initial-focus); every other
dialog keeps its first control. The Modal's comment on the re-armed watch now
says where the focus goes when the focused control is removed but others stay.
YouTube answers the uploads list of a channel with no uploads as
playlistNotFound. The recent and deep backfills raised on it, so the channel
was never stamped and was picked again, charged a quota unit and logged an
ERROR on every run. Both now read it as an empty last page, as explore already
does; any other failure still raises.
A mirrored library that answers "0 items" is not believed (its rows stay), but
that verdict stopped the whole reconcile: one library emptied on Plex with its
mirrored items kept made every run incomplete, and un-watches in every other
library never synced again. Each section now settles on its own walk, and only
the settled sections' rows are cleared; the unsettled ones are named in the
warning.
The register test detected narrowings by modelling config shapes, and each
review round found one more shape it did not model (a basePath global ignore,
a rule src never declares, a widened glob filed under a narrow row, a substring
check on a CI line, knip.json beside knip.jsonc). It now compares instead:
gates.snapshot.json holds ESLint's effective config for every code file and the
lines that run each lane, and any difference fails until it is regenerated.
The register is read off the snapshot's configs, which surfaced the ui/ and
lib/ test scopes the sampling never reached.
The complexity ratchet also names a wrapped function by its binding
(const onSave = useCallback(() => ...)), not as "useCallback callback".
A library left out of the allowlist is never scanned, so its watch states were cleared on
every reconcile; clearing is now limited to the sections walked. A section we mirror items
from that says it holds 0 is not believed complete. The page size is no longer read as the
section total when totalSize is missing (measured: the server always sends totalSize on a
paged request, 0 for an empty one).
playlistNotFound was mapped to "gone" everywhere: exploring a channel without uploads (its
uploads list answers not found) and a list deleted mid bulk pull both said a playlist no
longer exists. The reason is now "gone" only on single-playlist routes; explore reads it
as an empty back-catalogue, and the bulk pull skips that list and syncs the rest.
The watch stayed armed when the form arrived while the focus was off the close button, so a
later change in the body pulled the focus off a close button the user went back to. It now
disarms on arrival and re-arms only when the focus is taken away.
rebase updated the state only, so the mirror the callbacks read still held the removed
row and an undo right after it wrote that row back for a moment.
ESLint leaves an arrow bound to a const or a property unnamed, so three ratchet keys were
(anonymous) and ranked by position again. A tiny rule reports each unnamed function's
binding (const, property, assignment, else <callee> callback) at the head complexity
reports, and the keys line up by location.
Round 2 of the E32 review found the key whitelists blind: a line regex saw neither a
[lint.per-file-ignores] table nor a quoted key, and pytest.ini's continuation lines and
key: value form slipped past. Ruff's config is now parsed (smol-toml) and held to its
key paths; pytest.ini to its whole lines; the CI ruff steps to --config backend/ruff.toml,
which is what makes that file the whole configuration.
ESLint: every glob of a block must reach a sample (reactPlugin.ts and devOnlyGuard.ts
rode along unseen), a block's own severity is held to src, a no-restricted-* list is held
to the src block's declaration rather than Feed.tsx's effective config, named global
ignores count, an ignored sample is named, and linterOptions are checked. tsconfig.node
must keep allowJs + checkJs while it lists a .js file; knip's positive glob is pinned.
ruff.toml ignored E402 everywhere for main.py's logging setup. Measured: 28 hits, of which 11
had nothing to do with it — runner.py's logger line between its imports, models.py importing
canonical_email mid-file, a test constant above its imports, and main.py's own third-party and
mimetypes imports. Those are moved up. What stays after configure_logging in main.py is marked
per statement (noqa E402, logging is configured first), its comment now says why the order
matters (a line logged while importing, not the logger's creation). The register row goes, the
ruff key whitelist loses ignore, and a late import is red again. The T20 comment counts the
third deliberate print (scripts/e2e_seed.py).
scripts/check-node-modules.mjs and gen-changelog.mjs run in check and release but sat outside
every lane: the eslint and prettier lanes run from frontend/, and an ESLint config cannot reach
past its own directory. A contract test now holds them to the js-configs bar (recommended rules
under Node, the frontend's Prettier style); an unused variable and the old formatting are each
red. 0 lint hits; both files reformatted. Registered in docs/gates.md.
eslint.config.js called every zero-hit React Compiler diagnostic a tripwire. Under
eslint-plugin-react-hooks 7.1.1 three cannot report anything: exhaustive-effect-dependencies (its
validation is compiled off), memoized-effect-dependencies (nothing emits its category) and
rule-suppression (lint mode passes no suppression rules). Probed: a missing effect dependency is
reported by exhaustive-deps only, and an inline disable is named by nothing. The header's
"33 suppressions" of exhaustive-deps is 0. reactPlugin.ts said one react-hooks rule was still
off; none is (three run at warn, which --max-warnings 0 fails).
The Plex and feed filter fixtures were typed as the filter type itself, whose scalar filters are
optional: a new optional field compiled without a test value, so its wire key went unchecked.
Both are Required<…> now (a probe field fails typecheck on each). The feed set gained the fields
it omitted (year, librarySource, the scope and show switches) and goes out with and without year,
which wins over the other date filters; a misspelt year key is now red in the wire check, which
passed it before.
The ceilings were a per-file ranked list, so one function could grow by what another in the same
file lost: Home.tsx's [34, 32] passed with Home at 32 and Hero at 34. Both twins now key every
offender as module::function (an unnamed one is (anonymous); a repeated name gets 2, 3 in source
order) and re-baseline at today's measured values. Swapping two ceilings is red on both sides
(Home 34 > 32; feedquery filtered_query 28 > 14).
The ratchet measures shipped code only. docs/gates.md said C901 covered backend + scripts; it
covers backend/app. The exclusion of tests, test kits, e2e and scripts/ is now a row with its
measured hits, and the stale counts are replaced (9 BE modules over 800, not 11).
Leaving the Playlists page and coming back painted the selected list empty ("0 videos") while
the rail counted its rows. The page re-seeds its undoable order from the cached list in an
effect; StrictMode runs that effect twice on the remount, so a reset was followed in the same
tick by a rebase. Since E32 S3 a reset is state only and the rebase read the mirror ref, which
still held the empty initial history, and wrote it back over the reset.
rebase is now a state updater, so it builds on whatever an earlier call in the tick installed.
Both tests seen RED (hook: [] for [10, 20]; page: no rows), and the bug reproduced in the
browser on the old hook.
E32 review round 1 (T1–T5, s1). The test that holds docs/gates.md to the effective configuration
could itself be passed by a narrowing it did not look at:
- ESLint: besides each scope's sample, every config block is read on its own. A block that targets
files no sample reaches fails as an "unsampled block"; a no-restricted-* list re-declared without
an entry src has, an option that lets something through (allow…/ignore…/except…), a block-level
`ignores` and EVERY global ignores object (not only the first) count. A scope switching a src rule
at warn off now counts too (it skipped them). Two live narrowings surfaced and are registered:
no-floating-promises' allowForKnownSafeCalls (src and e2e) and the three network owners'
no-restricted-globals.
- TypeScript: a strict sub-flag set false beside `strict: true`, an extends/exclude/files, a changed
include, and a frontend-root config file no project checks. eslint.config.js is that file: listed,
with the 43 errors strict checkJs reports in it (measured, none a defect).
- Ruff, basedpyright, pytest and knip are read as key whitelists (an unknown key fails until someone
decides whether it narrows); pyright's information/hint count as weak; ruff's extend-select is
pinned; pytest's three keys are pinned exactly; knip's script must run both modes.
Each new check was probed with a planted narrowing (an unsampled block, a block ignores, a second
global ignores, a warn rule off in a scope, strictNullChecks false, a ruff extend-exclude, a pytest
-m) and failed on it.
E32 review round 1 (F3–F6).
- Channels' open-row store and the chat dock use lib/store's createStore instead of a hand-rolled
listener set (the chat dock gets a test of its own).
- The Plex backdrop reads prefers-reduced-motion through useMediaQuery; the query string is one
constant (REDUCED_MOTION) for the hook and the carousel's one-off read.
- A link-restored collection filter showed its raw rating_key in the picker while the titles
loaded: usePlexCollectionTitles now says it is pending, the picker draws a placeholder
(labelsPending, as the channel picker does) and so does the chip row.
- The "deleted tag" rule was written twice with two i18n keys and had drifted: the channel manager
showed NO chip for a pick while the tag list loaded, or ever after a failed load, while the pick
still narrowed the table. One rule now (lib/tagPicks), one key (common.deletedTag); the manager's
row shows every pick, as a placeholder while loading and as its id after a failure.
E32 review round 1 (F1, F2).
- Issue composer: the review's focus-return token became state in S4 and was never spent, so every
Preview → Edit round trip remounted the attachments and moved the focus from the toggle to the
log's Review button. The token is cleared once it has been used, as the ref was.
- Modal (initialFocus "field"): the late-form watch moved the focus to the first control once and
stopped. A failed load's Retry then gave way to the spinner, its focus fell to <body> inside the
aria-modal dialog, and the form's first field never got it. The watch now re-arms when the focus
falls to <body>: it waits on the ✕ and moves to the first field when the form arrives. Focus in a
dialog stacked on top is left alone.
E32 review round 1 (B7). Each phase took its own now; phase 2's "not checked this run" test then
let a just-recovered video through and spent another videos.list call on it.
E32 review round 1 (B5, B6).
- The autotag orphan cleanup reads every user's feed views. A view whose tag pick was not a list
("tags": 5) raised for everyone's job, True passed as tag id 1, and ~66k ids pushed the NOT IN
past psycopg's 65535 bind parameters. The views are now read defensively and the kept ids
filtered in Python; the saved-views route holds filters.tags to a list of at most 500 ids, on
create and on update.
- tags_by_category resolved any tag id, so another user's private tag landed in its real category
and the feed summary answered differently than for an id that does not exist, which told the
caller which foreign ids exist. It is owner-scoped now (pre-existing; S2's missing-tag branch
made the difference visible).
E32 review round 1 (B1, B2, B3, B8).
- Plex watch reconcile: a section the server SAYS is empty (totalSize 0, nothing listed) is a
complete listing. It read as "cut short" forever, so one empty library stopped un-watches from
ever syncing. A listing with no total at all stays incomplete (the client now returns None for
it instead of 0), and a whole scan that listed nothing still clears nothing. Tests now cover
completeness across two sections, both orders.
- Plex collections: no members from a collection whose listing counts some (childCount) keeps
the membership we hold, in the full sync and in the targeted resync after a write.
- YouTube playlists: no items for a playlist whose listing counts some (itemCount) keeps the
mirror in the bulk pull, and is refused as retryable in "reset to YouTube" and in the push
plan/merge, where it read as "YouTube deleted them all". The rule sits beside vanished().
- A playlist deleted on YouTube answered "reconnect your account"; it now says it is gone
(errors.youtube.gone, HU+EN).
- The pure 3-way merge moved to sync/playlist_merge.py; playlists.py is back under 800 lines and
off the size list.
E32 S4 — the burn-down from 29 skipped functions to 1:
- try/finally (26 sites): the cleanup runs after a try/catch; steps that return early moved into
their own function (push, the setup wizard's next, the share page's unlock).
- A conditional, loop or optional call inside a try skips the function too, and react-hooks/todo
does not see it: the value is computed before the try, or the try lives in a module-level helper
(config writes, clip queueing, clipboard, frame counts, seekable end, YouTube player times).
- The two exhaustive-deps suppressions are gone. useYtProgress takes its per-item snapshot through
one effect event, read once when the player is built; PlexPlayer's unmount relies on the
leaving-the-item cleanup for the generation bump it duplicated.
- What the skips hid: PlexPlayer's 17 render-phase ref writes (useLatest), its tracks popover alias,
the loadSession self-reference (declared early, set after commit); PlayerModal's session-position
read during render (now where the player starts), navRef/modeRef writes, and a description helper
that ran a ref-carrying callback during render (now PlayerDescription); SavedViewsProvider's
latest-cfg ref, and its setState in the seed effect (preferSticky now prefers the starred default
among same-filter views, so the seed needs no sticky write — test seen red on the old helper).
- A store's hook was called `use()`, which the compiler does not know as a hook: inside the newly
compiled usePlaylistActions it was memoized, ran on the first render alone, and the playlist rail
threw "rendered fewer hooks" on its second render. Renamed to `useValue()` everywhere; the old
contract test claimed the call was safe inside any `use…` function, which was false — replaced by
a render test that is red under the old name.
- PlexPlayer's palette ref was written and never read since the Backspace guard moved to the layer
stack: removed.
react-hooks/refs is back at error with no exception. The ratchet lists only VirtualGrid
(IncompatibleLibrary) and pins both players as compiled. No size or complexity ceiling was raised.
react-hooks/refs 245 -> 31 hits and 93 -> 29 skipped functions in the register and the eslint
comment. The canonical-patterns table gets the latest-value row (useEffectEvent; a handler or timer:
useLatest / useStableCallback, never a render-phase ref write) and the popover / scroll-fade row
(destructure the ref setter).
E32 S3. The compiler skipped 93 functions; it now skips 29 (try/finally shapes, two suppressions,
VirtualGrid). react-hooks/refs hits 245 -> 31.
- Popover engine: `ref={pop.refs.setReference}` made the compiler treat the whole popover handle as
a ref, so every component holding one was skipped (21 call sites, the engine and its surface
among them). The setter is destructured now; the same for useScrollFade and useReturnFocus.
- Render-phase `ref.current = x` writes moved to `useEffectEvent` where only effects read them,
else to the new `useLatest` (written in a layout effect) / `useStableCallback`.
- FocusSafeButton (under every Button) read the focused element during render; it reads it through
useSyncExternalStore, which React asks on every render instead of a cached memo.
Bugs this surfaced, fixed:
- Combobox: a chip could keep showing its raw id. The label cache was a ref an effect filled after
the render that needed it; it is state merged during render now (test seen RED).
- useUndoable rendered from a ref plus a forced re-render; compiled, that memoizes a stale value.
The history is state now, mirrored in a ref for the stable callbacks.
- ConfigPanel and the saved-views editor re-seeded their drafts in an effect, painting the stale
draft for a frame; both adjust during render against the remembered key.
- Toaster's handover is written after commit instead of during render; a new test pins that the
inherited set is taken once, at the takeover (seen RED on a per-render re-read).
- IssueComposer mutated a ref prop as a one-shot focus token; it is plain state.
- PlexBrowse wrote its info-scroll memory from an effect declared before the ref, past its own
"one writer" rule; the writer comes first now and the effect goes through it.
The compiler ratchet drops to the measured 29 and now also requires FocusSafeButton and the popover
engine to compile.
FastAPI ignores an unknown query parameter, so a misspelt or renamed key silently stops a
filter while its chip still shows. Contract tests read routes/feed.py _feed_params and
plex/query.py LibraryFilters. The superseded feedCount/facets calls and keys are removed.
It rendered nothing until its quota arrived and nothing at all when the load failed, so the
click looked dead. The first field still takes focus when the form arrives.
The missing identifier became server:/// in the write URI. Client tests over a MockTransport
(errors, paging, cutoff, write URIs, image transcode) and admin-route tests (connection test,
enabled-section sampling, no server URL in a 422, the one-time watch import).
A failed config fetch or a keyless consent page cached the fallback InnerTube key for the life
of the process; once it went stale, search returned nothing with no error. The fallback is now
used for that call only. A 200 JSON without a result frame returned an empty page that read as
no results; it is a ScrapeError now. Parser, cursor and Shorts-probe tests added.
A saved view or the stored filter outlives its tag. feedquery dropped the unknown id, so the
view showed the whole feed under its old name. The id now lands in a MISSING_TAG_CATEGORY that
filters to nothing in the SQL and in feed_summary's mirror alike; the feed chip reads "Deleted
tag" (a placeholder while the tag list loads, the id if it failed). Channels' persisted tag
filter gets the same chip, first in the row so the +N overflow cannot fold it away.
A card inside the viewport can still sit under the chrome above the scroller (the update
banner); the covered click made Playwright scroll and retry, moving the feed the scroll-restore
test then measured. webkit failed 3 in 24 before, 0 in 48 after.
The YouTube playlist guard from the previous commit put sync/playlists.py over its size ceiling;
the rule now lives beside should_prune as vanished(existing, listed), which the mirror calls in
place of its own list. The pyright baseline also drops by one (187), from the plex/overlay split.
Feed views store tag ids. A system tag no channel carried for a while was deleted and, once a
channel earned it again, recreated under a new id — the view's filter then pointed at nothing, the
feed dropped the unknown id, and the view showed the whole feed under its old name. The cleanup now
skips tags a saved feed view references (test seen red without it).
lib/e2ee was 14% covered and fails silently in every way it can break. Real WebCrypto and an
in-memory IndexedDB: a message decrypts for its partner and nobody else, every message gets its own
IV, a new device unlocks with the passphrase and refuses a wrong one, the key survives a reload
until reset, a partner's rotated key is used only after forgetConv, and the key check never shares
the wrap IV (GCM nonce reuse). Mutations seen red: the shared IV, a no-op forgetConv, no persist.
The catalog prune and the subscription import have long refused to reconcile against an empty
listing that contradicts what we hold (should_prune: a 200 with no items is a blip far more often
than a user who removed everything). Two mirrors did not: the YouTube playlist mirrors (dropped
every mirror) and the Plex collections (dropped every collection with its user-set `editable`
flag, and every member's keys — the guard landed in plex/sync.py with the previous commit). Both
now hold the same rule; test_empty_listing_guards covers each way (seen red).
sync/maintenance (19% covered) gets a lifecycle test: recover, grace, delete + one notification
per user, live stream kept, abandoned premiere. It found the rolling revalidation re-fetching the
same videos until `checked` reached the batch (15000 by default) whenever fewer videos were
available — quota spent on answers it already had; a run now skips what it has checked.
The daily watch reconcile reads "absent from Plex's scan" as "Plex un-watched it" and deletes the
row — but it walked the sections without asking _paginate whether the walk reached the server's
total, so a Plex hiccup mid-walk (an empty page early) un-watched every title it failed to reach
until the next complete run. The scan now reports completeness; an incomplete one still pulls in
what it saw, re-pushes dirty rows, and clears nothing (logged as plex.watch.reconcile_incomplete).
The HU overlay had the same shape: clear every row's HU text, then refill from the listing. It now
reads the listing first and clears only after a complete one. The overlay moved to plex/overlay.py
(plex/sync.py drops under the 800-line limit and off the size ratchet's list).
Tests: plex/paths (23% -> 100%: prefix boundary, longest prefix, dead mount), the reconcile's
clear/keep/re-push rules, the cut-short scan and listing (each seen red with the guard removed).
CastStrip (a show's page) and PlexInfo (a title's page and the player's overlay) each drew the
cast carousel by hand, and the copies had drifted: the gap, lazy pictures, the name's tooltip, the
initial's size. Both now render CastRow, which takes the overlay's white-on-art palette as a tone;
each caller keeps its own frame and heading. A DOM test holds the palette per variant (seen red
with the tone wired to "panel"). jscpd over the shipped code: 1 clone >= 10 lines -> 0.
The 7 baselined Optional accesses in plex/stream.py were one fact the type did not carry: the
initial spawn (replace_only=None) always supersedes, so only the fallback respawn can get None.
An assert at the call site says so; the pyright baseline drops 195 -> 188.
test_abuse_limits: the public-URL and YouTube enqueue tests stopped at the profile lookup
(400 no_profile) and passed on that unrelated refusal. They now send an inline spec and assert
the job is queued. The same shape — a request whose response is discarded — sat at 53 more
places across the suite (all returned what they should, measured with a TestClient probe);
each now asserts its status, and test_response_checked keeps it that way (seen red).
E31 put the admin pages behind one rail row ("Administration", nav-admin)
that opens a panel. The spec still clicked nav-scheduler, nav-config,
nav-users and nav-audit as rows of their own, and timed out on every engine.
That went unseen from E31 to now because e2e ran in no required gate, the
exact hole E32 closes: the first run of the new release e2e step found it.
The rail test keeps the rail rows. A new test opens each admin page from the
panel and checks that the group row is current and that the reopened panel
marks the page. Seen red before the fix; the whole suite then passes, 52/52.
docs/gates.md lists every place a gate deliberately checks less than it
could, each with its reason and the count measured when the row was
written:
- an ESLint rule off, at warn, or not applied, by scope;
- a weak compiler flag, a ruff ignore, the basedpyright mode and baseline;
- a knip exclusion, a lane that does not run everywhere;
- an accepted CVE.
gates.contract.test.ts reads the EFFECTIVE configuration and fails when a
narrowing has no row:
- ESLint's computed config per scope;
- the three tsconfigs, ruff.toml, pyrightconfig, pytest.ini and knip.jsonc;
- .trivyignore.yaml, where an entry also needs a statement and an expiry;
- CI's pip-audit ignores.
It also pins `--max-warnings 0`, which is what makes a warn-level rule fail.
Seen red on a missing row, a new `off` rule and an allowlist entry without
an expiry.
CLAUDE.md gains the rule E32 exists for: a gate with any real hit is never
switched off or narrowed, and an "enforced by" comment is checked by running
the check. The gate descriptions there, in docs/development.md and in
backend/app/README.md name the lanes as they now are, and the "directly"
recipe points at the full list.
The "class size" item from the quality checklist, measured:
- A shipped file over 800 lines may not grow past its line count today
(13 frontend, 11 backend). No other file may cross 800.
- A function over the complexity limit may not rise, and no new one may
appear. The limit is eslint `complexity` 20 on the frontend (64 functions
over it today) and ruff C901 10 on the backend (32).
- Each is a per-file ceiling. Shrinking is free; an entry back under the
limit must be deleted, so the lists only shrink.
C901 is measured in a test rather than switched on in ruff.toml: ruff has no
per-function baseline, and a noqa would let a function grow without limit.
Coverage (owner decision D4) is printed with no threshold:
`vitest --coverage` (v8) gives 80% of lines, and the pytest lane ends with
the total for app/, 78%. The bar stays "every regression test seen red".
.coverage is ignored by git and kept out of the image's build context.
The first Trivy scan of the shipped image (0.98.0) found four fixable HIGH
advisories. One was pcre2: a Debian fix newer than the base snapshot, now
taken by `apt-get upgrade` at build time. The others were urllib3 (bumped in
the lock), and msgpack, setuptools and urllib3 vendored inside pip itself.
pip is removed once it has installed the lock, since nothing runs it at
runtime.
A local build of this Dockerfile with --pull scans clean: 0 HIGH/CRITICAL
with a fix, no secrets. .trivyignore.yaml is the publish gate's allowlist,
empty today. Each entry must carry a statement and an expiry date.
The server gate adds `vite build` (and with it devOnlyGuard, which until now
first ran inside the image build, after main was pushed) and
`npm audit --omit=dev --audit-level=high`. pip-audit is blocking: when it was
finally read it held three urllib3 advisories with a fix available.
urllib3 goes 2.7.0 -> 2.8.0 as a single in-place pin, passing pip check,
rather than a regeneration that would move every package.
The dev toolchain's 18 advisories are gone:
- vite 5 -> 6.4.3 (path traversal, fs.deny bypass, and esbuild <= 0.24.2
through it);
- vitest 3 -> 4.1.11 (mocker path traversal);
- typescript-eslint 8.46 -> 8.71;
- `npm audit fix` for the rest.
tsc, eslint, knip, all 2269 tests and the build pass unchanged.
scripts/check-node-modules.mjs checks that frontend/node_modules holds
exactly what package-lock.json pins. The local gate's frontend lanes judge
the host tree, and nothing tied it to the lock the image installs.
`npm run knip` now runs a second, --production pass, which asks whether the
code that ships uses an export, not just whether anything does. Of the 47
exports only tests reached:
- 34 are exported for their test and say so with @internal, naming the test.
- 11 were module-private names with a stray `export`. Two of them were not
used at all and are deleted.
- The unused alreadyReported re-export is gone.
Test kits, the e2e suite and the two build-tooling modules are excluded from
the production project, each named in knip.jsonc.
noUncheckedIndexedAccess is now on for the node and e2e tsconfigs too (0
errors), matching the app project. tsconfig.node.json listed a
tailwind.config.js that has not existed since Tailwind 4. That entry is now
playwright.config.ts, which the typed lint finds through this project.
compilerCoverage.contract.test.ts used to check two files. It now runs the
real compiler over all of src (minus tests) and holds the exact set it skips
today, file -> function -> category: 93 functions. A new skip or a category
change is red, and so is a listed skip that has gone, so the list only
shrinks. Channels and DataTable must also show up as compiled, so a harness
that compiled nothing cannot pass.
Measured against the audit's scan: no function this sprint touched started
to skip. PlexPersonView and useOverlayOrder now compile.
reactPlugin.ts claimed the compiler's rules were linted, which was false
while react-hooks/refs was off. That comment and the refs block in
eslint.config.js now say what holds the line and why refs is still off:
245 real hits, pinned by the ratchet, burned down from S3.
eslint now also enforces:
- the React Compiler diagnostics that `recommended` leaves out (todo,
memo-dependencies and ten zero-hit tripwires);
- a description on every suppression, and no blanket disables;
- no focused or skipped tests (vitest and playwright);
- the typed pair no-floating-promises and no-deprecated.
Lint also covers e2e/ and the TypeScript build configs, which `eslint .`
had been skipping in silence.
Every hit is fixed or suppressed inline with a named reason:
- The 31 try statements the compiler cannot lower yet carry a suppression
until S3 rewrites them. Five other compiler skips were plain fixes:
a module counter bumped inside a hook, a ref read in a default
parameter, and a local named like the <section> it renders.
- The real defects behind floating promises are fixed. ChatDock threw at
nobody when the device key store could not be opened. VideoEditor's
play() rejection went unhandled. loadYouTubeApi never rejected, so a
blocked IFrame API left the player on an empty stage forever; it now
fails on a script error or after 15 s, shows its own error with the
way out, and lets the next open try again.
- MutableRefObject, FormEvent and lucide's deprecated brand icon are
replaced. The YouTube glyph now lives in lib/brandIcons.
- The lint-probe harness lets its virtual file names into TypeScript's
default project, so the contract tests still run the real config.
ruff gains RUF100, PLW1510 and flake8-bugbear, every hit fixed rather than
ignored. B008 stays on with FastAPI's Depends/Query declared immutable.
basedpyright moves to standard mode, pytest to --strict-markers, and a hand
run of pytest now requires the database unless SIFTLODE_TEST_REQUIRE_DB=0.
PLW1510 found a real bug: the three ffprobe probes in plex/stream.py never
looked at the exit status, so a probe that failed was read as the file's
truth (an HDR source sent down the HW-safe path, a multi-audio file collapsed
to one track, a subtitle offset taken from garbage) and the warning each
except branch promises never fired. They now run with check=True.
The React Compiler silently skipped Channels and DataTable (a ref written
during render; the refs lint is off), and even once compiled it dropped the
memo of the rows and columns (they reach filterRows/optionsFromColumns past
the page's hooks) and the columns read the open row. Every open, close and
reposition of the right-click menu rebuilt the whole table: on "All" rows
~0.9 s to open and ~0.6 s to close (dev). Now: effect events instead of the
render-written refs, explicit memos for rows, columns and their inputs, a
module-scope keyOf, and the open row reaches its "…" through a small store.
Measured on 397 rows (dev): ~75 ms to open, ~35 ms to close.
A contract test runs the real compiler over both files and fails on any
skipped function. The dropped memos have no automated guard: they need
the full page rendered with its providers; the measurement above is the
evidence.
prefers-reduced-motion hard-disabled the rotation whatever the toggle said,
and Windows reports it whenever its animation effects are off — so the
owner never saw the crossfade. Untouched, the setting now follows the
system (and the toggle shows that state); a pick made on the toggle is
stored and wins on every device.
After a reload the chips (filter panel and the row above the feed) read as
raw UC… ids until the subscription list landed. The names seen last time
are now kept per account (useChannelNames) and name the chips at once; the
list then replaces the copy, so a renamed or dropped channel heals. A
channel never named on this browser draws a placeholder bone while the
list loads; a failed list still falls back to the id.
It printed the tag's whole-library count as the current filter's while the
facets loaded, and for good when they failed: live, "Music 238" beside a
search that matched 3 of its channels. Ordering still falls back to it.
- DataTable: the reveal floor holds only in the view it was made for (Channels
never clears its follow, so every later sort mounted rows up to it)
- Feed + Plex browse: a failed next page stops the read-ahead and shows a row
that retries, instead of re-requesting it once a round-trip
- the YouTube Load more click uses nextPageOnClick; a contract pins NEXT_PAGE
to the read-aheads and the click sites to nextPageOnClick
- ItemCount: a failed count says so; a stale one says updating to a screen reader
- the narrow-picture Play lift applies only where a hover row is there to clear
- PlayerModal: one description ladder for the popover and the panel, both tested
- plexImage contract reads every img src, any attribute order
- Tabs comment names who persists the sub-tab
The page halves compare the cursor's timestamp with the window's in Python;
a null or naive one raised there, past the route's 400. decode_cursor now
refuses a null non-nullable key and a naive timestamp.
keepPreviousData held the last filter's count, and a broad word's count
takes seconds: the pill showed the old number as the new search's. It now
dims and is aria-busy until the new count lands, as AdminLogs does.
A broad word matched most of the library and ts_rank read every match:
"a" took 4.4 s a page on prod. The window's start is read once (0.1 s) and
rides the cursor, so a video ingested mid-scroll cannot repeat a row; the
page is read as its two halves, each on an index (80 ms + 5 ms).
useTags reset the language in a finally while its hook was still mounted;
the switch scheduled a render, and in a loaded gate run it landed after
the jsdom environment was torn down ('window is not defined', two
unhandled errors). auditColumns and comfortPin had the same shape.
The e2e asserted the channel chrome never moves; it folds once now. The
ui catalog lists scrollFold and progressiveRows, Tabs names the
per-tab memory its callers use, and the welcome note says a removed
language orphans its stored copies too.
A concurrent build that failed part-way leaves an INVALID index under its
name; IF NOT EXISTS kept it on the re-run, and 0095 then dropped the old
channel_id index, leaving none usable. The leftover is now dropped and
built again, and the old index goes only once the new one is valid.
rss_all_failed is a plural pair counted on the raw total (the values are
formatted strings, which pick no plural form); both sentences end with a
stop. The Hungarian 'show N more' uses the house's megjelenítése.
The playlist rows, the search box and the collection picker still
fetched the 400px poster for a 32-80px picture. A contract test pins the
set of bare Plex pictures, and the srcset widths to imgcache.SIZES.
At text size 1.3 a 242px feed thumbnail put the 63px Play 13px into the
hover row (the Play grows with the root font, the picture's height does
not). A picture narrower than 14rem centres the Play and the resume pair
above the row; the picture box is a size container for it.
The Downloads title and channel links were 21px tall inline boxes; they
stand on a 24px line now (B1). The player's description says a failed
load, with a retry, instead of 'No description' (both the side panel
and the popover).
NEXT_PAGE joins ANY fetch in flight: a read-ahead during an invalidation
or a poll joined that refetch and appended no page, and nothing asked
again until the reader moved the range (the channel page during a
backfill). The read-ahead now waits while the list fetches anything
(mayReadAhead, re-run on isFetching); a Load older click cancels a poll
instead (nextPageOnClick).
A page turn to a revealed row restarted the progressive mount at 30
rows; Channels focuses that row in the next commit, and at index 40 of
a 50-row page it was not there, so focus fell to <body>. The mount now
reaches through the revealed row.
A 1-2 letter prefix matched most of the library and ts_rank scored every
match: "m" took ~4-8 s on prod against ~150 ms now. The prefix match
starts at three characters (MIN_PREFIX_LEN); shared by the YouTube feed
and the Plex library search.
A watch-state scope (watched / in progress / unwatched) joined the per-user episode rollup into
each show facet query: five passes over the user's episodes per call, half of a filtered facets
request. The shows in scope are now resolved once and every group filters on their ids; the
grid-parity test holds the counts. Localdev: 99 -> 61 ms.
Cards fetched the full cuts: a 400x600 poster (~38 KB) for a ~144px picture and the 1280x720
backdrop (~162 KB) for a ~212px shelf picture. The image proxy now also cuts thumb_sm (240x360)
and art_sm (480x270), cached apart; card pictures (grid, shelves, collection tiles, Up next) carry
both in srcset with sizes=auto, so the browser picks by laid-out width and DPR. Localdev:
17 KB a poster, 56 KB a backdrop. The detail page keeps the full cuts.
The views, duration and title sorts had no index: every page read the whole videos heap and kept a
top-N (prod 350 ms a page, 550-780 for title). Migration 0096 adds one per direction and, for title,
per reader collation, in the exact ORDER BY feedquery builds. Localdev prod copy: plain sorts
180-310 ms -> ~1 ms, page 20 3-5 ms, narrow filters unchanged. view_count/duration are only written
by the enrich, which already rewrites indexed columns, so no HOT update is lost.
The title indexes are the first on an ICU collation, and postgres:16-alpine floats: collation.heal_drift
rebuilds them (CONCURRENTLY) at startup when Postgres reports the ICU version moved.
test_feed_sort_indexes holds every videos sort to an index and checks the rebuild.
react-query's fetchNextPage cancels a next page still loading and sends it again by default.
Our requests ignore the abort signal, so the server ran both: the feed's read-ahead effect
re-runs on scroll frames before isFetchingNextPage reaches it, and on prod every feed page
went out 2-3 times (a 0.8 s title sort queued to 2.3 s). Every call now passes NEXT_PAGE
({cancelRefetch: false}), lint-enforced, with a contract test.
The channel manager's All page (~400 rows) blocked one frame for 0.7-0.9 s on prod to mount,
and every sort and search key re-rendered every row (300-400 ms). No single cell is to blame,
measured column by column. DataTable now mounts the first 30 rows at once and 40 more per
background transition; a new page, size, sort, filter or caller-side search (narrowedBy)
starts over from the first rows. A row is its own component, so the compiler keeps the rows a
step already holds.
GET /api/channels summed every subscribed channel's videos by reading each video's heap page
(681 MB on prod): 51-61 ms warm, but 2.25 s on a first open and 6.4 s under EXPLAIN once the
table had left memory. /feed/summary and /feed/years did the same (~140 ms warm, ~800 ms cold).
Migration 0095 replaces ix_videos_channel_pub_desc with the same key INCLUDING the columns
those queries read, so they are Index Only Scans over a 25 MB index. A test holds the queries
and the index's column list together.
The admin Configuration page did a db.get per setting (two for an unset one): it now reads
every override in one query (~40 ms of ~55 on prod).
The hero's grid row is as tall as the crew column beside the text, and the poster, a flex child,
stretched to it: a 2:3 picture drawn ~175x590 and cropped. It now aligns to the top, and a wide hero
gives the movie and show poster a bigger rung (w-56). The crew, the hero's tallest part, shows its
first 6 names and a 'Show N more' toggle; a list of 8 or fewer shows whole.
- Channel page: the banner and identity block fold away once the grid scrolls
(ui/scrollFold: folds past 32px only if the page can stay scrolled, unfolds
at the very top) and the name rides in the tab row. One Back button in both
states keeps its focus; folded at a tablet width the actions wrap instead of
pushing the page sideways.
- Plex movie and show pages: the TMDB facts over the key crew are the hero's
second column (plexDetailAside), the keywords sit under the text; the show
page shows them for the first time, with the same three toggles. A
collection's hero is sized to its text.
- YouTube player, 1280px and wider: the title, channel, actions, description
and queue sit in a side panel beside the video, which takes the window's
height (1438x809 at 1920x911, was ~1008 wide). The wheel scrolls that panel
while it can and turns the volume elsewhere.
- DESIGN.md decision log: the keywords' place under the text.
"1 seasons", "Synced 1 subscriptions", "1 video(s) were skipped": 21 English
strings carried {{count}} in one plain key and read plural at one. Each is a
_one/_other pair now (Hungarian keeps one form for both), and so is every other
counted string, so the next wording change is a plural too. A contract test
holds the rule for every locale file.
Settings, Configuration, Statistics and Users kept the last sub-tab in localStorage, so every
fresh tab opened Configuration on "Plex" as if that were its home. useTabPersistedState keeps it
in sessionStorage (account-scoped): a reload and a trip to another page keep it, a new tab starts
on the first sub-tab. The pre-selects (a Review link, the Google-link return) write the same store;
a value an older build left in localStorage is cleared on first read.
- NavRailGroup: one "Admin" rail row opening a panel of the admin pages (links, so a
middle-click still opens a tab); the rail fits 133% zoom without a hidden scroll
- sysconfig.SECTIONS orders the config groups under four headings (every group in exactly
one, test-held); the page shows them as Settings' side list (Tabs `group` headings);
the lone registration group reads "Registration" under "Access"
- StatTiles / StatTile / StatBars: the Statistics overview's figures as tiles, the
per-operation quota use as horizontal bars (a zero draws none)
The owner read a fold that broke at every other job as the same row five
times. A job's successful runs now fold into one row at the newest run's
place across other jobs and admin actions; only that job's own failed or
skipped run breaks the fold (it stays its own row, and older successes
start a new fold below it).
- Nav rail page rows are real links (/?page=<id>): a middle-click or
Ctrl-click opens the page in a new tab; a plain click stays in-app.
- A fresh channel page shows the unwatched videos; a picked watch state
is a remembered preference (channelShow).
- Users: the role, suspend and delete move into the row's one menu
(the "…", right-click, Shift+F10); locked ones keep their reason.
- Audit log: back-to-back successful runs of one job fold into one row
that opens to the earlier ones; a failed run reads in the danger colour.
- DESIGN.md decision log: the E31 picks.
The audit log showed a failed RSS poll as the server's English sentence
under a Hungarian UI. A job can now raise progress.JobFailed(reason,
message, **params): the scheduler stores the reason and params as the
run's data (dashboard, manual-run notice, audit row) beside the English
line, and the client renders scheduler.failure.<reason>. The RSS outage
names its first channel and the reason code (HTTP 404 vs unreachable).
An upstream's own error text stays as raised (diagnostic).
- The browser tab names the place and the object: PageHeading reports the
view's own title (lib/documentTitle), so a channel, playlist or any Plex
view reads "<object> · <module> · Siftlode" however it was opened; the
Feed is named too; a season adds its show (tabTitle).
- The channel page's tab + action row wraps and lets the tag lane fold into
"+N": at 1024 it ran past the edge and the page scrolled sideways.
- The YouTube player card's width is capped from the window height less the
chrome measured under the video: at 133% zoom (685px) it cut its channel row.
- Plex certificate words (Not Rated, Approved, Passed) are named through the
vocabulary on the info badge, show page, collection, filter and chips.
- DESIGN.md: the zoom target is 125% and 133%, not 200% (decision log).
E31 S1: the welcome is rendered once, at account creation, so an account that
switched language kept it in the old one (the owner's, English under a
Hungarian UI). A stored copy is recognised by its opening line (derived from
WELCOME) and served in the reader's language; other system messages stay as
stored.
- Person page: a bio ask that fails says so with a retry instead of reading as 'no biography'.
- Show page: the summary is a ClampedText (5 lines + More), like the person and collection pages;
one More/Less pair (plex.textMore/textLess) for all three.
- Channels: a re-slotted row is placed in the language the server orders by (the stored
preference, else English), not the UI's guess.
- A skipped run's body is only the reason (its title already says skipped).
- HU: Behúzási kvótatartalék; Eseménynapló ürítve / az eseménynaplót; indok; közvetlenül
játszható / közvetlen közvetítés in the Plex hints.
- Tests: ClampedText re-measures on a resize, the show summary clamp, serverOrderLang.
- warm_portraits: only 404/410 mark a headshot dead (measured: Plex answers a dead CDN file 404);
a refused token or a 5xx is Plex failing, ends the run after three in a row and marks nothing.
'failed' counts every failure, not only the current streak.
- is_portrait_url: plain path segments only — no dot or empty segment an HTTP client folds onto
one file; person_thumb_url uses the same predicate (27 852 stored headshots all pass).
- Tests: Plex refusing (401/403/429/5xx), failures between successes (portraits and bios), the kept
original served from cache, the audit_clear / demo_reset / discovery_purge rows' counts as data,
and the 0094 backfill on real rows.
- audit log: a job run named from its result data (nested phases too), a skipped run says so, a
secret's reset reads as a reset, choices named as Configuration names them, interval / batch /
counts localized; the inbox shares the same jobRunText
- HU: remux is "Közvetlen közvetítés" (it shared "Közvetlen lejátszás" with direct play); backfill is
behúzás, enrich is dúsítás, collection is kollekció, the allow-list is fehérlista; the last
"jobnak" / "transzkódolás"; a test holds the options of one choice to distinct names
- contract: every skipped / reason word a job returns is named in both languages
- person page: the bio wait is timed from the open and a failing ask stops the polling; ClampedText
(ui) shared with the collection page, whose short summary lost its dead More
- a playlist names season 0 the Specials; the channel re-slot compares in the reader's alphabet; an
invite's date is in the admin's language; the language-ordered lists are asked again once a
language switch is saved (saveLanguage)
- poster grids' column minimum in rem; lint refuses minmax(Npx,…)
- people_enrich: a failed TMDB lookup or a dead headshot is skipped, not the end of the run (a dead
file is marked and left for a week; three failures in a row end it); portraits wait for the Plex
module switch; a no-Plex original keeps its own cache key, so the resize still replaces it; the job
entry lives in the plex domain (run_people_enrich)
- person-image takes only a headshot-shaped CDN url (no query variants of one file)
- every synced row gets Hungarian genres, and a HU genre filter matches the word as picked too;
migration 0094 fills the rows no overlay reached
- a job run's audit row carries its result as data; audit_clear / demo_reset store their counts
- one credit-column list for the prune and the enrichment order; _overlay_pass typed
- tests: batch skip + stop, HU bio stored, dead headshots, module switch, the person-image route,
the job body, genre filter + facet write + twin fallback, tagline/summary fallbacks, audit data,
the reader's alphabet at every remaining collated() call site
With one to three titles (about one person in five) the rail sat at the far edge of the hero with a gap
between it and the bio. The person's column is now capped at the bio's reading measure and a short rail
follows it directly, so the leftover is the panel's end (owner pick "b"). Four or more keep half the hero.
The rules covered each piece; nothing asked whether a screen works as a whole, and the person page passed
every rule with half its panel empty. New chapter: the space is used, one focal point, worst real data,
where am I, a click answers at once (no outside service on open), zoom and text size first-class (the
owner reads zoomed in). Conformance step 5 writes one composition line per screenshot and checks 200%
zoom and the largest text size. Anti-patterns and a decision-log row to match.
HU: the download worker setting reads "Párhuzamos letöltések" (a leftover from the S5 sweep).
The biography sat at reading width in the left half of a full-width panel with nothing on the right.
Now the person (headshot, facts, the bio clamped to eight lines with More / Less when it is cut) is on the
left and "Known for" on the right; with fewer than four titles the rail takes only its posters' width.
One column where the panel is narrow (200% zoom, the largest text size).
While the server fetches a bio (`enriching`), the page shows a paragraph skeleton (a new "text" shape of
LoadingState) and asks again every 1.5 s for up to 20 s. The view is keyed by the person's name, so
another person starts fresh.
A person's TMDB bio was fetched only when their page was first opened, off the request path, so the first
open showed no bio and the page never asked again (2 of 18,202 linked people were ever enriched).
- people_enrich scheduler job (every 10 min, 300 per run): linked people's bios from TMDB and their
portraits into the image cache, the most-credited people first. A failed TMDB lookup ends the batch.
- Portraits are resized through Plex's photo transcoder to a 320 box (~15 KB instead of ~170 KB; the
person-image proxy uses the same cache key, and a full-size original is removed as its small twin lands).
- The person response says `enriching` while a bio is on its way (a person the job hasn't reached yet).
- "Known for" is ranked by vote count, without guest visits as themselves (talk shows had crowded out the
films: Tom Cruise had 14 of 15), with directed works, from a 40-deep pool; the page shows up to 15.
Migration 0093 un-stamps enriched people so the job refetches them.
- A phased job result (backfill, people_enrich) now counts as a change for the audit trail; before, an
automatic backfill run that added videos was never audited.
- HU: the watch reconcile job reads "egyeztetés" (a leftover from the S5 sweep).
The audit log showed the server's fixed English lines ("rss_poll: ok — new=5, failed=0",
"log_level = DEBUG", "Background sync paused") to a Hungarian reader. The details are rebuilt from the
row's data on the client: a job run names the job and its result as the inbox does, a config change
names the field and its values (on/off, grouped counts, "default"), a pause/resume adds nothing the
action does not say. A failed run keeps its error text (diagnostic). parseSummary reads enrich's bare
count ("50") back as `enriched`, as the server's `_result_data` names it — the audit and old notices
keep only the one-liner. No backend change; old rows render too.
Found in the Hungarian UAT: a toolbar SelectTrigger's text value wrapped out of the pill in a narrow
place (the playlists rail) — it now truncates in its own span; the Channels "Prioritás" and "Utolsó
feltöltés" headers were clipped by their fixed columns — sized for the label, funnel and sort arrow;
a sortable header is at least 24px wide (Hungarian "Ki" was a 12px target). PRE-EXISTING, any
language: the messages unlock button and "Forgot passphrase" sat flush — one gapped row.
The D3 list: Feed, Shorts, Admin, Token, Port stay; stream, Prio, Job, Audit, Demo, Auto, Info,
Frontend/Backend, Backfill and transcode get the Hungarian word (közvetítés, Prioritás, Feladat,
Eseménynapló, Demó, Automatikus, Infó, Felület/Szerver, videók behúzása, átkódolás). The final sweep
also took out reset/sync/reconcile/setup/logs/whitelist/email, and three features that went by two
names: access requests (kérés/kérelem), backfill (betöltés/behúzás), and the connection test
("Check the connection" beside "Test connection", in both languages). Two quota actions shared one
Hungarian name. The playlists rail title and two of its sort labels are shorter so they fit.
DESIGN.md Copy: the rule and its decision-log row.
The /person fetch appends translations and keeps TMDB's Hungarian biography beside the instance-language
one (new plex_people.biography_hu, migration 0092). A Hungarian reader is served it, the English one when
TMDB has none. Already-enriched people are un-stamped, so their next open refetches them once.
"Full history: done / requested" counted every subscribed channel whose
back-catalog was complete in the numerator - also the ones another user asked
for, or short enough to finish on their own - against only the channels this
user requested, so the Stats page read "342/329". The numerator now counts the
requested channels that are done; "channels without full history yet" still
counts every subscribed one.
Every locale formatter takes the language as an argument. Under the React
Compiler a formatter that read the i18n singleton was memoized on its other
arguments, so a card kept "2 hr 4 min" after a switch to Hungarian; plexLang()
had the same flaw and left Plex queries in the old language. Components now
take the language from useTranslation, and the signature makes forgetting it
a type error.
- ratings, file sizes, stream stats: the language's decimal mark ("8,3")
- view and subscriber counts compact in the language ("1,2 E")
- seconds as the language's unit ("6 mp") in the sliders and the stats
- a person's birth and death dates as dates, not ISO strings
- A-Z lists by the shown name in the language's alphabet (compareText),
the tag catalog re-sorted by its translated names
- lint local/locale-format (was date-format) also refuses Intl.NumberFormat
outside lib/format.ts, localeCompare, and a component reading the language
off the i18n singleton
The Postgres image is Alpine, whose en_US.utf8 is byte order: "Életrajzi" and
"Ötödik" sorted after "Zorró" in the feed, the channel lists, the Plex grid,
its genres and collections. `app/collation.py` names the ICU collation of the
reader's language (hu-x-icu puts "Ö" after "O" and "Cs" after "Cu"), wraps
every text ORDER BY with it - the feed's keyset cursor compares with the same
expression, so paging walks the order it sorted in - and orders the lists built
in Python (genre and studio facets, collection members, the discovery re-sort)
through the same collation. Plex title tiebreaks use the shown (HU) title.
- lib/vocab + the `vocab` locale area: TMDB crew jobs and departments, auto-tags (D1 A: the data
stays English, the catalog is named at its one source, useTags), built-in download profiles,
YouTube topics; held key-for-key to the backend's own lists by backendIds.contract.
- Countries by ISO code: TMDB parse stores {code, name}; rows stored before are coded from TMDB's
own country list in one request (run_enrich); Intl names them, historic codes (YU, SU, XI…)
from the catalog.
- Player tracks named from the language tag, forced/SDH flags and codec tail, not Plex's
server-language displayTitle; no invented "Audio N" on the server.
- Download lifecycle notices (shared / expiring / expired / evicted) named from data; the share
notice carries the title.
- Scheduler: the job result rides along as data and is named per key (dashboard + run notice).
- Invite status named; a render crash's notice no longer shows the engine's English message.
- Fix: YouTube links …/wiki/Humour, the tagger looked for "Humor" — Comedy never came from a topic.
Found in the S1b UAT: 'Mert megnézted: Batman Forever' kept the English title, and
the info page's TMDB recommendations kept TMDB's English titles even for titles we
hold. A held recommendation is now named as the library names it, in the reader's
language; one we lack keeps TMDB's title.
The grid served Hungarian titles, but every other card surface (Discover rails, the
show page's episodes, search, collections, the person page, playlists, Home's resume
rail, hero and comfort shelf) served English. The card serializers now take a
required keyword-only lang (a forgotten call site fails instead of going English), and
each of those reads takes ?lang=hu, with the language in its query key. A Hungarian
search finds an episode by its Hungarian title too.
An episode on a poster rail showed its 16:9 still cropped into a poster, titled only
with the episode name; it now shows the show's poster and name, with S·E · title under
it (option A). Plex's generic season names (Season 1, Specials) are rebuilt from the
number in the reader's language; a season with a name of its own keeps it.
Home's comfort-shelf move wrote its optimistic order to the key without the language,
which nothing read any more; it writes the query's own key.
The HU TV section lists every episode with its Hungarian title and summary, but the
overlay read only movies and shows, so every episode stayed English. It now overlays
the episodes too, and the HU tagline (new tagline_hu column, served on ?lang=hu).
Plex's HU agent returns a mixed genre vocabulary (Action beside Dráma), and a title
with no HU twin kept its English list, so the HU genre list showed Drama and Dráma.
plex/genres.py maps every genre word to one Hungarian word; the overlay writes it
into genres_hu for every row, and a HU genre filter is read through the same map,
so a saved filter still holding Action keeps matching.
The server gate's frontend job went red on 0.96.0: PlexPlaylistView's j/Delete
test pressed j after the rows painted but before useListNav's passive effect
registered the list, so the key did nothing on the slower runner. Every test
that presses a list key after an async load now waits for [data-nav-list] on
its list first (PlexPlaylistView, Playlists, Home, PlexDiscover). Checked
against a 30 ms delayed registration: the old tests fail, the new ones pass.
E28: one action list per object, keyboard reorder, touch drawers and player
controls, the table pager, plus four deep-review fix rounds (playlist sync
locking, player restart and focus behaviour).
The repeats of a Space/Enter that landed on faded bands pressed the control once the bands were up;
they are swallowed with it. A play or pause while a session restart is out now sets that session's
intent instead of playing the outgoing stream. Tests: Skip intro outside the bands keeps its keys,
every restore takeover (wheel, finger, scrollbar, key) is covered, and the chip handoff test tells
the chip before from the row's first control.
A delete landing while a push read YouTube left a plan over no items, so the push emptied the
YouTube playlist a local-only delete keeps: the push now stops at a vanished list (PlaylistGone,
404), and a delete is refused while a sync of the list runs. The closing baseline fingerprints the
title YouTube got, not the one its lock re-reads, so a rename made during the writes stays dirty.
The other-tab test helper bounds its lock wait, so a lock-order regression fails instead of hanging.
A chip handed its focus to a Clear all or +N that went in the same removal, and the focus fell to
the body. The restore now stops when the reader scrolls, and finishes the way once the Back-to-top
spacer brings the saved spot within reach.
The same-film restart read the paused state, restarted the session without play, and resumed the
outgoing stream beside it. Space or Enter on a control the bands had faded from pressed it unseen;
now it only brings them back, and Space plays or pauses.
The mirror refresh re-ingested inside its row lock, and that ingest's commit let go of the lock
before the replace: a remove made then was undone and the list read as synced. Push, re-pull and a
delete touched items before the list row, a deadlock beside the playlist-first route edits. A push
now re-plans from the items under the lock and derives dirty after its YouTube writes.
lock_playlists moves to the sync package. Tests pin both ids of a move's lock and the claim's
own connection.
Two session requests for one item in flight at once (a quick double seek; the
StrictMode replay of the start effect) let the server's last spawn be the older
request's. The newer request's answer then named a session already gone; its
playlist 404'd before a manifest parsed, and the player stopped with "Playback
couldn't start". The 404 reclaim now covers a session that never played too,
bounded by the same budget, and keeps the start's own autoplay.
A frame later never came in a page the browser was not painting (measured: a hidden page runs no
requestAnimationFrame), and the focus stayed on the inert tab under the card.
- A key landing on a side panel's tab steps into the card it peeks open, not onto the hidden tab.
- The Channels cards keep their status / pager row at the list's end while the band is folded.
- A menu row's note shows on an enabled row too: the download state under 'Download…', not a title.
- A removal chip hands the focus to its neighbour before it goes, for every caller (the feed's and
Plex's active filters); the hidden-filter cue's own copy is gone.
- Back to a spot within a page's end spacer lands at the page's end, not its top.
- A running push / revert is tracked per list: another list's push is no longer refused unseen.
- The saved views, the view switcher, the year filter and the table's single-choice filter mark the
chosen row with MenuCheck, as DESIGN asks, not a trailing tick, a dot or a filled row; a contract
test holds every checkable menu row to it. keepSelectedPaint had no caller left and is gone.
- A combobox's panel carries its listbox role on an inner PopoverList (listInside), so the failed
load, Loading, No matches and the 'n more' line sit beside the options, not inside the listbox. The
Plex search box's Loading line likewise.
- The Plex sidebar's playlist picker offers Create only once the lists have loaded: over a failed load
it pushed the error and its Retry out at the first keystroke.
- The info overlay's collection strip listing the film itself: Play/Restart restart or resume it in
place instead of pausing it on a frozen 0:00.
- Up/Down on the closed ⋯ and tracks buttons stay the volume (openOnArrowKey: false).
- A keyboard focus in the bands holds them for 10 s after the last key, not the whole film; a tap
leaving a tapped button no longer re-shows the bands its own toggle then hid.
- Next leaving under the focus hands it to its neighbour (the player keeps its focus).
- The chosen audio / subtitle track wears the MenuCheck tick.
- Every playlist item write (add, remove, move, reorder, rename; Plex remove and remove-bulk) row-locks
the playlist before it touches an item, a move both lists in id order: remove beside reorder was a
deadlock, and two adds of one video a unique-key 500.
- The scheduled mirror refresh re-reads dirty under that lock, so an edit made during its YouTube calls
is not replaced by YouTube's order.
- A user's bulk pull holds a per-user advisory claim: two at once created duplicate mirrors. The manual
sync answers 409 sync_all_busy instead of '0 synced', and like the re-pull spends its cooldown only
on a pull that runs.
- Deleted mirrors are committed at once, not only when a later list commits.
- The claims open an unpooled connection of their own instead of a third pooled one per push.
A picker whose options failed to load said so, but the only way back was closing its dialog.
Combobox takes `onRetry`: a Retry beside the message for the pointer, and Enter in the field for
the keyboard (the panel is a listbox, which may hold options only, so the alert names Enter for
assistive tech). Every caller with an error state passes its query's refetch: both user pickers,
the channel picker and the two Plex pickers.
The client's in-flight flag covers the menus of one page only. A push reads YouTube, then writes
both sides for seconds and commits step by step, so a row lock ends at its first commit: two tabs,
or a push and the scheduled mirror refresh, could interleave into a doubled YouTube item or a
baseline recorded over the other's writes. `sync_claim` takes a session-level advisory lock on a
connection of its own (it survives those commits; a crashed worker's lock dies with its
connection, so no claim column is left stuck). Push and revert answer 409 `sync_busy`; the
scheduled refresh skips a list being synced and picks it up next run.
The player's settings tabs switched tab on an arrow and prevented its default, yet the player's
window listener still sought the video 10 s. dispatch() now skips a defaultPrevented key for
every scope; listNav's private copy of the same guard goes.
The card's own pin and the drawer's still said 'Unpin panel' / 'Pin panel open' after the edge tab
learned the title (F6). Naming them too made two buttons share a name, because the invisible tab
under an open card was still in the accessibility tree: it is now aria-hidden as well as out of
the Tab order. The generic sidebar.pin / unpin keys are gone.
The button floats over the bottom-right corner, and at the end of a long table it covered the closing
pager's '100' and 'All'. While it is up, PageScroller ends the page a button's height later (only
then, so a full-height layout gets no scrollbar). Its shown state is a shared store read through a
use… wrapper: a store's .use() called straight from a component is memoized by the React Compiler
and never re-renders (contract: store.contract.test.ts).
A tabletHidden/narrowHidden column below its width, or any column in a card
layout, took its header funnel with it: the rows were narrowed by a filter
nobody could see. The cue is shown by the inverse of the column's own
container query, so it agrees with the table at every width and text size.
Wired into DataTable, the playlist table and the channel cards (E28 F2).
CLAUDE.md, DESIGN.md and ui/README said a raw <button> is allowed anywhere in components/ui/; the
lint rule allows it only in BUTTON_PRIMITIVES (and refuses a bare FocusSafeButton elsewhere too).
The focus-safe rule's message no longer points feature code at FocusSafeButton. The README's
'actions & inputs' row was split in two by a stray line break.
The trailing .,;:!?)] was trimmed off the href and dropped from the text too, and a URL ending in a
balanced ")" (a Wikipedia article) was cut short. The trim keeps a closing paren that has its
opener, and the trimmed tail is put back after the link.
Each of the four sites kept its own invalidation list and each missed something (the channel menu's
unsubscribe left the feed showing the channel's videos; the channel page's left the counts stale).
lib/subscriptionCache invalidateSubscription is the one set; a contract test holds every call site
to it.
Every other page band folds its filter row below ~600px of height; the Channels band never did, so
its tag chip lane and status / pager row kept a short laptop window for themselves. They fold behind
one "Filters · n" (n = picked tags + other active column filters); the tabs and the TAGS editor stay,
and a folded table keeps its controls at the list's end.
Every surface composes the pin row into its object's one action list (E28 C1); the pin-only menu
had no caller left. Also un-exports isStandInFocus, which only its own file reads since H1.
- F5: a chip's × pressed from the keyboard hands the focus to the next chip (else the previous one,
else what follows) before it unmounts — in DismissibleChip, so all five chip rows get it
(`focusNeighbour` in lib/useFocusKeeper).
- F4: a drag grip names what it lifts (`common.order.dragNamed`: panel groups, personal views);
the two generic "Drag to reorder" keys are gone.
- F6: the side panel's edge tab is out of the Tab order while the card covers it, names its panel,
and a keyboard pin moves the focus into the card.
- F7: Back to top is no Tab stop while hidden, and a keyboard press leaves the focus on the page.
endsTextSelection takes the click event and exempts a keyboard click (detail 0): Enter / Space
ends no selection, so a title selected and then tabbed to opened nothing. All three callers
(FocusSafeButton, the Plex caption, the video card title) go through it.
The remote thumbnail was the last resort with no fallback of its own, so a dead CDN link drew the
browser's broken-image mark. A failed remote URL is remembered like a failed poster, and the row
shows its film / music glyph.
locate() spread the reader's filters and cleared only the tag, search and age cap, so a year, a
custom range, a length, a content type (a hidden Short under "no Shorts") or the scope could still
leave the video out. It builds the filters from nothing now, keeping only the sort and tag modes.
(Also types the C1 test's pending plan.)
The in-flight flag was per hook instance, and the rail row's menu and the list page header's "…"
are two at once: a push from one left the other offering a second push. It is one app-wide store
(`lib/store`), read with `get()` at the start so a second start refuses before a render; a revert
re-reads it after its confirm dialog.
- The row opens the download dialog, so it is labelled so; the download's state ("Downloaded",
"In queue") is its hint, not its label.
- The share dialog's expiry ("1 days") and a link's view count ("1 views") go through i18next's
plural forms (`count`).
- Combobox gains `error` (said in the list, not "No matches") and a soft `disabled` (keeps the
focus, announced, opens and changes nothing).
- UserPicker passes both: the share dialog's recipient picker is off while a share is pending, and
the share and quota pickers say when the user list failed to load.
- ChannelPicker and the Plex playlist/collection pickers say a failed load too.
- The channel menu's Tags row says loading / failed instead of "no tags yet".
- PlexInfo's collection strip gives each card the related strip's action set: the "…" menu (with
Restart for a touch reader), the watched toggle and the playlist add.
- `onPlayItem` is required, so no variant draws a Play that does nothing; the player's info overlay
plays the sibling in the running player (`go(id, _, fromZero)` for its Restart).
The picture read "started" off the rounded progress %, so a resume point under 1 % showed Play
while the card's menu offered "from the start". Both use `plexStarted` now.
- Its section switch is `ui/Tabs` (new `tone="media"` paint for the dark chrome) with a wired
tabpanel; the panel opens with the focus on the selected tab.
- On/off rows are `CheckRow` (the whole row, 44 on touch), the "no background" checkbox too.
- Every slider and the four colour wells carry the name their row shows.
- The clock's date-style buttons reach the touch target.
- The stage is sized in dvh (80dvh × 1.25 windowed, 100dvh fullscreen); the viewport-units
contract no longer exempts the player.
- A tap that reveals the bands keeps them pointer-events-none for the double-tap window, so the
second tap of a skip at the bottom is not a seek.
- An arrow on the volume slider wakes the bands, and a keyboard focus in a band holds them
(`holdOnFocus`, `:focus-visible` only, so a clicked button does not pin the bar).
- Next stays mounted (disabled) while the next episode's detail loads, keeping the focus.
The rail was a row of aria-pressed toggles: every tab a Tab stop, no arrows,
nothing tying a tab to its content. tablist/tab/aria-selected, one Tab stop,
arrows on either axis, Home/End, and useTabPanel for aria-controls/labelledby.
They were hand-built divs: no menu roles, no arrow keys, and Escape left the
focus on <body>. Both run on the popover engine now (media-tone rows), and the
tuning panel is a labelled dialog that takes the focus and hands it back to ⋯.
The page region is <main tabIndex=-1>, so a background click focused it and the
Back restore took that for the reader's choice. A resting place is marked and
one predicate (focusIsUnclaimed) decides "the focus is nowhere" everywhere.
fromStart rode in history.state._sub, so reloading the player entry restarted
the item from 0 again and the progress checkpoint overwrote its resume point.
A sub-view config can now say what the history entry keeps of a view.
A raw index recorded at removal drifted once anything changed before the Undo.
Both lists now read the list fresh and re-insert each row after the row that
preceded it (lib/reinsert). A failed watched toggle in a Plex playlist also
puts the row's state back: its local copy was never re-seeded.
Two adds read the same max(position) and a reorder racing an add collided with
it. Every add/insert/reorder takes the parent row FOR UPDATE; the YouTube list
breaks position ties by id and a Plex reorder renumbers rows it was not sent.
channels.list simply omits a terminated channel, so nothing was stamped and the
channel page spent a quota unit on it every time. The miss is stamped like a
fetch; the published_at sentinel that kept it stale is gone.
One invalid byte sent a whole UTF-8 sidecar to a legacy code page, an untagged
file fell to Windows-1252 (ő/ű became õ/û) and Serbian was read as Cyrillic.
The cache key moves to sub3_ so a track the old rules mis-decoded is read again.
A job with no name yet is titled by its short link, as the link itself;
the channel line no longer falls back to the ref and the link line does
not repeat it. No picture at all draws a film (or note) glyph instead of
an empty box.
ui/MatchToggle replaces the Combobox popover footer (Feed tags, topics,
languages; Plex collections) and PlexSidebar's own toggle (genres,
actors): shown from two picks, one wording. A closed picker had hidden
that "all" was on.
The pager is four named arrows round the page box (first, previous,
next, last). A table hides a tabletHidden column below 64rem of its own
width, as narrowHidden does below 42rem, through one shared helper that
also hides the column's <col>. The Channels table drops Prio, About and
Length there: at 1024 the channel name had 60 px.
The trial is over: the owner kept it. The trial wording goes from the
tokens, the scheme list, the lab and DESIGN; B2's own-hue rule still
binds any new scheme.
The locale JSON's importer did not accept HMR, so the update climbed to
main.tsx, whose re-run called createRoot on the live container again; the two
roots tore each other's DOM down ('removeChild ... not a child' by the dozen),
each a fatal toast evicting an old notification. Dev only.
The first UI-review run (Downloads) found a failed row whose Retry wore the
Play glyph in the row and its menu, promising a video. DESIGN.md gains
'a glyph means one thing' and a log row.
The owner's picks (all the recommended options) become DESIGN.md's Copy
chapter and a sweep of both languages (742 values, no key drift):
- One name per feature: the rail's label, which may shorten it (Alerts
-> Notifications, Fiókok -> Felhasználók, Download Center -> Downloads,
Channel manager -> Channels); one "sync from YouTube" verb.
- Four verbs, four meanings (Delete / Remove / Dismiss / Discard, and
their Hungarian pairs), checked against what each action does.
- Sentence case, the app's places capitalised; "…" on a command that
asks first (a popover or form title got its own key without it);
one-sentence messages without a stop or a tick, "Couldn't …"; curly
quotes.
- Dates only from lib/format.ts, in the UI language: formatDateTime
names the month and stops at the minute (it printed "9/27/2026,
8:30:14 AM"), formatDateTimeSec for the log, the audit and the
recorder; ui/RelativeTime pairs "3d ago" with the exact minute on
hover. local/date-format refuses toLocale*String and Intl date
formatters elsewhere.
- Units from Intl, short (formatUnit): runtimes, totals, the Scheduler
countdown, the stats range, the player's tap skip. "2h 13m" was
English in both languages.
- src/i18n/copyRule.test.ts holds the machine-readable part (quotes,
"...", ticks, bare unit letters).
The S3g measure found the row's failure link 20px tall (the meta line's
text height): DESIGN B1 puts nothing under 24. Coarse pointers already
grew it to 44.
The probe re-run over the 15 pages at 1920x1080 found every button, icon
button, chip, field, tab, segment and menu row drawn by its primitive
except the S3h Plex picture, a div with the button role. It is a
Pressable now, and the button-primitive lint rule refuses an element
with role="button" outside the control primitives.
- SegmentedToggle defaults to the toolbar rung (28, text-xs): the old
small default had put the pager's page sizes and the YouTube result
count on 10px type beside 28px buttons. The Plex match toggle asks
for the small rung explicitly.
- The language badge on the rail uses text-3xs (was an 8px one-off);
the Plex playlist's watched mark and the Home hero's length use
MEDIA_BADGE instead of hand-copied classes.
- A media picture shows the pointer whether it is a link or a button.
- DESIGN.md: a Conformance chapter (the four-step check before a UI
change is done, with the S3g baseline), the segmented rung, two log
rows. CLAUDE.md points at the check.
C2 as decided: on a coarse pointer, or a window below 768, the panel's
tab (shown at every width, 44 wide on touch) opens the panel as a
start-edge drawer over the content, with a scrim. It is ui/Modal's new
drawer-start placement, so Esc, Back, the scrim, the focus trap and the
scroll lock are the dialog's own. A tap never peeks; pinning is a button
in the drawer's header, offered only at 1024 and up; a pin saved on a
desktop is left alone below that. A mouse on a wide window keeps
hover-to-peek and click-to-pin.
DESIGN: C2 as built, rows answer to their own width, phone-only layout
deferred to its own epic (principle 2).
- Download row: answers to its own width (container query); below 40rem
it stacks: picture beside a two-line title, the meta line across the
row (one line, never wraps: zoomed on the iPad its pieces broke apart),
then the source link and the actions on one wrapping line. The paste
field takes a line of its own when the buttons would squeeze it.
- YT playlist row: below 42rem the length rides on the picture and the
title runs two lines over the channel.
- Channels tag row: the suggestions take a line of their own below
40rem (the chip lane was 0 px and the row's menu off the screen).
- Pager: on a phone the arrows drop their words (names kept), and the
size label stays one line.
- Lint: a class split where the interpolation is the variant itself
(${NARROW}:w-24) is refused too; it had shipped with no CSS.
The header button said only "Play from start" and resumed the first
episode at its saved point. It now reads "Season from the start · S6 · E1"
or "Show from the start · S1 · E1" and asks the player for 0.
Plex player: a finger's tap on the picture only shows or hides the controls,
the hide timer starts with playback, a double-tap on a side third skips 10 s,
the seek bar is a 2.75rem touch-none hit area with its head shown on touch.
The row is play/pause, Back by position, Next only with a next item, mute,
time, tracks, a fixed ⋯ (settings, media info, stats, shortcuts, download)
and fullscreen; Stop, Restart and the ±10 s buttons are gone.
A started card shows the video card's Continue + Restart pair (one shared
MediaResumeButtons, beside the picture's button), and its menu lists the
restart; the player opens that item at 0 (fromStart). YouTube modal: the
description opens from an ⓘ, the queue arrows sit inside the screen.
Modal takes a required initialFocus (field / close / action), a field that
loads late takes the focus while it still rests on the close button, and
ModalFooter portals a dialog's commit row under the scrolling body from any
depth. The header and footer fades follow the body's scroll timeline, and the
viewport's interactive-widget=resizes-content keeps the footer above an
on-screen keyboard.
- ui/MediaCard + mediaCardStyles: one frame for every video, Plex title and
Home tile; the meta line pinned to the bottom edge, the length a badge on
the picture, the media Play only where the click plays, the hover row at
the picture's bottom-right (the owner's pick C).
- Shelf density: on any strip a one-line title over one meta line; mixed
Home shelves are landscape; rails turn content-visibility off (a card's
reserved height had become the strip's). Home slot 232px, so the Play
and the hover row no longer touch; a landscape shelf takes the small Play.
- Home hero is a card (hover row, the whole menu, a touch "…"), three
quarters wide and centred; channel / playlist tiles get Play, open and
"…"; a show tile acts on its next episode, ⓘ opens the show.
- Plex preview: a ~0.9 s mouse rest opens a passive panel with the whole
title, facts and synopsis (popover `pointerOnly`); the synopsis and title
tooltips are gone.
- Compact list row B: the title leads, "…" at the end, toggles over the
date on hover.
- Media-info layout B (collections, crew, cast beside the facts sidebar;
an empty column is not drawn); filtering values are chips there and on
the show page; "Cast" over the cast-only strips.
- Browse tiles hover like a card; prose capped by the `measure` utility.
Google's image CDN refuses a banner request that carries a Referer from our origin - every
time, not only under load (measured: 6/6 refused with it, 8/8 served without). The banner
<img> now sends none, as ui/Avatar does for the same CDN; the comments that blamed a
transient burst throttle are corrected.
Channel metadata (banner, avatar, description, counts) was fetched only the first time a
channel was seen: the subscription import asked only for channels with no details, and the
channel page only for ones missing a join date. 394 of 397 subscribed channels had July
metadata. Both now also refresh details older than DETAILS_MAX_AGE (7 days), through one
shared staleness rule; channels.list costs 1 unit per 50 channels.
The banner is retried on error; each failed attempt showed the browser's
broken-image glyph for as long as the retry waited. The <img> now stays
invisible until its source has actually loaded.
A right-click on the EMPTY area of a page opens a menu of that page's own
toolbar: the feed (sort, reshuffle, view, watch state, content, source, clear
filters), the Plex library grid (sort, watch state, clear), the channel manager
(layout, the cards' sort, sync status, edit tags, clear the tag filter, read
subscriptions), a playlist (play all, group by, reset view, save order,
undo/redo, the list's own menu) and the notification center (mark all read).
No destructive row and no backfill.
PageShell listens on the page area; lib/backgroundTarget decides empty (not
text, a control, a link, media, or a row/card with its own menu). Shift+right-
click, a touch long-press and a right-click on selected words keep the
browser's menu. ui/PageMenu registers while mounted, so a loading or error
state keeps the browser's menu too. The rows mirror each control through
ui/pageMenuItems (sortSubmenu via pickSort, choiceSubmenu, multiSubmenu).
pageMenuScope.contract.test.ts pins the five pages.
Beside the playlist rail an 800 px window left the table 432 px: the fixed
columns took all of it and the title column was 0 px wide, its thumbnail
drawn over the channel. The table is an @container now and the columns hide
under 42rem of its own width (narrowHidden, was phoneHidden).
A list item's ring sat 2 px outside it. On a table row it ran under the
sticky head and past the table's sides; on a Plex playlist show header, flush
in its clipping card, it was cut away and the focus was invisible. A table row
(by its tag), a log line and the Plex show header and episode rows now ring
inside themselves, the show header with its card's corners.
The sticky header's fade-below was drawn at rest, over the first row's top
16 px, hiding the ring's top edge: it now fades in with the first 1rem of
scroll (a scroll-driven animation; no fade where unsupported).
The row menu took every right-click, so selected text could only be copied
with Ctrl+C. On the selection (its laid-out line boxes) the event is left to
the browser; beside it the object's menu opens as before.
A button whose text is a title or a name is now `selectable` (FocusSafeButton,
under Pressable and Button): Chrome selects inside a button once it is
user-select: text (measured), and the click that ends a selection, or a press
dragged past the slop, opens nothing. 23 surfaces carry it (cards, rows, people,
channels, playlists, users, views, messages, log lines, description links); the
Plex caption div gets the same guard. A contract test pins every plain Pressable
with its reason, so a new one has to choose.
One rule, two forms: useOrderItems (Top / Up / Down / Bottom rows of a row
menu) and OrderButtons (four buttons with the place, or a compact Up / Down
pair). A move that would go nowhere is shown off.
- YouTube playlist rows: the four moves now come from useOrderItems.
- Plex playlist rows: a block among the blocks, an episode within its show,
off while a write is in flight; drag and menu share moveBlock/moveEpisode.
- Saved views editor: the selected view's four buttons (owner: A), and the
grip's keyboard pick-up (its instructions promised one it did not have).
- Side panel groups in edit mode: an Up / Down pair (owner: A); a group the
stored order does not hold yet moves too; the name wraps there.
- The grip's touch-action: none is now test-held.
Tags A-Z as in-place checks (the menu stays open for the next); Priority
as the fixed steps 10 5 3 2 1 0, an off-scale current value ticked in its
place. A pick re-sorts the list at once, moving only that row
(placeByPriority, the server's order), and the table / card pager and
the focus follow it (DataTable and useCardPager take a reveal seq,
listNav.focusNavItem lands on the row's own "…", a virtualized card
revealed first). ActionMenu rows take check: many | one. The menu reads
the row fresh from the cache and keys its target by id.
ui/MenuCheck: a box filled with the accent for a set (a minus for a group
only partly in), a bare tick for one choice, in the row's leading slot.
Replaces seven hand-drawn marks: three box sizes, a tinted box, a cross
for "on" in the table filter, a trailing tick in the language menu.
The owner's call: sharing a download's link is the library's main errand, more
important than saving the file, so it does not hide in the row's "…". Manage
sharing stays in the menu. The "…" tests press as a mouse, so a stray long-press
timer no longer fires after teardown.
A download row keeps play (pause / resume / retry in the queue) and the save link; the editors,
sharing, re-download, the copies and cancel / delete / remove moved into its "…"
(useDownloadActions). A link inside the row keeps the browser's own right-click menu. A
notification row keeps its link and its unread mark and shows its dismiss on hover; an activity
notice's Find in feed and Undo are in its "…". The inbox band keeps Mark all read and puts Clear
everything, now behind a confirm, in its "…". The Scheduler strip keeps Run all and Pause and puts
Purge discovery in its "…". Polled lists key their menus by id (keyOf). ui/menuGroups draws a
rule only between two non-empty groups. Find in feed also drops the tag, search and age filters
that could hide the video it looks for.
The "…" told its own menu from another row's by payload identity. The Plex playlist page builds
each row's menu target afresh on every render, so a second press on the same "…" reopened the
menu instead of closing it. useContextMenu takes an optional `keyOf`, and the page keys its
targets by the row key.
- Channels: the row's full-history, hide and unsubscribe buttons become its "…" at the row end
(useChannelActions: open, pin, YouTube, copy name / link, full history or the admin reset,
hide with an undo toast, unsubscribe behind its danger confirm); the card layout docks the "…"
beside the name (ChannelLayoutGrid moreKey). The admin reset's confirm now names the reset.
- Plex playlist page: a title, a show group and a season each open the page's one row menu (a
title's is its usePlexCardActions list plus the removal); no X at rest; removals take an undo
toast whose Undo puts every row back at its own index in one request (bulk add `positions`);
j/k walk the visible rows, Delete removes the walked one; rename and delete in the header's "…".
- notify: a toast with an action lives at least 8 s (the owner's call), whatever the toast setting.
- Playlist rows: the X column became the row's "…" (play, pin, copy title/link, move, order,
remove). Remove runs at once, with no confirm; its toast's Undo puts the row back at its index.
The Delete key removes a keyboard-walked row, and focus moves on to the next.
- POST /api/playlists/{id}/items takes an optional `position`, so an undo is one request that
renumbers the list around the row.
- A new `listDelete` key scope. Only a list with `onDelete` registers it, so the help sheet does not
offer Delete where nothing handles it.
- A playlist has one list, `usePlaylistActions.items`: rename, pin, YouTube open/push/revert,
delete. The rail row's "…" and the page header's "…" both open it. Push and revert moved into
the hook; the header keeps Play all.
- Phone-width playlist table: "#" and Channel are hidden below sm (`Column.phoneHidden`). Their
fixed widths had left the title no width.
Every Plex poster list (library, Discover rails, person, show, season, collection, Home's rails)
now opens one menu per list from the right-click, the long-press, Shift+F10 and the card's "…":
play/open, watched, add to playlist, media info, copy title, the pin (usePlexCardActions +
PlexMenu, replacing the pin-only PlexPinMenu). The card keeps its state at rest and shows
watched / add to playlist / "…" on its picture on hover (ui/reveal.ts, shared with the video
card); on touch the toggles are gone and a 44px "…" sits beside the title, while the watched pill
stays. The hover ⓘ left the card; an episode's add button moved from its caption to the hover
row. Seasons get their own short menu (open, watched, add, copy), labelled "Open season" (the
card's overlay said "Open show"). Related shows are ordinary poster cards now. A collection's
admin "Remove" X moved into its menu, still behind the confirmation. The collection grid is two
columns on a phone. The unused .reveal-idle rule is gone.
- useContextMenu gains buttonProps (a row's visible "…" opens the same menu under the button; a
second press closes it) and anchor (the element the menu came from, for a panel a row opens).
- ui/MoreButton; ActionMenu rows can run inGesture (a clipboard write inside the click, Safari).
- useVideoActions: one builder for a video's menu (play / restart, channel, watched, save, add to
playlist, download, details, copy title / link, hide, reset, pin), composed by the feed and by
Home's rails; the playlists and details panels open anchored beside the card (VideoFollowUp).
- The card keeps watched / save / add to playlist and the "…": on the picture on hover (the
duration steps aside), in the rows' action column; on touch only a 44px "…" beside the title.
Download, hide, reset and the hover info card leave the card.
- The title is selectable: a click ending a selection neither plays nor follows the link.
- DESIGN.md: how C1 is built + the log row.
The optimistic layer overrode only the status, so after "Reset — clear watch progress/status" the
card kept its progress bar and its Continue button until the next refetch. Found in the E28 S3e1
UAT.
The table header carried an arbitrary-value copy of fade-below; a contract refuses a hand gradient of a surface colour. 100vh is the viewport with a tablet browser's toolbar retracted, so h-screen / min-h-screen / static vh hid the last rows under it; a contract holds dvh (the Plex player stage changes with S3f, where it is played on the tablet).
- PageShell names the page with a visually hidden h1; a view with its own title renders it as
PageHeading and the hidden one steps aside (contract: no hand <h1> inside the shell).
- Section captions are SectionLabel headings (h2, h3 in a dialog), ui/form Section included.
- "Skip to content" is the first Tab stop and focuses the scroller's <main>.
- Below 600px of height the band's filter row folds into "Filters · n" (Feed, Plex, server log),
the same row shown in place.
- The band holds controls only: Downloads and Notifications lose their title cards, the Config and
channel-discovery intros scroll with the content, the Stats tabs and the audit pager join the band,
and the server log's toolbar sticks with the fade under it.
With the labels on, the row's name was its text, badge first: 15 unread was heard as "9+ Alerts". The
name is now the visible label plus the count, as the icon-only row's is, and the drawn badge is hidden.
The palette row was ~1.07:1 on its glass (B3 names bg-card hovers a violation). ROVING_HIGHLIGHT is
exported from AnchoredPopover for the listboxes that are not PopoverItems; a contract holds that no file
paints the fill itself and every hand-built option row takes the recipe.
A native date field reports a complete date on every keystroke of its year (0002, 0020, 0202, 2026),
so one typed year refetched the feed four times. The field holds a draft while focused; leaving it
empty clears the bound, leaving it half-typed restores it.
The layer was the name button's own and ended at the row's first badge or button. The card now paints
the B3 layer under its content (isolate + a new z-under level), the name button's hit layer spans the
card, and the actions sit above it.
A Hungarian .srt beside the video (Windows-1250) was decoded as UTF-8 with
errors=replace: one episode served 1391 U+FFFD and no accented letter.
decode_subtitle now lets a BOM decide, keeps valid UTF-8, and otherwise
reads the legacy code page of the stream's language (cp1250 for Central
European, cp1251 for Cyrillic, else cp1252) — on the ffmpeg path too.
The cache has no eviction, so its key is versioned (sub2_): a track cached
broken is read again once instead of being served for good.
- F22: the control bar faded out from under a mouse parked on it; the hide timer
now waits while the pointer is on either band and re-arms on leave (test seen
RED by disabling the hold).
- The hand-drawn volume bar broke DESIGN.md A6 (native sliders): it had no hover,
focus, keyboard or screen-reader state. It is the Slider primitive now; a
pointer drag blurs it so Space and the arrows keep driving the player.
- The control tooltips are composited from mount (will-change: opacity): a fade
that promoted one on hover re-layered its neighbours under the player's
scale(1.25), and the whole row shimmered.
E28 S3d1 moved the modal's card from `.glass-card` (no blur) to the `.glass` panel role, whose
`backdrop-filter` makes the card the containing block of the fixed "full window" stage: F showed a
894×161 strip inside the card with a scrolling, wrapped control bar. While the stage is maximised the
card now drops its blur (`html .glass:has(> .player-stage--max)`, outranking the dark theme's rule);
measured live: the stage 1268×861 = the viewport, the blur back on exit.
buttonStyles/chipStyles glued the hover guard on at runtime (`${ACT}hover:text-fg`). Tailwind emits CSS
only for a class it finds whole in the source, so all 22 guarded hovers shipped with no rule: no text or
edge colour on hover for ghost/outline/danger buttons, no white layer on the player's control row, no
underline on a link button, no accent hover on token/suggest/ghost chips. The stylesheet had 0 rules
for `not-aria-disabled`.
- `@custom-variant act` (index.css): the same guard as the state layers; `:disabled` never matches an
<a>, so one variant serves buttons and links and the per-element variant maps go.
- ui/tailwindScan.contract.test.ts: every class the primitives can emit stands whole in their source.
- a lint rule refuses a variant right after an interpolation; the `enabled:hover:` rule now points at
`act:hover:`.
- F16: --rating (theme-scoped gold, 4.5:1 on the page and its chip), --rating-media, --live/--live-fg,
--media-chrome replace 14 palette literals; a lint rule refuses palette classes; the theme contract
measures them.
- The Plex player's notices drew over its open settings/tracks popovers: notices z-base, popovers z-menu.
- The player's info overlay rendered theme tokens on its always-dark panel: on a light theme the score
(2.6:1), collection years (3.2:1) and cast initials (1.05:1) were unreadable; onDark variants now.
- The lint-rule contract tests share one ESLint with a one-off warm-up (a cold first lint took 8 s,
past vitest's 5 s).
- F12 (owner: A): SwitchRow, the whole settings row toggles; a Config boolean toggles from its title
and hint (a label pointed at the switch).
- F14 (owner: B): the form dropdown trigger wears the field frame (see-through, the >= 3:1 edge);
labelled pills keep the faint edge and the accent-edge hover. DESIGN says so, with log rows.
- F6: ~15 bg-card / bg-border / opacity hovers onto the B3 state layer; a lint rule (HOVER_SWAP)
refuses the swap, contract-tested.
- F10: bare 14px icon links onto IconLink / LinkButton; a local lint rule refuses a glyph-only <a>.
- F13: reason-disabled controls are aria-disabled (Users, Channels backfill, Discovery, Scheduler
runs, Config tests); FocusSafeButton now swallows the click of a caller's aria-disabled; the
Config tests say why while dirty (testDirty, HU/EN).
- F5/F11: every control measured live at 24 (mouse) and 44 (coarse) across every page; touch-target
is a utility that wins over a caller's min-h-6; link variants carry it too; SegmentedToggle,
Tabs, NavRail, menu rows, PriorityStepper, sliders, CheckRow, InnerInput, card links and the rest.
- F9: DismissibleChip's label reaches the chip's 24; the Combobox chips are DismissibleChips.
- F15: the "+N" / "More..." open state worded as it renders.
- In-place editors (Playlists heading + rail rename, Plex collection + playlist rename, PriorityStepper,
Scheduler interval): a real Enter closed the editor, returned the focus to the trigger, and the same
keystroke's activation pressed that trigger again - re-opening the editor, or in the rail selecting
the renamed playlist. useReturnFocus.arm(e) now cancels the key's default (type-enforced); the rail and
Scheduler editors do the same. The earlier tests dispatched a synthetic keydown, which has no default.
- Plex playlist rename: an unchanged or empty title closes with no write, as the collection rename does.
- Switch: a 36x24 box around the track (the 24 floor), 44 on a coarse pointer; `label` is required, and
the Plex two-way-sync switch had no name.
- z-index: numeric z-10/z-20 and a literal 40 moved onto the named layer scale; a lint rule + contract.
- The /watch file card's Download is the filled primary: the page's one action.
- Stale comments: the 1024x768 floor, the retired glass tuner.
- SelectTrigger lays its toolbar value out as a row; the saved-view chip passes its glyph and
modified dot through the icon/trailing slots (an <svg> is display:block and broke the chip in two).
- One Escape in a dialog's open list closed the list AND the dialog: the list's close flushes
synchronously and pops its layer before the dialog's window listener runs. takeEscape() is the one
close-on-Escape test (topmost AND not yet taken); Modal, the Welcome lightbox and useLayer use it,
and keymap.dispatch ignores a taken Escape.
- /watch: the Download sits on the toolbar rung, level with the Repeat chip.
- A glass role's shadow is one token (shadow-float); the header search's drop-shadow utility had
replaced its top highlight, and the debug pill's shadow-lg did the same. The surface-role lint
rule now refuses a shadow size beside a role.
- Plex: every value that filters carries a hint (genre, certificate, studio had none), and the facts
panel's year and certificate filter like the header's. filterKeyword -> filterValue.
- Downloads: the row resized its buttons with a [&_button] override that the save link escaped
(28px beside 32px); the size is the primitive's md rung now.
An overflow-y container clips on both axes; the bar sits in the rail's padding, outside the row.
NavRailList spans that padding (-mx-1 px-1) so the bar stays inside the list's box.
- ui/Input, Textarea, InnerInput + fieldClass (A4): the >= 3:1 edge, a 2px accent
focus edge, the danger edge for invalid, md/sm/xs/heading rungs, 1rem and 44 on touch
- ui/SelectTrigger (A5): SortControl, FilterSelect, the view switcher and the date
filter on one trigger, a pill in a toolbar, r8 in a form
- ui/NativeControls (A6): Checkbox, CheckRow, Slider, ColorInput
- every raw input swept (~100 sites); inputCls retired; local/input-primitive
- useReturnFocus: an in-place editor hands the focus back to its trigger on
Enter/Escape (the playlist and Plex title renames, the priority number);
the Plex playlist rename gains Escape and an accessible name
- back to top: a round 48 FAB (owner decision); DESIGN.md A2 and the log
The second half of the button sweep: the remaining hand-rolled buttons in
feature components, the design lab and the components/ui composites now
stand on Button, IconButton, Pressable, Chip or DragHandle.
- Chip + DismissibleChip (filter / token / tag / suggest / ghost, 24px
floor, 44 on touch) replace five chip geometries; FilterChipGroup
renders Chips; overflow lanes measure a mirror painted by chipClass.
- The Plex control row is IconButton's player rung (36, 20px glyph,
round, overlay; 44 on touch); the YT modal bar is on the sm rung with
pressed cycle toggles and names that survive a folded label.
- New variants: IconButton ghost-accent, danger-soft; Button warn on a
new state-warn utility. The .btn-{tone} recipes and btnCls are gone.
- Fixed on the way: membership rows (collection editor, Plex playlist
add) announce checked/mixed; a one-letter tag chip was 21px wide;
descriptionLinks moved out of lib/ (it renders components).
- local/button-primitive: a raw <button>/<FocusSafeButton> outside the
control-primitive files is a lint error (contract-tested, seen red).
Button is rebuilt to A1/B1/B3: explicit heights (xs 24 / sm 28 / md 32, lg 40 for a hero CTA), r8 (a
pill on media), state layers instead of opacity and bg-card hovers, a `pressed` toggle, `overlay`,
`media` and `link-muted` variants. The icon-only sizes move to the new IconButton (28 / 32, 48 only for
the Play on a picture; a required label). Pressable (a card, row or tile that is the control) and
DragHandle join them. The state-* utilities now stand down on a disabled, busy or pressed control, held
by theme.contract.test.ts.
Swept onto them: the Button icon-size callers and 16 files (Plex playlist, Plex detail, the YT player
modal chrome, Welcome, Plex and video cards with AddToPlaylist and DownloadButton, the video editor,
Feed, personal views, Scheduler, Users, the nav rail, Plex detail parts, Settings): 268 raw buttons are
153. Fixed on the way: FocusSafeButton overwrote a caller's aria-disabled (the "unavailable, here is
why" state; adminInvites caught it), a season toggle and a Plex settings switch had no name/state, and
the video card's overlay follows the C5 card (Resume pill, From the start pill, a round Play).
The lime accent was the loudest of the four (OKLCH L 0.85, C 0.21) and read as neon; the owner chose the
moss variant at Midnight's lightness with near-neutral greys, and the Matrix backdrops follow it. Fixed
white/black parts on a solid accent fill (two switch knobs, three play buttons, the Up next pill) now use
--accent-fg, and theme.contract.test.ts refuses a fixed white or black next to a solid bg-accent.
Four schemes (Midnight; Forest cyan, Slate fuchsia, Matrix lime), YouTube
and Starship retired with no data migration: normalizeTheme maps a stored
retired scheme to Midnight. A vitest contract measures every scheme x mode
(text 4.5:1, control edge 3:1, status text, the accent's 30-degree hue gap);
it caught light-mode danger at 4.43:1, now red-700. The state layers, the
strong edge, the touch target and the fades are index.css tokens and
utilities the lab now reads. Backdrops recoloured to the new accents, with
a grey overlay grain that actually breaks the banding (longest flat run
15-26px -> 4-8px). The glass tuner is retired.
The owner's lean Plex control set with both refinements (Back by position,
a touch double-tap for the 10 s skip) goes into DESIGN.md's Players
chapter and the log. The lab is now the reference only.
Stop, Restart and the 10 s skips leave the row, Back restarts or steps to
the previous episode, Next shows only when there is a next item, and the
card carries Resume + From the start. The lab asks back two points: how
Back tells restart from previous, and a touch path for the 10 s skip.
C1-C4 as proposed: the action classification, overlay-drawer side panels,
touch-first players, dialog focus by purpose with a pinned footer. The
players were then played live at tablet size, which found the Plex
control row running off-screen below 1024; how it fits is open as C5.
CLAUDE.md now points at DESIGN.md.
DESIGN.md: B1-B9 decided (compact density, the four-scheme set with the
accent-vs-status rule, state layers, the sticky band, surface per role, the
watch page, the slim NavRail with optional labels, loading by shape, the
fade edges) with their decision-log rows. The lab keeps the chosen A and B
renderings as reference on shared tokens (labTokens.ts) and adds batch C:
the action-placement classification for review, side panels on touch and
below 768, the Plex player on touch, and the dialog conventions.
DESIGN.md: the Controls chapter decided (A1-A6) with the decision-log rows;
density stays open as B1. The lab keeps only the chosen A renderings as
reference and adds batch B: density, the scheme set (measured accent hue
gaps and light contrast), state layers, the page-chrome model, surface per
role, the public watch page, the nav rail, the loading form and where the
header + fade edge goes.
plugin-react recognises the React Compiler only by its package name, and
reactPlugin.ts passes the imported plugin, so plugin-react left Babel's
retainLines on in dev. With the compiler's re-printed JSX that trimmed the
space at the end of a text line (the privacy page read
"for example/api/feed" on :5173). Production builds never had it on.
Turn retainLines off through generatorOpts, which wins over the plugin's
top-level option, and pin it with a DOM test seen red without the fix.
- docs/DESIGN.md: the written source of truth for the UI. Decided chapters
(principles, device adaptation, action placement, text stays selectable,
interaction rules, copy, anti-patterns, decision log); controls and
surfaces are marked Pending until the owner picks from the lab.
- /design-lab (dev only): renders each open decision's variants with the
app's real CSS, themes and font scale. Batch A = six control decisions.
- devOnlyGuard: a production build fails if anything under src/design-lab
reaches the bundle (seen red with an unguarded import).
- Size contract: tablet portrait + landscape and up, replacing the
1024x768 floor with its never-built "too small" message.
The localdev e2e account's password was a hard-coded default in this public repo, so it
was never a credential. E2E_PASSWORD is now required; the ops wrapper exports it from
the secret store, and the seed/setup fail with a clear message when it is missing.
The P1 epic: the upload route reads its own body after `require_human`
resolves, so an anonymous caller can no longer make the server spool 500 MiB
before the auth check runs. Three deep-review fix rounds on top of it.
Deep-review round 3, targeted at the surfaces round 2's fix commit changed.
Five of the eight findings are the same shape: the guards written to stop
half-done sweeps were themselves half-done.
test_upload.py's display-field guard walked only `ast.Assign`, so the one
writer with an unbounded REQUEST HEADER behind it — `spool_upload`'s
`display_name=display_text(...)` keyword — was invisible to the guard meant
to protect it. It also stopped at `app/routes/`, missing the four writers in
`downloads/` that actually construct the row, and asserted `not offenders`,
which enforces nothing once a refactor moves the writers out of its view.
It now walks assignments AND keywords across `app/`, and holds exact set
equality against a registry of all twelve write sites with each one's value.
test_body_limit.py's release guard used `any(... ast.walk(fn))`, which
accepts a `release_read_connection` that runs AFTER the await it exists to
precede — satisfying the letter of the rule while pinning a connection for
exactly the transfer it was supposed to free. Position is checked now, and a
late release is its own, louder failure. The registry keyed sites by file
basename while scanning recursively, and `admin.py`, `discover.py` and
`playlists.py` each exist both at the top level and under `plex/` — the same
entry-collapse the registry was widened to close. Keyed by relative path now.
And all three guards recognised a body read only by the `capped_` naming
convention, so a route spelling `await request.body()` inline declared no
ceiling, released no connection, and was invisible to every one of them.
`release_read_connection`'s precondition claimed the caller "must have
NOTHING PENDING, and that is checked rather than described" while checking
only UNFLUSHED ORM work. Measured: after `add`+`flush` all three collections
are empty and the rollback loses the row, and Core DML never enters them at
all — both of them this codebase's canonical write shapes. It now also asks
the transaction directly via `pg_current_xact_id_if_assigned()`.
`errors.tooLarge` was reworded to "try a smaller file", but that key answers
every route's body cap: the issue composer's over-size JSON draft got advice
about a file it does not have. Back to payload-neutral; the file-specific
sentence keeps its own key.
The upload dialog's new danger border outranked `dragOver`, so a user
replacing an over-cap file got no sign the drop zone was live — and with a
file picked the label shows its name rather than "drop it here", so the
border was the only feedback left.
Four further findings were refuted 3/3 and are not fixed: `aria-invalid` on
the drop zone (measured in Chromium — the AX node does carry it on a native
button), a bidi-override spoof through `display_text` (the served filename
already strips those categories, and the proposed filter would have broken
every ZWJ emoji), and `capped_body` not draining (the screenshot route's edge
ceiling equals its own cap, so that refusal is unreachable, and the log sink
is sent by beacon with nobody reading the answer).
Deep-review round 2, on round 1's own fix commit. It found MORE than round 1
did (28 raw, against 13), and most of it was mine -- a new piece of UI attracts
findings a backend-only diff never does. The pattern in almost all of them is
the same: a principle applied to the site that prompted it and not to its
siblings.
* `display_text` reached three of the FIVE writers of a user-supplied name.
`POST /api/downloads` and `POST /api/downloads/edit` still handed Postgres an
unbounded string, so a 300-character name pasted into the Add-download dialog
or the editor was a bare 500 (main.py re-raises every DataError but the NUL
case). Four of the seven reviewers found it independently. The bound now lives
in `models.py`, the three columns are DECLARED with it, and the guard for the
next writer is parsed rather than remembered: every `display_name` /
`display_uploader` assignment under `app/routes/` must go through the helper.
That guard immediately found two more -- the Google sign-in callback writes
the profile name into the same 255-wide column -- so it is six, not five.
* The connection release reached one of the THREE routes that read their own
body. The screenshot route (10 MiB, from a phone) and the client log sink both
still held a pooled connection idle-in-transaction for the length of the
upload, which is the invariant db.py sizes the pool on. All three now call
`db.release_read_connection`, whose precondition -- nothing pending on the
shared session -- is CHECKED rather than described, because a whole-session
rollback discarding a caller's staged write is a mistake this repo has already
made once. `test_body_limit.py` holds the rule for the next such route.
* The AST drift registry keyed its sites by (module, limit expression), so a
second route copied from the first collapsed onto the same entry and went
uncovered -- the exact hole the registry exists to close. Keyed per call site
now, and it carries the release requirement too.
* The dialog's own refusal was announced once and never again: two over-cap
picks leave an identical node with identical text, and an untouched live
region says nothing. It is keyed per pick now, the drop zone reads as invalid
and points at the sentence, and the reason is reachable from the control
rather than only from a one-shot announcement.
* Two tests rebuilt the blind spot they were written to remove: the dialog's cap
test sat nowhere near the boundary (a `>` loosened to `>=` survived it, which
is the mutation round 1 fixed on the server side), and the header-precedence
test only covered an exact-case duplicate while a differently-cased one would
have been COMBINED onto the wire rather than replaced. Both fixed, and the
merge now dedupes case-insensitively because header names are.
Also: the upload dialog's unstubbed usage query was firing a real request from
jsdom in a neighbouring suite and leaking a delayed "connection lost" toast
across test boundaries; `GET /api/downloads/usage` had no test at all though the
whole client-side check hangs off it; and the comment claiming the edge 413 is
readable now says what was measured -- readable to 200 MiB over loopback, a bare
`onerror` through a proxy -- so the pre-send check is named as the mechanism and
that branch as the backstop it is.
Every test added or changed here was mutation-checked. One mutation SURVIVED and
is recorded rather than papered over: the `tooBig` term inside `submit()` cannot
be reached, because `ui/Button` swallows the activation itself, so it is marked
as belt and braces instead of being described as the guard.
The epic-end deep review found three defects in P1 and four tests that did not
hold what they claimed. They land together because they share the upload route
and its test module; splitting by hunk would have produced commits that do not
individually run green.
* The edge ceiling and the route's own cap are the SAME number at the default
configuration, so the edge always answers first: a declared Content-Length
over the cap is refused before a byte is read, and an undeclared body trips
the middleware on the same chunk the route would have tripped on. The route's
numbered 422 -- and the drain written for it last week -- are reachable only
once an admin LOWERS the knob, which is the one shape the browser UAT tested.
Measured: headroom does not fix this, because the declared-length check fires
first for any body big enough to matter and the drain pushes the count back
over the edge for the rest. So the edge's own refusal is made readable
instead: `errors.tooLarge` localizes `too_large` the way `upload_too_large`
was already localized, and the dialog refuses an over-cap file BEFORE sending
it, off the `upload_max_bytes` that GET /api/downloads/usage now reports.
Measured in Chrome against the API directly: that 413 arrives as `onload`
with a readable body in 43 ms.
* Reading the body behind the auth dependency put the whole transfer inside the
request's database session -- the auth SELECT autobegins a transaction, so one
of the twenty pooled connections sat `idle in transaction`, with nothing to
time it out, for as long as the client cared to keep sending. That is the
invariant db.py sizes the pool on and plex/playback.py records an incident
for. The read transaction now ends before the await. Measured on the dev
server: an 1818 ms upload shows no idle-in-transaction sample while the bytes
move, only afterwards, when the handler actually writes.
* The display name moved into a request header, where nothing bounds its length,
while the column stayed varchar(255) -- so a long enough name was a DataError
500 after the whole file had already transferred. One `display_text` helper
now serves all three writers (the metadata PATCH already truncated, the upload
ingest did not), and the dialog stops at the same number.
* The gate invariant asked `route.dependant.body_params`, which holds only the
route's OWN callable's parameters: a body declared in a SUB-DEPENDENCY -- the
upload route's own idiom -- leaves it empty while FastAPI still reads the body
before auth. It asks `route.body_field` now, which is what FastAPI itself
consults. The AST registry of body-reading call sites had also never followed
`capped_stream_to`, so the one route the whole invariant exists for was the
single site the drift guard could not see.
Four tests that passed over their own mutation are fixed: the cap boundary (a
`>` loosened to `>=` survived the entire suite), the percent-decoding (every
filename the suite sends is ASCII, where quote() is the identity function), the
display-name header (no test at all), and the staging cleanup (deleting the
unlink left the suite green while every refused upload leaked its partial bytes
onto the volume). Every test added or changed here was mutation-checked.
Found in the browser UAT, not by the gate. Moving the cap onto the arriving
bytes meant it fires while the client is still sending, and answering + closing
mid-upload is what XHR reports as a network failure: the upload dialog said
"the server couldn't be reached" while the access log showed a correct 422
carrying the limit. A refusal the client cannot read is an outage as far as the
user is concerned.
`capped_stream_to` now reads the remainder to nowhere before raising, so the
client finishes sending and reads the real answer -- re-measured in the
browser, which now names the limit. Nothing past the cap is written, and the
request is still bounded by bodylimit's ceiling, so the cost is bandwidth an
AUTHENTICATED caller was sending anyway. The anonymous path is untouched:
auth still resolves before a byte is read.
The regression test sits on the helper, not the route, and the reason is worth
keeping: the route-level version was written first and PASSED with the drain
deleted. httpx's ASGI transport pumps the request body whether or not the app
reads it, so "drained" and "abandoned" are indistinguishable through
TestClient. Its own mutation check is what exposed it.
Its own entry in requirements.txt said the download center's local-file upload
was "the only multipart endpoint in the app". Since that route reads a raw
body, the claim is false and the package is dead weight: no route declares
File/Form, and nothing calls `request.form()`.
Removed from requirements.txt and from its single pinned line in
requirements.lock. The lock is generated and normally regenerated wholesale,
but a full regeneration would bump every transitive dependency, which has no
business riding inside a security fix -- the next legitimate regeneration
reproduces exactly this state, since the package is gone from the source.
The `multipart` entry in logs.py's third-party level map goes with it: a level
for a logger that can no longer exist.
`POST /api/downloads/upload` declared `file: UploadFile = File(...)`, and
FastAPI parses and spools a declared body before the first dependency runs --
`require_human` included. An unauthenticated caller could therefore make the
process write the whole per-file allowance (500 MiB by default) to temp, once
per request. SEC-20's ceiling bounded that number but could not stop the
writing, which is why E27 parked it rather than closing it.
The route now takes no body parameter. `spool_upload` names `require_human`
first and only then streams `request.stream()` to the staging temp file, so a
stranger is refused before a byte moves -- the shape the screenshot route has
used since E16. Filename and display name travel as percent-encoded headers
(latin-1 on the wire, so a Hungarian filename needs the encoding) instead of
multipart parts.
`capped_stream_to` joins `capped_body` in routes/_common.py: the same counting
rule, written to a file descriptor rather than accumulated, because holding
500 MiB would trade a disk problem for a memory one. Its refusal is the
caller's, so the upload route keeps answering 422 `upload_too_large` with the
admin knob's own number.
With no envelope to allow for, the edge is now exactly that knob, and
MULTIPART_ENVELOPE_ALLOWANCE is gone.
The invariant stops being prose: test_body_limit.py asks FastAPI itself
(`route.dependant.body_params`) and fails the gate if any route with a raised
ceiling declares a body. A comment could not have prevented the defect it
described; that test would have.
Both new tests were seen red for the right reason -- before the fix the
anonymous probe got its 401 only after the server had read 4236 bytes of a
multipart body, and re-declaring the File parameter turns the invariant test
red naming the route and the parameter.
Two findings, both in the review's own work; nothing in the product code. That is
the shift that ends the loop: 22 → 13 → 6 → 2, and the last two are a test and a
comment rather than a defect.
The "an override that is fine is still honoured" leg of the bounds-fallback test
stored `spec.max` — which, for this key, IS the env default, because the spec's
max is derived from it. So the assertion was satisfied by the fallback it existed
to rule out: mutating `_coerce` to reject EVERY int override left all three legs
green. It stores `spec.max - 1` now and asserts the value differs from the
default, so the leg can actually fail.
And the shrink test's comment claimed `3 - 5 = -2` for an expression that computes
`chips.length - visible` = `2 - 3 = -1` — naming neither operand nor the result,
in a comment that was rewritten one round earlier precisely for miscounting.
Round 3 over round 2's fixes. The trend is 22 → 13 → 6, and the character has
shifted: these are mostly defects in the REVIEW's own work, not in the epic.
The ordering test I added to hold the middleware's position could not fail for
either arrangement it names. All four `@app.middleware("http")` layers — the
access log, the two header decorators AND the setup gate that must be inside —
register as the same `BaseHTTPMiddleware`, so asserting on the class passes for
the pre-move stack and for a cap moved back outside CORS. It names the layers by
their dispatch function now, which is the only thing that distinguishes them.
The `sysconfig` bounds check CLAMPED, and clamping is a silent rewrite — the thing
this module refuses to do everywhere else, where an unusable override falls back
to the env default and logs once. On the max side the difference is cosmetic; on
the min side it is a live bug. `download_upload_max_bytes` had `min=0` before E7,
and a stored 0 means "no per-file cap" to the route that reads it. Clamped to 1 it
becomes a ONE-BYTE cap: uploads dead, nothing in the log, the admin page showing
"1". It raises `OutOfRange` now and takes the existing fallback, with its own
event code.
The `capped_body` sweep grepped for an identifier as the limit argument, so a site
written `capped_body(request, 4 * 1024 * 1024)` — or with the parameter not named
`request` — was MISSED, and a miss reads as "no new sites". Parsed with `ast`
instead: every call is seen whatever it looks like, and an unfamiliar argument
shape fails the equality instead of vanishing.
Frontend: the shrink test drove the mock in a way the real hook cannot produce
(it moved `visible` in the same step as the shrink, and the comment miscounted the
tags), so it reproduced the right END STATE by accident rather than the sequence.
`visible` is now left alone across the shrink, which IS the precondition.
Ops: the baseline guard answered "did this run rewrite it?" and therefore went
green on the second run over an already-tightened, uncommitted baseline — the
exact state its own comment says marks the localdev stack stale ever after. It
asks both questions now. And `.dockerignore` joins BAKED_PATHS: it decides what
the COPY lines actually take, so it is an input to the build context exactly as
the copied trees are.
`frontend/tsconfig.tsbuildinfo` was swept into the previous commit by a `git add
-A`. It is a machine-local incremental artifact carrying absolute Windows paths,
no lane reads or regenerates it, and it was already stale in the commit that added
it — it does not list the contract test that same commit created. Untracked, and
`*.tsbuildinfo` ignored, because the day anything does start rewriting it a green
gate would leave a tracked file dirty: the exact hazard the basedpyright baseline
just got a guard for.
And CI ran bare basedpyright while the local lane gained that guard, so a
burn-down would have been green on the server and red on the developer's machine
over the same tree. Two gates, one tree, one answer.
The reset I added guards on `hidden === 0`, and `hidden` can be NEGATIVE.
`useChipOverflowFit` keeps `visible` in state and re-decides in a layout effect,
so the render in which the chip set SHRINKS still carries the previous
measurement — delete three tags under an open "+N" and `chips.length - visible`
goes to -2, the equality never matches, and the flag survives the one window the
reset exists to close. The next re-fit brings the menu back on its own, which is
the original defect with an extra step. Clamped at all three lanes, with a test
that drives the shrink through the stale value.
Also: the Scheduler characterization banner said "the four tests below" and now
sits above six, the last two of which are genuine regression guards for the
range-refusal fix. A future mutation check would have read it, seen them go red,
and concluded the harness was broken rather than that the guard worked.
A second review round over the first round's fixes, which is where the remaining
real bugs usually are. All four are the same failure as the round it reviewed: a
claim stated in a comment that the code does not hold.
The middleware moved inside CORS, but the setup gate is registered LATER and is
therefore OUTSIDE it — so the comment's "the setup gate never runs for a body that
is already too large" was false the moment it was written. On an unconfigured
instance that gate opens a DB session per request, which is exactly the anonymous
pre-auth work SEC-20 exists to remove. The cap now sits between them, and the
order is asserted against `app.user_middleware` rather than described: prose about
a stack that `add_middleware` builds inside-out is how this got backwards.
The edge ceiling and the route's own cap were pinned EQUAL, but they measure
different things — the edge counts raw body bytes, the route counts the decoded
file. A file of exactly the advertised maximum was refused one layer before the
route that would have taken it. The edge now allows for the multipart envelope,
and the test asserts the relation and the gap instead of equality.
`sysconfig`'s new `max` was enforced on write only. That bound did not exist
before this epic, so a value that was legal when it was written can be sitting in
the database — served unclamped it produces exactly the drift the comment says is
impossible, with the admin page showing a number outside its own range. Int
overrides are clamped on read now, which also survives an env default being
lowered later.
And `DOWNLOAD_UPLOAD_MAX_BYTES=0` meant "no per-file cap" to the route but a hard
zero to the edge: every upload refused, including the admin page's own way back,
recoverable only by editing env and restarting. The setting takes `ge=1`.
Two test gaps from the same round: the header assertion that is the ONLY check of
the middleware's position was DB-gated and skipped on a host without Postgres, and
the headroom "ratio" the docblock calls load-bearing was held as `<=`, i.e. no
ratio at all. Both fixed, plus a sweep that every `capped_body` call site in the
routers is known and fits under its ceiling — the point of that list is the third
entry, not the two that exist today.
react-i18next was still on ^14.1.2. The E27 S1 plan named it as "the one peer that
actually needs moving — React 19 support is the 15 line", and then it did not
move. The 14 line's <Trans> reads `element.ref`, removed in React 19, and its
withTranslation types are built on the GLOBAL JSX namespace @types/react 19 no
longer declares — both latent only because this app imports neither. 15.7.4 fixes
both at the source (`c.props.ref ?? c.ref`, `React.JSX.LibraryManagedAttributes`).
`reactEngine.contract.test.ts` joins its Node and Python twins and holds what was
only a comment: the compiler's `target` against the installed React major, react
and react-dom together, the types against the runtime, and react-i18next's floor.
A stale target is not a corrupt bundle — the plugin validates the option — but it
is a silent one, since tsc never sees Babel's output.
pythonEngine gains the seventh spelling nobody held: `requirements.lock`'s own
regeneration recipe names a python image, and wheels resolve against THAT
interpreter, so a recipe left behind yields a lock that does not match the image
installing it.
The type lane now covers `alembic` too, for the reason the ruff lane was widened
to it — migrations run on every container start, so one that raises crash-loops
prod. It came back at zero new errors, so the baseline is untouched. `tests/` is
deliberately still out, and the config says why.
vitest.config's docblock claimed the React transform is "a no-op for the plain .ts
node tests". It is not: the compiler rewrites src/lib's hooks in that lane too,
which is the point of sharing one factory with the build — and worth knowing
before ruling the compiler out of a divergence.
Tests: useCardPager had none, in either the hook or its two callers, though its
page clamp moved to render in S2 — the transformation where a non-convergent
condition is a crash rather than a glitch. Four cases now, including termination
on an empty list. Combobox's and Avatar's close/failed-URL cases are relabelled
CHARACTERIZATION: both pass against the pre-sweep code, and the tests beside them
are the ones that discriminate.
The module admitted its two opt-outs under a stated rule — "both enforce their own
limit on the bytes they actually read, which is what makes opting them out safe" —
and that rule is false for one of them. `POST /api/downloads/upload` declares
`file: UploadFile = File(...)`, so FastAPI parses and spools the whole multipart
part to a temp file BEFORE the first dependency runs, `require_human` included:
its in-handler cap measures bytes that have already landed. Measured on the
installed fastapi 0.141.1 / starlette 1.6.0 — starlette's max_part_size guards
non-file parts only, so an anonymous chunked upload was bounded by nothing. In the
shipped self-host compose that lands in a RAM-backed tmpfs.
The exposure predates SEC-20 (no route was capped before it); what this branch
added was the claim. So the list becomes a mapping of per-route CEILINGS instead
of a set of exemptions: every request now gets a finite number, the upload route's
being the same one the route itself enforces. `sysconfig`'s knob is bounded by it
so an admin cannot raise the handler's cap past the edge and start collecting 413s
from a layer the admin page never mentions — held by a test, not by prose.
Two more things the review caught in the same module:
The 413 was not byte-identical to `capped_body`'s after all. `json.dumps` puts
spaces where JSONResponse does not, and the test that promised to hold them
together compared PARSED JSON, so it could never have seen it. Built through the
same JSONResponse now, and compared as raw bytes.
And the middleware sat outside CORS and the security headers, so its 413 carried
neither — on the split-origin dev setup the browser drops it and the user gets a
generic network failure instead of the translated too_large, while the identical
refusal from capped_body one layer down renders fine. It moves to just inside
CORS: still ahead of the session, the setup gate and every auth dependency.
Also corrected: the docblock's "largest non-exempt body is 64 KiB, ~16x headroom"
(it is 512 KiB and 2x — the diagnostics sink, named eleven lines further down),
and CLAUDE.md's Request SIZE row, which forbade the one correct use of
capped_body that is actually in the tree.
Both refused an out-of-range value by discarding it: the entry vanished, the box
snapped back to the server's number, and nothing said whether the save had
happened, been rejected, or been ignored. That is the house rule about honest
states, applied to a write instead of a read. They now keep the draft, mark the
field aria-invalid and name the range (HU+EN), and the Save control is disabled
rather than silently inert.
The interval editor also dropped a keyboard user's focus on every exit — Escape,
Cancel and Save all unmount the focused input — so it hands focus back to the
pencil that opened it, the pattern IssueComposer already uses.
Tests: the four interval-editor cases added in S2 are relabelled CHARACTERIZATION,
because they pass against the pre-sweep code too (the old effect was already
guarded by `editing`). The editor that genuinely lost typing to a poll is the
maintenance batch box, whose old effect had no guard at all, and it had no such
test — it does now, anchored on a value that follows the server so it cannot pass
on a page that simply has not polled yet.
Every one of them is the same shape: a value derived correctly, and a WRITE to
the thing it derives from left unattended. Deriving hides a stale value; it does
not consume it.
PlayerModal — the watch toggle inherited the previous video's auto-watch. Moving
the queue-advance sync into render was right for `currentVideoId`, but the same
block also synced `status`, and that one had an ordering constraint of its own:
useYtProgress's cleanup for the OUTGOING item can still call setWatched(true)
near the end of a clip, and cleanups run after the commit. As an effect the sync
landed last and corrected it; from render it lands first and the stray write
wins. Press Next in the final ten seconds and the NEXT video showed as watched —
one click on the toggle then sent onState(next, "new") for an item already new.
Fixed by keying the mirror to the id it describes, so a write for another item is
ignored rather than displayed, and no ordering has to be argued about at all.
ChannelsProvider — a focus-channel intent was masked by the page rather than
consumed, and nothing ever cleared it. The manager is a page chunk: it unmounts
on every navigation, and its seed effect runs again on each mount, so returning
to it re-applied a channel name from earlier in the session over whatever the
user had typed since. The consumed marker now lives in the provider, keyed on the
token — which also closes the case the old clear-on-leave effect never handled,
opening a channel page (that does not change `page` at all).
TagFilterChips + ChannelTags x2 — `open = openRaw && hidden > 0` hides the
overflow popover when the lane stops overflowing, but floating-ui only reports a
close for a real event on a mounted surface, so the raw flag stayed true and the
menu came back by itself on the next re-fit — a window resize or the text-size
slider is enough, with nobody near it. The flag is now reset in render against
the same condition that hides it.
Avatar + downloadRow — remembering a failed image BY URL fixed the recycled-row
frame but never forgot it, so a transient 429 pinned a channel to its initial for
the life of the instance, never re-requesting. The memory is now dropped once
`src` moves off it, which keeps both properties.
And playerModalHooks' last exhaustive-deps suppression claimed "nothing in the
body reads a render value" while reading five. The invariant that is actually
load-bearing is that each of them is a per-item snapshot — written down instead.
All four fixes are mutation-checked: each new test was seen red, against the
fixed line only, naming the real defect.
Closes the burn-down. Hook suppressions 113 -> 34, `exhaustive-deps` 45 -> 2 (the target was
<10), and every one of the 34 survivors is an inline directive NAMING the invariant it
protects. The suppressions file is deleted, because a bulk file hides the next violation
instead of answering it — and it cannot hold a warning-level rule at all.
The sprint plan's three verdicts missed the answer that turned out to be the most common one:
adjust the state DURING RENDER. React re-runs the component and discards that pass rather than
committing it, so the stale frame an effect-based reset paints does not exist. Applied to the
Plex player's up-next card, the playlist view reset, the feed's optimistic overrides, the
channel banner's retry count, the download center's tab guard, the Plex search highlight, the
comfort-pin latch, Welcome's per-view outcome and the card pager's page clamp. It is legal only
for a component's own state — PlexBrowse's language commit resets PlexProvider's filters, so
that one stays an effect and says why.
Mirrors of a prop or query became derived values or `draft ?? server`:
- `downloadRow` had ui/Avatar's defect exactly — a boolean "poster failed" that an effect reset,
in rows recycled down a virtualized list, so one job's missing poster painted over the next
one's for a frame. Keyed by URL now.
- Scheduler's two number editors held a mirror an effect re-synced, on a page that POLLS — the
shape a poll can overwrite mid-edit. A draft while editing, the server's value otherwise; six
tests where there were none, the one behavioural change (the save button stops being dirty at
commit rather than at the echo) mutation-checked.
- SetupWizard's "no token" and WatchPage's "gone" are facts about the URL, so they are seeded and
derived rather than pushed into state by an effect that had to commit first.
Elsewhere: PlexInfo's rating chip was a component declared during render — a new component type
every render, so React remounted the subtree and dropped the focus on its filter button. Hoisted.
Feed's undo action and PlexPlayer's session reclaim both re-entered their own useCallback, which
the checker reads as a forward reference; both go through the ref idiom this file already uses.
BootGate's dep list was load-bearing for correctness — a re-run meant `location.reload()` — so
the "already adopted THIS identity" comparison now enforces it in code, with a test that fails
without it (the existing reload tests all install their spy too late to catch it).
One correction to the batch before this: keying ChatThread's and Messages' decrypt passes on
`q.data` instead of `q.dataUpdatedAt` was wrong. react-query's structural sharing makes it safe
in production, but the component must not melt down when something hands back a fresh object per
render — and Messages.keyboard.dom.test does exactly that, so the effect span the suite. Both are
back on the fetch as the trigger with the payload behind a ref, which is honest AND loop-proof;
the replaced comment had warned about precisely this.
CLAUDE.md gains four canonical-patterns rows for the above, and its "React 18" line is now true.
The interesting part of this batch is a change that was WRONG and the test suite said so.
PlayerModal synced its local watch-status mirror and `currentVideoId` from an effect on
`active.id`. Moving that into render is right — as an effect there was a frame per queue advance
showing the new episode's chrome over the previous item's watch state, with `navigated` still
true so the "you have navigated away" affordance flashed. But the same effect also advanced
`currentIdRef`, and that line must NOT move: useYtProgress's per-item cleanup persists the
OUTGOING item's position and guards it on `currentIdRef.current === id`. React runs every
cleanup before any setup, so in an effect the ref still names the item being left. Advanced to
render time the flip happens first, the checkpoint is skipped, and stepping next→prev back to an
item resumes from the frozen launch queue's stale position. "resumes an item played earlier in
the SAME session" caught it immediately (748 instead of 830); the ref write stays in its own
effect with the ordering written down.
The rest:
- PlayerModal's keyboard handler is a useEffectEvent, so the "bound once, reads live state"
contract is enforced instead of asserted. `focusModal` is a useCallback now — it reads a ref
and nothing else, and three hooks take it as a parameter where a fresh identity would spread.
- PlexPlayer's session reclaim called `loadSession` from inside loadSession's own useCallback,
which the checker reads as a forward reference. Routed through a ref, the file's existing
answer for exactly this (detachSessionRef, cancelAutoSkipRef).
- PlexPlayer's item-load effect excluded `prefs` by hand so a track pick from the player's own
menu would not double-load the session. The two language prefs go through a ref instead —
"latest value, not a trigger", said in code rather than in a comment.
- PlexPlayer closes its up-next card during the render that changes `id`, not a commit later.
- The one PlexPlayer directive that stays is the live session counter, whose cleanup must bump
the CURRENT value: the rule's advice to copy it into a variable is precisely wrong there, and
the comment now says why rather than pointing at another comment.
Suppressions 75 -> 70; exhaustive-deps 20 -> 17.
useHistorySubview returned a fresh object with a fresh `open` on every render. Correct, but it
made "re-run when the sub-view moves" unwriteable: PlexBrowse had three effects that wanted
exactly that and had to disable the dependency check to get it, and a fourth that listed `sub`
honestly and therefore re-ran on EVERY parent render, saved only by a ref guard.
Fixed at the source, which is where the house rule says a principle belongs: `open` and `back`
are useCallbacks and the returned object is memoised. `url` is a module constant at every call
site (PLEX_SUB_URL, MODE_URL) and the body otherwise touches only module functions, so there is
nothing left to churn on. All three PlexBrowse dep lists are now the honest ones, and the
comment that claimed `open` was "behaviourally stable" is replaced by it actually being stable.
PlexBrowse's remaining four are escape hatches, and two of them are now ONE:
- `infoScrollRef` is written from four places, and the compiler objects to a ref being modified
outside the effect that reads it. That objection is a conservative false positive — a scroll
memory needs exactly that arrangement — but four bare mutations were worth concentrating
anyway. One `rememberInfoScroll` writer now holds the "stash on the way out, reset on a fresh
page" rule, and answers the checker once instead of three times.
- The language-commit effect cannot move into render: it resets PlexProvider's filters, and a
render-phase setState may only touch the component's own state. Said so at the site.
Suppressions 78 -> 75; exhaustive-deps 23 -> 20.
Eleven suppressions across the five providers. The recurring shape here was "an effect that
undoes something when you navigate away", and every one of them ran a commit LATE — the new
page painted once while the old module's state was still live.
Adjusted during render instead (React discards that pass rather than committing it, so there
is no in-between frame):
- SavedViewsProvider reset the sticky view and closed its modal from an effect on `source`, so
the first Plex render carried the feed's sticky id — long enough for a stale view chip. It
also followed the active view into stickyId from a second effect; that one converges by
construction, since preferSticky returns the sticky match when there is one.
- NavigationProvider ended a live YouTube search from an effect on `page`. Keyed on the page
CHANGE rather than derived from it, because "ended" has to be permanent — deriving would have
quietly brought the search back on return to the feed.
- ChannelsProvider's manager focus is now simply DERIVED from the page: it means nothing off
the Channels page, and focusChannel sets it and navigates in one handler.
Honest deps by freezing what is genuinely mount-time:
- FeedFiltersProvider persisted a share link's filters with an empty dep list; listing `filters`
honestly would have re-persisted on every edit and spun. The mount value is frozen into its
own state, so all three deps are stable and the effect says what it does.
- ChannelsProvider's URL-seed layout effect and NavigationProvider's initial history stamp get
the same treatment. The three slice setters they call were already stable useCallbacks
(useFilterState / useSortState / useAccountPersistedState all return one).
- SavedViewsProvider reads `cfg` through a ref: it re-memoizes on every filter edit, and the
per-source seed guard already enforces once. That also made the set-state finding go away —
with the render value gone, the checker can see the seed for what it is.
WizardProvider keeps its one escape hatch, now inline with the invariant: auto-opening is a
one-way decision taken from server data as it arrives, and deriving it would reopen a wizard
the user had just closed.
Suppressions 89 -> 78; exhaustive-deps 27 -> 23.
Ten more suppressions, and React 19 turns out to supply the missing primitive.
useEffectEvent (stable since React 19.2, which S1 brought in) is the canonical answer to the
"bind the listener once, but read the latest values" pattern these hooks were disabling the
checker for. Three of playerModalHooks' four escape hatches become honest dep lists: the
wheel-volume handler, the native-controls handover, and the fullscreen-change sync. The
contract is no longer prose — the compiler enforces it.
The fourth stays, deliberately, and now says why at the site: the YouTube player's lifecycle
effect must run once per video (re-running destroys and rebuilds the iframe), and YT.Player
takes its callbacks as an object literal, which an effect event may not be passed into.
Fixed, not suppressed:
- useDismiss captured onClose when the popover opened, so a caller that re-rendered with a
different handler while it stayed open kept getting the OLD one called. The disabled dep list
asserted the opposite ("read fresh from this run's closure"). Both the handler and the ref
list are read through refs now; three tests, the two that matter mutation-checked.
- usePref wrapped its seed in a useCallback whose dep list had to be excused. useState calls a
lazy initializer exactly once, so the memo was saving nothing; inlined.
- useCardPager clamped an out-of-range page from an effect. Adjusted during render instead —
React discards that pass rather than committing it, so no out-of-range page is ever painted.
- plexPlayerHooks' auto-skip countdown reads its marker from the ref that already existed, so
the dep list names its real inputs. That also made the set-state-in-effect finding go away:
the progress is genuinely timer-driven, and the checker can now see that.
- useUrlMirror and useChipOverflowFit only needed their stable refs listed.
Two test-harness suppressions (a render-phase probe ref, a module-level setState handle) moved
inline with the reason they exist: both exist to observe a hook from outside without changing
what is being measured.
Suppressions 99 -> 89; exhaustive-deps 35 -> 27.
Burns the first 14 of the 113 hook suppressions. Three verdicts, applied per site:
A useEffect that should not exist (6):
- Avatar remembered "the image failed" as a boolean an effect reset on src change. The
component is recycled across items in the virtualized feed, so after one avatar 429s the
next item's slot committed the fallback initial over a perfectly good image and only the
post-paint effect cleared it. The failure is now keyed BY URL; derived state cannot be stale.
- Combobox cleared the typed query from an effect on `open`. Moved into setOpenAnd, the single
funnel every close goes through. That also stops a spurious onQueryChange("") AT MOUNT, which
told a controlled caller its own initial query had been cleared before the user touched it.
- CommandPalette reset query + picked row from an effect. Closed now means UNMOUNTED, not
hidden, so a fresh open starts blank because mounting already means that -- and the ranking
memo over every channel, playlist and saved view stops running while the palette is shut.
- Pager's page box held a copy of the page that an effect re-synced; it now derives it and
keeps a draft only while being edited.
- App's ?link= / ?oauth= / ?admin= one-shots asserted "once per mount" with an empty dep list.
The ref guard the codebase already uses in 22 places ENFORCES it, so the honest deps can
include `t` -- which a language switch legitimately changes, and which would otherwise have
re-fired the toast.
Honest deps that also fix something (5):
- App's module preload was keyed on the account id, so a permission change that alters the
module set without moving the id never re-preloaded. `preload()` is idempotent, so depending
on `me` costs nothing.
- App's access-request nudge now re-issues on a language switch; it used to stay in the old
language until the pending count changed.
- App's diagnostics effect: applyDiagnostics already no-ops on an unchanged effective level,
so picking four fields out by hand was guarding nothing and missed any field added later.
- DataTable's two filter effects: a ref holds the latest onFiltersChange so commitFilters can
be stable, which is what lets the effects name it instead of switching the check off.
Genuine escape hatches (7) moved OUT of eslint-suppressions.json into inline directives that
name the invariant at the site -- the toast handover's render-phase global, the live region's
deliberate arming tick, the docked one-slot component, the popover engine's floating-ui list
state, dnd-kit's per-drag `moved` flag, BackToTop's DOM read and the palette's debounced
announcement. A reader of the code now sees why; a JSON file no one opens does not.
Tests: Avatar (3, new file) and Pager (3) pin the derived-state frames with a layout-phase
probe -- layout effects run before any passive effect, so they read the frame the browser would
paint. Combobox (2) pins the close-clears-query contract and the no-callback-at-mount rule.
All three sets mutation-checked against the effect-based originals.
Suppressions 113 -> 99; exhaustive-deps 45 -> 35.
The compiler auto-memoises, which is the point: it infers what the app's
112 `useCallback` and 48 `useMemo` call sites were hand-written for and
applies the equivalent itself. It is on repo-wide with no opt-out list —
it bails on anything it cannot prove safe, and says so through the lint
rules. Verified in the built bundle rather than assumed: 369
`react.memo_cache_sentinel` markers across 35 of the 76 chunks.
ONE transform, shared by vite.config.ts and vitest.config.ts through
reactPlugin.ts. Not two `react()` calls: if the compiler ran only in the
production build, every vitest run would exercise code the user never
receives and a compiler-induced change would ship green — the same
gate-vs-image split 0.93.2 was released to fix. The plugin is imported
rather than named by the usual string, because a string is invisible to
knip and, worse, a typo in it is a silent no-op that ships an uncompiled
bundle, while a bad import is a tsc error.
eslint-plugin-react-hooks 5 -> 7 brings the whole compiler rule set: 283
findings, triaged rather than bulk-suppressed, because the two halves
needed different answers.
react-hooks/refs (217) is OFF. 153 of those 217 are this app's popover
architecture, not its history: 79 are @floating-ui/react's documented
`pop.refs.setReference` / `getReferenceProps` callback-ref setters, and
74 more are passing the popover handle down as a prop, which is what
components/ui's popover engine is for. Suppressing them would not help
the next popover anyone writes. The remaining ~64 are genuine
render-phase `ref.current = x` writes and are real, but the rule cannot
tell them apart here; they are E27 S2's work.
The other 66 go into eslint-suppressions.json as a ratchet — new
violations fail the gate, and `--prune-suppressions` makes a fixed one
fail too until it is removed. 45 of them are `set-state-in-effect`,
which IS E27 S2's subject, so that sprint now has a measured worklist.
react-i18next deliberately stays on 14. It never declared a React-19
peer — every version from 14 to 17 asks only for `react >= 16.8.0` — and
what the newer lines actually require is i18next 25/26 against our 23.
That is an i18n runtime migration wearing a React hat, and it buys this
sprint nothing.
Two React 19 type changes: `useRef<T>(null)` is now `RefObject<T | null>`
(the honest shape — the element is null until it mounts), so
PlexPlayerSettings' prop follows; and reactPlugin.ts joins
tsconfig.node.json's include.
The compiler skips memoising VirtualGrid and reports it: TanStack
Virtual's `useVirtualizer()` returns functions that cannot be memoised
without going stale. That is the right trade for a component whose cost
is its row window, and it is the one such site in the app, so it carries
a targeted disable with the reason — a second incompatible library
anywhere else must still stop the gate.
Nine lanes green, vitest 1558. Browser UAT on the dev server against the
still-running React 18 build as a control: boot, feed (virtualized grid,
popover menu, a filter change that moved the list 195,187 -> 64 items
with the URL mirrored, so no stale memoised UI), command palette, player
modal — no console errors on any of it.
0.93.2 shipped a week's worth of confusion about which Node this repo
runs on; the Python side had the identical shape and nobody had looked.
Prod (Dockerfile), the test image (Dockerfile.test) and CI (check.yml)
each spelled 3.12 as an independent literal, there was no
`.python-version`, no `requires-python`, and ruff had no target-version
so it judged our syntax against some other interpreter. The dev machine
had no 3.12 installed at all — only 3.13 and 3.14 — so every ad-hoc host
script ran two majors ahead of production.
`.python-version` is now the single source of truth, and the contract is
held by tests rather than by prose. It is split in two because no single
runner can see every link: the pytest suite runs inside the test image,
which mounts backend/ alone and cannot read the repo-root Dockerfile or
the workflow.
test_python_engine_contract.py : running interpreter == pyrightconfig
== ruff.toml
pythonEngine.contract.test.ts : .python-version == pyrightconfig ==
ruff.toml == Dockerfile ==
Dockerfile.test == check.yml
Together they close the loop, and the backend half carries the assertion
that pays — that the interpreter running the suite is the pinned one. A
green suite on another minor does not mean the shipped image is green;
it means they have drifted and nobody has noticed yet.
Three tsc projects held the frontend and NOTHING held the backend: ~36k
lines of `app/`, annotated in 113 of 128 files, that no lane had ever
read. Asymmetric by accident, not by decision.
basedpyright, in basic mode. Measured against mypy on this tree with the
dependencies actually installed — without them basedpyright errors on
every import and mypy's --ignore-missing-imports quietly turns them into
Any and UNDER-reports. Both land ~190-200 errors with the same
distribution, so the choice fell to what differs: basedpyright is ~2x
faster (4.4s vs 7.4s, and this runs on every `siftlode lint`), resolves
our `app.*` imports from its own config with no MYPYPATH, and has a
first-class `--writebaseline`. That last one is the ratchet itself;
mypy would have needed a third-party tool for it.
198 pre-existing errors are baselined, so the lane fails on NEW errors
only and the list burns down epic by epic. A green lane means no new
damage, not a clean codebase.
It runs in the pytest test image rather than on the host, and that is
forced rather than chosen: `requirements.lock` cannot be installed on
Windows at all — uvloop refuses to build there — so a host venv could
only ever be the lock minus a package, and a type checker without the
real third-party packages reports nonsense rather than nothing. The
image installs the pin from requirements-dev.txt, so the lane's version
IS the pin and there is no host copy to drift. The cost is that
`siftlode lint` now needs Docker, which it did not before.
FastAPI reads a declared body IN FULL before the first dependency, so
`POST /auth/password-login` drained an arbitrary upload from an anonymous
caller and only then answered 401. 70 of the 72 body-declaring routes
behaved the same way: one unauthenticated client could spend the
process's memory and time at will.
A raw ASGI middleware, not `@app.middleware("http")` — BaseHTTPMiddleware
cannot wrap `receive`, so it could only trust `Content-Length`, which a
client may omit (chunked) or understate. This refuses an oversized
declaration outright and counts the bytes of everything else as they
arrive, the same rule `capped_body` already applies per-route. Registered
directly inside the access log: a refusal is still logged with its
request id, but the session, CORS, the setup gate and every auth
dependency never run for a body that is already too large.
Two routes opt out, under one stated rule: an exemption is ONLY for a
route whose own cap is larger than the global one. That is why
`/api/diagnostics/events` is absent — it caps itself at 512 KiB, below
the ceiling, so exempting it could only widen what it accepts. The list
names route templates and a test resolves each against the live route
table, so a rename fails the gate instead of silently dropping an
exemption in prod.
The 413 is byte-identical to `capped_body`'s and the test compares the
two against each other rather than against a literal — two shapes for one
fact is how the `cancelled`/`canceled` bug happened.
Declared side by side, so they read as parallel; the lab runs a single
act_runner with one slot, so the backend job starts at the exact second the
frontend one ends. Measured across runs #86-#91: ~4 + ~6 = ~10 minutes from the
push, not ~6.
Written down because believing the comment cost something real — the ops
release read-back budgeted the max instead of the sum and timed out on its
first live run, reporting a still-running gate as a failing one.
A release pushes main and then the v<ver> tag, and a bare `on: [push]` treated
the tag as a second push of the identical commit — every release ran the whole
gate twice to the same verdict (runs #81-#90 are five such pairs).
It has to be `branches`, not the `tags-ignore` this obviously wants to be:
Gitea's matchPushEvent requires every named filter to match and each one bails
on the wrong ref kind, so `tags-ignore` rejects every BRANCH push and silently
disables the gate. Measured in go-gitea v1.27.3, where it differs from GitHub.
A trigger filter that stops firing is invisible, so the ops `release` now reads
the verdict back for the sha it pushed and says so when there is no run for it.
The server gate ran node:22 while the dev gate ran node:24, and the two disagree about
Intl.NumberFormat's compact currency padding and about which realm's Blob a Response hands
back — so the frontend job had been red for 73 of its last 90 runs while the local gate was
green, and nothing said so.
Align everything on 24, with `.nvmrc` as the single source of truth: the CI container, the
prod build stage, `engines`, and `@types/node` (which was two majors ahead of the runtime, so
tsc was green over APIs the shipped engine does not have). The backend job's node only runs
the checkout action, so it tracks the pin as hygiene — node 20 is EOL — but deliberately
stays outside the contract.
The pin is held by a contract test that reads every one of those places AND the engine
running it, so the next drift is a red lane on its first run instead of a CI log nobody reads.
Verified on node:24-bookworm (the CI lane: 228 files / 1552 tests, typecheck, lint,
format:check, knip all exit 0) and on node:24-alpine (the prod build stage, v24.21.0, SPA
built). Each assertion of the new test was mutation-checked red.
`expect(x).toBeInstanceOf(Blob)` asks which realm constructed the body, not
whether there is one. Under jsdom the ambient `Blob` is jsdom's class, while
`Response.blob()` hands back node's own on Node 22 and jsdom's on Node 24 — so
both assertions passed locally and failed on the server gate, for a reason that
says nothing about the code under test. `.text()` is the mirror image: jsdom's
Blob does not implement it, so reading the bytes back is green on 22 and red on
24.
Both now assert the brand (`[object Blob]`, agreed by both realms) plus size and
type. Mutation-checked in node:22-bookworm: dropping `{ raw: true }` from
`debugExport` still turns the test red, so the repaired assertion still catches
the defect it was written for.
core.test.ts also stops building its Response from a jsdom `Blob`: on Node 22
undici does not recognise it as a body and stringifies it, so the fixture's body
was the 13 bytes of "[object Blob]" rather than the zip header. A string body is
what the sibling test already used.
`{ notation: "compact", maximumFractionDigits: 1 }` leaves the MINIMUM to the
engine, and engines disagree: the same options render "$160.0M" on Node 22 and
"$160M" on Node 24 (measured 2026-09-20 in node:22-bookworm, the image the
server gate runs, against the local Node 24.17). Browsers split along the same
line, so a viewer on an older engine saw the padded form. `minimumFractionDigits:
0` makes both render "$160M"; the plain `compact` helper (vote counts) was
measured too and already agrees on both.
This is why the server gate's frontend job was red on PlexInfo since 09-17 while
the local gate was green: the two run different Node versions.
The composer's preview did not fail a call — it killed the Gitea process, and
with it the git server and the container registry. `POST /api/v1/markdown` with
`{"Mode": "gfm", "Context": "<repo>"}` makes Gitea resolve every hex-shaped word
as a commit link, and on the API's render path the commit checker it asks is nil:
panic: runtime error: invalid memory address or nil pointer dereference
renderhelper.(*commitChecker).IsCommitIDExisting(0x0)
<- markup.hashCurrentPatternProcessor (html_commit.go:217)
Every report carries hex — a tab id, a request id — so this was not an edge case
but the only case: the preview answered "the tracker isn't answering" every time,
because the tracker had just died.
Measured on throwaway instances, not argued. Same body, same call:
gfm + Context + "5645b1c0ae01" -> 1.26.4 CRASH, 1.27.1 CRASH, 1.27.3 200
gfm + Context, no hex -> 200
gfm, no Context -> 200, and the HTML keeps its tables, task
lists and chroma-highlighted code blocks
Upstream fixed it in 1.27.2 ("fix: avoid markup render panic", #38698), but a
self-hoster runs whatever version they run, so the payload is what gets fixed.
Sending was never affected: the created issue's own page renders the same body
fine (measured) — a real repo context has a real commit checker. The only loss is
that a hash or a `#123` is not yet a link in the preview; the issue page links it.
The test pins the payload dict, because the defect is in what we send.
E16: a recorded debug session becomes a drafted issue with its own logs attached, sent to the private Gitea tracker or downloaded as a zip. Includes the four epic-end review rounds and the Plex player's focus trap.
Round 4 was targeted at the surfaces rounds 2 and 3 changed. 29 findings raised,
26 survived deduplication, 12 survived three adversarial readings each.
`set_delivery` renewed the claim but never checked it was still ours, while its
sibling `release_send` always did. `expire_on_commit` is off, so the token it
re-stamped came from the caller's own memory, not the row: a send that outlived
its 600 s lease came back, overwrote the take-over send's committed links with a
full-column write, and put its own dead token back. It now takes the token, reads
the row's claim with a targeted scalar (not a refresh, which would discard the
`issue_url` assigned one line earlier) and raises `SendClaimLost` rather than
writing. An issue created by a send that had already lost its claim is logged with
its number — that record is the only one that will exist.
The tracker's settings are read above the pre-call checkpoint instead of inside
`_client`. Reading them there was a SELECT one line after the commit that was
meant to end the transaction, and held it — idle, snapshot pinned — across the
repository check and the create. The comment and the module docstring said
otherwise; measuring it in pg_stat_activity is what settled it. Two more comments
were saying things the code had stopped doing: the `**state` spread does not carry
the claim (`set_delivery` strips and re-stamps it), and react-query does not
debounce the preview — the editor being unmounted is what paces it.
The composer stopped stranding the focus on `<body>`: a send that creates the
issue and then fails on an attachment unmounts the button that was just pressed,
and only the success path handed the focus on. Export no longer saves into a
refusal it does not need — the zip is the way out of a stuck send, and its
pre-save was the one step that could not survive one. The delivery banner
announces through a role instead of an `aria-live` created with its own message.
`historyState` returns a union, so `chipTone` matching on the same keys is checked
by tsc rather than by hope.
PlexPlayer was the only full-screen overlay in the tree with no focus containment
at all — Tab walked out of an opaque player into a page nobody can see. It now
carries the shared trap. Closing that hole exposed the one underneath it:
`useDismiss` registered as a layer that traps the Tab while installing no trap,
so the player stood its own down for its own menus. Measured in a real browser:
30 Tabs with the settings panel open, 9 escapes into the nav rail, and 0 after.
Escape ordering is unchanged — the panel first, then the player.
Three tests that did not hold their line: the advisory lock's call site (deleting
it left the suite green), the trail assertion whose `all()` was vacuous over an
empty filter, and a `layerStack` assertion the stand-in dialog already forced.
All eleven executable fixes mutation-checked red for the right reason; the two
comment corrections have nothing to pin.
Round 2 made the step prompt a blocking layer so that `j` and the `g` chords
would not reach the page behind it. The dialog it sits in gave up its Tab trap
because of that, while the prompt's own replacement trap covered only its own
two controls — so Tab from anywhere else in the dialog (the bar's Screenshot or
Stop, the dialog's own buttons) walked out behind the scrim. `layerStack` now
separates the two questions it had folded into one flag: a layer can own the
keyboard and still leave the Tab trap to the dialog below it. The mirror case,
a dialog opening over the prompt, falls out of the same split.
A send's claim is now a lease with an owner. Every progress report re-stamps
it, so a send slower than the lease cannot lose it while it is still running,
and only the send that took a claim can give it back. The issue_create audit
row is staged inside the send and rides the same checkpoint as `issue_url`, so
the trail cannot be missing for an issue that exists. The attachment-name
prefix has one definition, shared by the producer and both readers.
A running send is reported to the UI. A reload or a second tab used to render
it exactly like a send that STOPPED, and offered a retry the server was certain
to refuse; the composer now says the report is being sent, holds the draft, and
puts both the retry and Create issue out. The past-sessions list says the same
for the same reason — it is the list a user opens to check on a send, and both
"draft" and "half sent" describe one that died. `/preview` and the retry path
got a pace of their own, and the per-user byte cap took the user-scoped lock
that makes it a cap instead of a guess.
Verified in a real browser on localdev: Tab cycles the whole dialog with the
prompt open, the prompt still swallows the page's keys, and the issue link is
focusable where the composer puts it. All 24 regression tests mutation-checked.
Creating the issue rebuilt the delivery state from scratch and dropped the
claim with it, so for a new issue the claim covered only the creation — a
draft, a screenshot or a second send could change the report under the uploads
and the body edit that follow, which is exactly what the claim is for. The
state is now built on what was read, and a test walks the send's checkpoints
to see the claim still held at each one.
Found by mutation-checking the round-2 tests: removing the claim's release
left them green, because for a new issue there was nothing left to release.
The send no longer holds the session's row lock while it talks to the tracker:
it claims the session, gives the lock back, and checkpoints each step. A Stop
is never held behind a send, while a draft, a screenshot or a second send are
refused at once instead of queueing. The claim ages out, so a request that
died mid-send does not strand the session.
What the composer says is what happened: an attachment that went is no longer
named as one that did not (the missing body edit is its own line), and the
issue's screenshot count comes from the server when the list cannot be read,
so a failed read no longer blocks saving the draft. The zip is fetched as a
request, so its pace is a message and not a saved error page. Screenshots of a
finished report can be removed again; only one still being sent is held.
A recording's log elevation can no longer outlive it. The step prompt keeps
the keyboard (the page's keys stay quiet) and traps Tab itself. An image
pasted into a dialog opened over the composer stays that dialog's.
The send, the draft and the screenshots take the session's row lock, so an
autosave or a paste during a send waits for it and then sees it, and two sends
cannot open two issues. A save keeps the delivery's progress; a failed edit
that shows the screenshots is finished by the retry; an answer that is not the
tracker's API (a redirect, a proxy page) is refused, not a crash that loses the
issue's record. Only an attempt that opened an issue counts toward the pace.
Screenshots: deleting one after the send is refused, a user's storage is
capped across sessions, new sessions and zip exports are paced, images are
stored uncompressed in the zip. One liveness rule, one session view, one image
type table.
Logging: a session's elevation is tracked apart from the user's own, so the
browser's own requests (no header) no longer lower the floor a session needs;
Stop ends it at once.
Composer: an image pasted anywhere is taken; what is still unsaved is saved on
close, and "saved" is shown only when it is; the draft is held while it is
sent; a failed screenshot read says so instead of reading as none; focus moves
to what stays; the preview follows the text size. Pill: the step prompt and a
running Stop survive the pill moving between hosts, the prompt keeps the
dialog's Tab trap, the toast region lets clicks through, other tabs hear of a
Start or Stop, the last seconds are sent before the cap, a capture with no
frame is an error.
- admin config group "Bug reports": Gitea address, repository, token (secret),
and "Check the connection" — the repository must answer, take issues and
be PRIVATE (a report carries its reporter's log); checked on every send too
- POST /api/debug-sessions/{id}/issue sends the SAVED draft: create the issue,
upload each file on its own request (the measured API), then show the
screenshots inline; progress is kept in the draft, so a send that stops
half-way keeps its issue and the retry sends only what is missing; 5 new
issues an hour per user; audited as issue_create
- the composer: Create issue (only where a tracker is set up, naming the
repository), a partial-send banner with retry, and a Preview — the tracker's
own rendering in a sandboxed frame, or the zip's markdown without one
- the recorder's refusals answer in the localized family (debug_session_*,
screenshot_*, issue(s)_*), not bare codes the UI could only call generic
- a tracker refusal logs the status only: its message can quote the title back
The shared test session kept whatever a failing handler flushed and never
committed, so a later read in the test saw a write production loses when
get_db closes the session — and a handler whose failure path must commit
(E16: the tracker's issue link) looked right without the commit.
After Stop (or at the cap, or from Settings → Diagnosztika's past sessions)
one dialog drafts the issue: title, description, the steps drafted from the
session's markers and page changes (a marker without a label is named by its
time), the environment (version, browser, system, window, language). Beside
it, what goes with it: the browser and server logs — each reviewable line by
line, an unticked line is not sent — and the screenshots, removable, with
paste / drop / pick to add more.
- it saves as it goes (debug_sessions.draft, migration 0090): the rendered
markdown in the user's language, the excluded rows, and its own fields to
reopen as it was; a sent session is read-only
- the zip export (issue.md, client.log, server.log, screenshots) is built
from the saved draft by app/issues/bundle.py — the one definition the Gitea
delivery (S4) will share; .log, not .jsonl, which Gitea refuses
- the log line's level tags and clock move to components/logLineParts so the
review list draws a level the way both log viewers do
- the start dialog is the consent: level (Részletes/Teljes), what is recorded,
retention and the cap (now served as diagnostics.session_minutes)
- the pill docks under the toasts: elapsed time, Step (an optional label,
its own Escape layer), Screenshot (tab capture where the browser has it),
Stop; compact on a phone
- while recording, lib/log tags every event and every request path (req,
beacon, uploadReq via one identityHeaders) names the session; applyDiagnostics
raises the level for the session's run and never lowers a raised one, and
lapses at whichever ends first
- Stop sends what is waiting before the server closes the session; the cap
says so and opens the composer
- Settings → Diagnosztika and the command palette start/step/stop it
- the evidence leaves the log upload's own request lines out
- the step shortcut is deliberately palette-only: the keymap's D1 rule bars a
new modifier chord, and a bare letter collides with the players'
The toast stack already has a host in the page and in every overlay (dialogs,
both players, the fullscreen player), the topmost one shows it, and inside an
overlay it sits above the player's control band. lib/toastDock lets one control
use exactly that placement; it renders outside the live region, so a ticking
control is not read out every second.
A session is one recording, capped at 60 minutes by the server. While it is
open, a request naming the caller's own session in X-Debug-Session is logged
at the session's level with every line tagged; a header naming anything else
is ignored. The browser's batches count a session as consent to its level and
keep the tag only on its own events. Screenshots are judged by their bytes,
capped in size and count, and served back to their owner only.
- debug_sessions / debug_screenshots tables, one open session per user
(partial unique index), log_events.debug_session_id indexed
- /api/debug-sessions: start (joins a running one), stop, list, get, events,
screenshots; /api/me reports the live session
- the hourly log job closes expired sessions and drops them with the logs
- logsink.serialize and routes._common.capped_body shared by their readers
Round 2 of the deep review:
- A click on, or a key typed into, the focus keeper's stand-in fires no focusin, so a restore still
polling for its list could take the focus from under the reader. A pointerdown or keydown now
makes it the reader's own focus.
- The Jump-to pill hid its "Ctrl K" from its name (the voice command "click Ctrl K" still matched
nothing); the key cap's tooltip now equals its name, so it is not read twice as a description.
- listNav's comment still cited DataTable's two layouts as the reason for its visibility filter.
Round 2 of the deep review, on round 1's own fixes:
- `ORDER BY position … FOR UPDATE` sorts before it locks, so the reorder that waited got the
fresh rows in its STALE order and dealt the pins it left out (a tab with Plex off) back to their
old slots. It locks in id order — one global order, so two reorders cannot deadlock — and sorts
the fresh rows itself. The race test now sends that partial list and fails unless tab B really
blocked.
- The request check saw only writes the handler flushed: a `db.delete` left staged, or a flush
followed by a released savepoint, passed. Staged changes count at the end of the request, and
only the ROOT transaction's end clears it (a savepoint test drives the dependency directly).
- The `ok` test exempted every call as a serializer, hiding four domain REPORTS (YouTube sync,
revert, push; Plex sync) without `ok`. They are spread into `{"ok": True, …}`; the exemption is a
named list of real serializers.
- The palette launcher's name replaced its visible "Jump to…" (and the key cap's its "Ctrl K"):
a voice-control "click Jump to" matched nothing (WCAG 2.5.3). Each name contains its text now.
- SegmentedToggle's `title` was only a tooltip on a non-focusable box — the live search's "how
many per batch" hint reached no keyboard or screen-reader user. It is the group's accessible
description, standalone and captioned alike.
- The unused-key contract let a template like `card.${status}` cover its whole namespace, which hid
five dead keys (deleted). Dynamic keys are matched by shape (`editor.*.hint`); a bare namespace
shape covers nothing, and the audit/quota namespaces are now held both ways by backendIds.
- CLAUDE.md's "FE control" row matches the lint rule it cites; each no-restricted-syntax entry has
its reason directly above it.
Deep review of the debt burn, each measured in Chrome:
- Back from a sub-view whose focused control the swap removed: the focus keeper put the focus on a
stand-in (the returning list's first item) and `restoreNavPosition`, finding the focus "already
somewhere", gave up — Plex show → Back landed on tile 0, not the tile it was opened from. A
stand-in the keeper placed now counts as untouched, like <body>. The keeper's walk up also stops
at its container: a slot left with nothing focusable no longer sends the focus to the first
control of the whole document.
- DataTable enabled both layouts' keyboard lists with the same flag, and `useListNav` reads its ref
only when the flag changes: after a resize across `md`, the layout that mounted was never
registered (no j/k) and the one that left stayed registered. Each is enabled with its layout.
- A Plex playlist nests blocks and each show's episodes in one DndContext; closestCenter and the
keyboard coordinates weighed every droppable, so a movie landed "over" an episode — announced as
a place in the other list, dropped somewhere else or nowhere. `sameListCollision` and
`sameListKeyboardCoordinates` keep a drag among its siblings; onDragEnd's episode → block
resolution is gone with the case.
- dnd-kit reports the item over itself in the pick-up's own tick; announced, "moved to position 1"
replaced "picked up … use the arrow keys" before a screen reader read it.
Helpers flush and handlers commit — but every test request shared the test's session, so a
follow-up GET or an assertion read the flushed write and the suite stayed green over a handler
whose commit was missing (production's get_db closes the session: the write is rolled back behind
a 200). The db fixture now hands out `db.info["request"]`: the same session, recording each request
that ends normally with a flushed, uncommitted write, and failing the test on it at teardown. All
28 get_db overrides use it (a convention test keeps it so); the full suite passes under it, and
dropping the commit from add_item turns a playlist test red.
Deep review of the debt burn:
- `refusal_reason` told the user to reconnect on ANY token-endpoint failure — a 5xx, a 429, a
misconfigured client — while the grant stayed and the account still read as connected, with no
connect button. Only `invalid_grant` (the one that drops the grant) means reconnect now.
- `pins.reorder` writes only the rows that change, so two overlapping reorders each skipped rows
that matched in their own stale snapshot and interleaved into two pins on one slot. It locks the
user's pins first; the second waits and deals from the first's result.
- Adding a whitelist entry committed the invite and the activation it implies separately, and its
audit row was written before a new invite had an id (target NULL). One transaction, flushed for
the id.
- The mutation convention test skipped async routes and dicts returned by name: request-access,
purge-discovery and the subscription sync answered without `ok`. They do now, and the test
covers both. The helper-commit check looked only at `_`-names; it reads every non-route
function, which listed two deliberate committers (upsert_pending_invite, ensure_welcome) — now
allowlisted with their reasons.
Two rows of the canonical-patterns table had drifted, found while closing the roadmap audit:
- 63 mutation responses carried no `ok` — `{"unsubscribed": true}`, `{"status": "ok"}`,
`{"added": 3, …}` and so on. Each now leads with `"ok": true` (additive: every field a client
reads is still there).
- Twelve route helpers committed on their own. Six were just the end of their handler's work and now
flush: the playlist item add/remove and Watch later get-or-create, the Plex series/season bulk
mark, and the invite decision + account activation — which an approval used to land in separate
transactions. The other six commit on purpose (a token durable before its link is mailed, a
single-use token burned as its own unit, the Google callback's tail, a background registration
with its own session, the staged search ingest around YouTube calls), each listed with its reason.
`tests/test_route_conventions.py` holds both rules in code; CLAUDE.md's two rows point at it.
A FocusSafeButton that holds the focus is disabled by aria-disabled, not the native attribute, so
every hover gated on `:disabled` / `enabled:` alone still fired on it — the glass lift, the primary /
danger / warn dim, and eight `enabled:hover:` chains (saved views, the player's list buttons, the
undo toolbar). Measured on localdev: the Pager's Next, focused at the last page, now keeps its resting
border and shadow under the pointer. The CSS selectors and the chains also exclude
`[aria-disabled="true"]`, and a lint rule refuses a bare `enabled:hover:` from now on.
- `overflow-clip` gets a safelist anchor in index.css (its only occurrence is a `.ts` constant),
and the chip-lane contract keeps it there.
- PlexPlayer's Backspace ownership rule is pinned on all three branches: nothing above, a foreign
layer above (it defers), and its own panel open under a layer (it still closes the panel).
- Removing a playlist item bumps the list's `updated_at`, like the three other item-only writes.
- Seen in a real browser on localdev: an exhausted comfort-shelf tile (an empty pinned playlist) is
focusable, aria-disabled, and its Shift+F10 menu offers Play off and Remove from shelf.
The module shows fewer lists than youtube.com, and without a word about it that read as a Siftlode
bug (the owner's ask, 2026-09-03). Owner's pick from the S7 mockups (2026-09-19): one quiet line at
the foot of the rail, "Missing a list? Why?", whose Why? opens three facts — YouTube's own Watch
later and History are closed to its API (the Watch later list here is Siftlode's own), only lists
you own sync, and an account with no YouTube channel has no playlists at all.
Ctrl/Cmd+K and the shortcut sheet had no entry point but a palette row, behind the other
undiscoverable chord. Owner's pick from the S7 mockups (2026-09-19): a key cap at the end of the
header's search field opens the palette, and a page with no search field shows a small "Jump to…"
pill with the same key cap in its place, so the entry is on every page. The cap reads the chord the
palette row binds from the keymap table (Ctrl K, or ⌘ K on a Mac) and stays out of a phone's
header, where there is no keyboard to press it on.
Owner's pick from the S7 mockups (2026-09-19): a short fixed set is a SegmentedToggle — the live
search's result count, the table's rows per page, a download profile's mode (on its own row, so the
three worded choices fit a phone) and audio format — and a longer set or the user's own list is a
FilterSelect: the profile's quality (seven heights) and the download dialog's profile.
A lint rule refuses a native <select> from now on (the canonical "FE control" row). The px-frozen
size rules also caught up with a fraction: `text-[9.5px]` had slipped past the integer-only pattern
in the Glass tuner, which is rem now.
dnd-kit's screen-reader text is hard-coded English and names an item by its id ("Draggable item
10357 was dropped", a role of "sortable"). Only the YouTube playlist replaced it; the Plex playlist,
the saved-views list and the side-panel groups read it out under a Hungarian UI — found while
checking the Plex playlist's keyboard reorder on localdev.
`ui/sortableA11y` is now the one place: `useSortableA11y` gives a DndContext its instructions and
announcements (HU/EN, `common.dnd.*`), `useSortableItem` announces the role. The YouTube playlist's
own announcements move onto it, which also fixes them: its lists reorder on drop, so reading the
dragged row's own index said "moved to position 1" at every step and "dropped at position 1"
wherever the row went. The place announced is now the slot the row would land in.
A `local/sortable-a11y` lint rule (with its contract test) refuses a DndContext without
`accessibility` and a direct `useSortable`.
The Plex playlist's reorder is a dnd-kit drag, and neither of its sensors activates under jsdom,
so `commit()`'s failure path could not be reached from a test. The query, the optimistic copy and
the two writes (reorder, remove) move into `usePlexPlaylistEdits`, unchanged, and are driven
directly: a failed reorder is said and the rows go back; an accepted order lands in the detail
cache; a failed removal comes back.
It also settles the `putBack` question that could not be driven before: with the refreshed list
taken in BEFORE the reorder fails, a rollback bound to the render that started the reorder puts a
removed row back, and the deep-equal refetch never clears it. The live cache read the code has now
is what keeps it out; the test fails if it goes back to the closure's `q.data`.
22 test files set `app.dependency_overrides[...]` by hand and tore down with a blanket `.clear()`
in a `finally`, which wipes every override rather than the test's own and stops fixtures composing.
They now take the `override_dep` fixture (helpers get it passed in), whose teardown restores each
key's prior value; no clear() carried meaning between two phases of a test. A convention test fails
any test file that writes `dependency_overrides` directly.
- AppCommandPalette had no test: modules gated by the account, hidden channels left out, the
playlist row selecting before it navigates, and the surprise riding setPage's `then`.
- keymap: an Alt/AltGr-modified key (Ctrl+Alt on Windows, a constant on the HU layout) fires no
bare-key or Mod row.
- Banner: the dismiss button falls back to "Close" rather than going unnamed.
- ShareDialog: a protected link's lit lock and Revoke's danger colour stay on the icon.
- Both playlist headers are rendered now: the YouTube one needed the toolbar slot it portals into.
Meta line sums known runtimes and treats an ABSENT duration as nothing (null never exercised the
guard: `3000 + null` is 3000); a one-row list offers no view controls.
A contract test scans the source (tests excluded) for each key as a whole literal, or under a
dynamic prefix like `plex.sort.${k}`, with plural forms counted through their base key. It found 75
keys that had outlived the code showing them (the old sidebar sort vocabulary, the old login page, a
pager the DataTable replaced, …); they are gone from EN and HU. A key read in a way the scan cannot
see goes in an explicit ALLOWED map with its reason.
- The Plex playlist rows' "reorder"/"remove", the Plex filter chip's "remove" and the player
slider's "reset" were English literals; each now names its row or setting in HU/EN.
- The playlist show header's chevron had no accessible name; its decorative thumbnail button left
the tab order.
- The opt-in Glass tuner panel was English only: every word is translated, and each slider's label
now names its number box and range.
- New `local/literal-a11y-text` lint rule (with its contract test): a literal word in aria-label,
title, placeholder, alt and the other spoken/shown attributes fails lint.
- A13: with Plex off, the Home shelf and the hero's comfort tier go YouTube-only like the rails
beside them (owner's call: hide the tiles). The pins are kept; GET /pins still lists them.
- Reorder deals the listed pins into the slots they already hold, so a partial list (hidden Plex
pins, a stale tab) no longer puts two pins on one position; repeats are ignored.
- A21: a comfort hero carries `pinned_from`, the shelf entry it was resolved from; its menu acts on
that pin, so a pinned show's next episode offers "Unpin" instead of creating a second pin.
- B12: the palette's "Meglepetés" asks for the surprise tier (the next press leading with it); the
rotation carries on from there.
- B13: the surprise token names the history entry it was requested for (minted by setPage's new
`then`, after the push) and is dropped on any other entry, so a navigation that never mounted
Home cannot replay it later.
- During a budget switch the rail shows the previous budget's payload
(keepPreviousData) while the new key is empty; the move read that empty
cache, found no tile and silently did nothing. It now acts on the order
the rail rendered.
- A refused move was undone only by a re-read; when that read failed too,
the shelf kept showing the order the user had just been told was not
saved. Moves are now written one at a time (the move rows wait, with a
"saving" note), which makes restoring the shown order correct — the
overlapping-moves problem that ruled a rollback out cannot occur.
The second half of the pending-button sweep: where the pressed control does
not merely disable but LEAVES the page — an invite row gone after Approve,
the inbox's Clear all gone with the last notification, a section gone once
its last row is handled — the browser dropped the focus to <body> and the
next Tab started over at the top.
useFocusKeeper, on the page scroller: when the element holding the focus is
removed and the focus fell to <body>, the first focusable thing in its slot
takes it (what followed, else what preceded, else the nearest ancestor still
on the page). Portaled layers (menus, dialogs) sit outside the scroller and
return focus themselves; listNav's keyboard lists are left alone — their `j`
already goes on from the slot, and a row scrolled out of a virtualized list
must not drag the focus after it.
A focused button that turns natively disabled loses the focus to <body> in
Chrome. The app disables the very button that was pressed on ~60 sites — a
mutation pending, a pager at its last page, undo with nothing left, a player
at the end of its queue, a form gone clean — so a keyboard user was thrown
out of the page on every such press.
One primitive instead of a fix per site: ui/FocusSafeButton renders a
focused-and-disabled (or busy) button as aria-disabled with a click guard,
and applies the native attribute again once the focus is elsewhere. It asks
the document at render time as well as listening for focus events, because
a window without focus fires none (measured). ui/Button and ui/form's Switch
are built on it; 57 hand-rolled buttons swapped their tag for it keeping
their classes, and three hand-copies of the rule (the channel page's Block
and Hide, ChannelDiscovery's Subscribe) now pass `busy`. The soft state's
dim look is one rule in index.css.
`local/focus-safe-disabled` refuses a new raw <button disabled>, pinned by
focusSafe.contract.test.ts. lib/plexDetailUi moved to components/: it is
components, and lib may not import the primitive.
Measured on localdev in Chrome: the audit log's Next button keeps the focus
on reaching the last page, reads aria-disabled, and the guarded click does
nothing.
The reason for a disabled menu row lived in a `title` on a natively disabled
button: no pointer events, so no tooltip, and roving focus skips the row —
four translated sentences reached nobody (the Watch later rename/delete
lock, "no other list to move to", the reorder gate). An aria-disabled row was
tried and reverted in E10's review. ActionMenu items now take a `note`,
rendered as a muted line under a disabled row's label: seen, and readable in
a screen reader's browse mode. Only the icon and label dim — row-level
opacity would dim the explanation too. The reorder group states its reason
once, under its first row.
The pxBox contract test held the literal w-[150px] it probes, which the new
lint rule refuses, and stickyTop's test used Array.at, outside the tsconfig
lib. Both landed because a local lane check piped its exit code into tail.
- "A page with a sticky ColumnHead must declare fadeTop: false" lived in
prose: a registry flag on three pages plus an App special case for the
Channels card layout, and the next table page would have re-acquired the
fade-on-the-header defect with every lane green. ColumnHead now registers
itself with the enclosing PageScroller (ui/stickyTop) while mounted; the
registry flag, the App branch and the prop plumbing are gone. Measured on
localdev: the top mask is 0px under Channels' and Audit's headers, 20px on
Home and the Feed.
- ui/DataTable mounted its table AND its card list at every width, one
hidden by CSS: double DOM and render cost, duplicate ids, two keyboard
copies of every row. It now mounts the one the `md` media query calls for
(lib/useMediaQuery); the keyboard test asserts one layout at each width.
- The Plex module's content width went px → rem with the rest of the page.
- PriorityStepper labelled itself with channels.row.* and told every caller
it orders the Feed; Combobox said plex.loading / plex.filter.moreRefine /
sidebar.match on the feed sidebar. The generic strings moved to common.*,
and what a priority MEANS arrives as the stepper's `hint` prop.
uiI18n.contract.test.ts pins components/ui/ to the platform namespaces —
the import boundary could never see an i18n key.
- 32 px-frozen box utilities (w-[150px], max-w-[1600px], left-[18px], …)
became spacing-scale steps (w-37.5, max-w-400, left-4.5): identical at the
default text size, and now they grow with the Settings slider instead of
clipping or re-wrapping their own labels. An eslint rule refuses the px
form beside the older text-[Npx] one, pinned by pxBox.contract.test.ts;
measured in the browser that every new step is generated at its old px.
- chipLane's import-strip regex ran past a side-effect import into the next
statement's `from`, hiding a lane between them; it now stops at the
import's own `;`.
- Home's rail handed each card a fresh `() => menu.triggerProps(c)` per
render, defeating memo(VideoCard) on every tile whenever the menu's own
state changed. `useItemTriggers` (ui/useContextMenu) keeps one trigger per
item, index-aligned; tested for identity across renders.
- The Button accessible-name union had one hole: `<Button icon={…} />` with
no children and no size typechecked into a nameless button. A textful
button now needs children or a name; pinned with @ts-expect-error.
- comfortPin's header said the pin belongs "on every card and row", which
the code never meant (an episode, a season, a person are not pin kinds).
It now says what is pinnable — and the show page's Related strip, whose
shows ARE pinnable, gets the pin menu it was missing (and a rem width).
- gridColumns / feedView / VirtualGrid claimed layouts 1024–2560 are
"byte-identical" under the column cap; the small cards are capped at 2560
with the filter rail collapsed. The comments say so.
~150 HTTPExceptions carried a hand-written English sentence as their detail,
shown verbatim under the Hungarian UI — in the blocking error dialog and on
every inline surface that printed HttpError.detail (sign-in, setup wizard,
change password, the passphrase gate, live search). Four also quoted an
upstream exception's text back to the user.
- app/refusal.py: `refused(status, reason, **params)` → {code: "refused",
reason, …params}; localizeDetail translates errors.refused.<reason> with the
params interpolated. "Unknown X" 404s use the existing not_found(what).
- sysconfig.set_value raises InvalidSetting (reason + bounds) instead of an
English ValueError; the three writers answer it through setting_refused.
- The Plex connection/write/image failures log what the upstream said and
answer a reason; the search scraper's failure is logged and answered as
youtube/unavailable (it crashed refusal_reason before).
- HttpError.detail now only ever holds a translated reason: a string detail
(only the framework's own can remain) is left out, so reading .detail is
safe everywhere — including destructured, the hole the raw-detail source
scan could not see. That scan and its five-file exception list are gone.
- Enforced: test_http_details.py fails on any HTTPException with a sentence
for a detail; the contract test requires every refused reason and every
not_found "what" in both languages, and no key for one that is gone.
- local/jsx-comment-text: a // inside JSX children renders as text; the rule
is ten lines in eslint.config.js rather than eslint-plugin-react's ~1800
lockfile lines.
HLS sessions were keyed by the item, so a second account opening the same
film killed the first one's session; the displaced player reclaimed at its
playhead and evicted the other in turn. The registry is now keyed by
(viewer, rating_key), segment requests resolve by both, and the session
directory carries the viewer (the orphan sweep still recognises the older
shapes).
Only transcodes counted toward a cap, so remuxes — each a whole segmented
copy of the film on the scratch volume — were bounded by the library size.
Three bounds now apply after every start, each dropping the least recently
used: two sessions per account, the transcode cap, and a new admin knob,
plex_max_sessions (default 8), over every session. A superseded session
request returns at once, since it waits on the killed ffmpeg, so an account
cannot park more request workers than it may hold sessions.
The subtitle extractor's full-pass slots were global and minutes long each;
one account opening subtitles on film after film could hold them all. An
account may now hold three of the six, and still gets windows past that.
The house guard requires a silent handler to carry a silent-ok note; the
savepoint fallback added in e74986cc did not, and the gate's pytest lane
caught it.
A full pass that ran out of time kept what it reached, but its retry an hour
later started again at 0, so a file too big to read inside the extraction
timeout never completed. The pass now starts where every embedded track's
coverage from the head ends (less the window pre-roll, so a cue across the
join is read whole). The seek needs -avoid_negative_ts on this read as on a
window, measured on a real file: without it the cue running at the start
shifts the whole output.
A partial track whose full pass had failed never reached a settled state, so
the player polled it every 2 s for the rest of the film. The status now says
whether the track is still growing, and a stalled one is polled every 15 s —
still inside the 60 s lookahead that reads the next window before an edge.
The comfort shelf resolved each pinned channel with three queries (the
row, its unwatched count, its newest video) and each playlist with two.
channels_summary gets the count and the newest row per channel as window
functions over one filtered set, playlists_summary reads every pinned list
in one query, and the rows come in one IN() each — so the shelf's cost no
longer grows with its length. A test counts the statements.
RAIL_LIMIT had two definitions held in step by a comment; the Plex Discover
route now imports the domain's. And rollup's "continue the latest-started
episode" was false: it takes the last in-progress one in broadcast order,
which is also what Home's next_episode picks.
move_item checked "already in the target?" and then re-parented without a
lock, so a second tab's add or the scheduler's YouTube merge committing the
same video in between hit uq_playlist_video and answered a raw 500. The
re-parent now runs in a savepoint and a conflict falls back to the
already-present outcome. The target is touched only when the row landed in
it (an untouched list was promoted in the rail's "recently changed" sort),
and _touch stamps the database clock like the mixin's own onupdate.
The canonical-patterns table names owned_or_404 against ad-hoc ownership
helpers; six were left (_own_playlist, _own_playlist_or_404, _own_view,
_own_tag, notifications._owned, downloads._own_job), two of them
hand-rolled with a bare English 404. All call sites now use owned_or_404,
and the download/link/notification 404s are structured like the rest. The
contract test reads every "what is gone" the routes name, and the YouTube
refusal reasons, and requires both languages to name each.
The rollback of a refused watched mark guarded against Plex's REPORTED
duration. On a file shorter than its metadata the end sits outside the
finish margin, so playing to the end and having the mark refused stored the
very end as a resume point on an unwatched row: a ~99% bar that reopened in
the last seconds. The media's own ended flag now decides.
Two near-end checkpoints arrive together routinely (the 10 s tick and the
debounced seeked flush); both read "not watched" before writing and both
scrobbled, bumping Plex's viewCount twice. The transition is now decided by
the write: an insert that did not conflict, or an update conditional on the
old status.
The finish also popped the push-ordering entry that _resume_push_superseded
reads, so an older resume push still in flight could land at Plex after the
scrobble and revive a resume point on a watched item. It now stamps the
entry instead; the existing eviction bounds the map.
The two manual syncs (subscriptions import, playlist pull) had no YouTubeError
handler, so a quota, token or transport failure was a raw 500 and the blocking
"Server error" dialog (E9 F1). The other YouTube routes answered either the
upstream's English or a playlist-scoped "youtube_failed".
One helper, routes/_common.youtube_refusal: 422 {code: "youtube", reason} with
the reason sorted by what the user can do — quota (wait for the reset),
reconnect (the token step refused), unavailable (retry). Used by every route
that surfaces a YouTube failure; localizeDetail translates it.
"Reconnect" had no path: a grant Google revoked still read as granted, so
Settings and the wizard offered no connect button while every call failed.
An invalid_grant from the token endpoint now clears the stored grant.
A failed connection or token step answered nothing about the channel, yet stamped it: one proxy blip
during an RSS blackout delayed every due channel by a whole window. YouTubeError now names the
endpoint that answered; a failure no endpoint gave, or a spent quota, ends the round and stamps
nothing.
The card menu's reset read 'Clear watch progress' but also un-hides and un-watches; it now says so.
The resettable rule, the demo-first role key and the display-name fallback each lived in several
components tied by comments; they are shared helpers now, and the menu reuses the card's own
strings instead of duplicate keys.
The query read the typing-debounced search, so for 300 ms after a jump it mixed the new view's
filters with the old view's text and fetched rows neither view shows. useDebounced takes a settle key
whose change applies the value immediately.
Restoring by name picked the first item of that name, so Back on the log console (repeated
http.request lines) or two same-titled downloads landed on the wrong one. The saved place wins while
it still holds the name; otherwise the nearest item of that name.
A channel whose uploads playlist also failed was never stamped, so a terminated channel was charged
again on every 20-minute poll instead of once per rss_fallback_hours. An upstream quotaExceeded now
stops the round instead of charging every remaining channel the same refusal.
An INFO line outside a request never reaches log_events, so the fallback's
own summary was invisible in the Server log. Its counts ride on the
sync.rss.failed warning, the one line of a poll the console keeps.
Measured on localdev: 375 of 375 dark channels read, 403 new videos.
The server now logs polled reads at DEBUG, and the browser's copy of each
request line promises the server's level: at INFO the unread-count polls
filled My log within minutes and, at Detailed, the shipped log. The server
marks a line it logged at DEBUG with X-Log-Quiet, so the polled-route list
stays in one place.
The countdown's clock was the moment Settings mounted, so a level raised
two minutes after opening the page read "24 h 2 m" until the first tick
(measured on prod). The clock is read at render; the interval only
re-renders.
The Feed's chips + Sort + View row did not fit one line at 375px and pushed
the page 21px wide; the toast stack capped its width by the viewport while
it sits right of the sidebar. The toolbar rows (Feed, Plex browse) wrap,
and the toasts are capped by their host.
Settings → Account names the latest sign-in on any device (only the
admin's user list did, though the 0.88 notes said otherwise), and the role
in the reader's language instead of the raw `user`.
Right-click, long-press or Shift+F10 on a feed card offered only the
comfort-shelf pin; watched, saved, hidden and the progress reset were
reachable only by hovering or Tabbing through every card.
Signed out, the tab read Home's question over the sign-in form; the legal
pages read just "Siftlode". The download field asked only for a YouTube
link although Facebook and the rest download too.
Opening a card left Home, and Back came back to "Doesn't matter" with a
different hero, whose card then dropped out of the Continue rail and moved
the keyboard onto its neighbour. The budget, reroll seed and seen list live
on the history entry now, and a returning list focuses the item by its
name before its place.
Every filter change replaced the history entry, so Back after a request or
job pivot left the console altogether (the 0.90 notes promised otherwise).
A pivot follows a link and is its own entry now; the console reads the
landed entry back on popstate. The other filters still adjust in place.
A tab left open across a release asked for a lazy chunk the server no
longer has (prod: DownloadDialog 404 an hour after 0.89) and fell to the
crash screen. `vite:preloadError` now reloads the tab once a minute at
most; a second failure is let through to the error boundary, which now
wraps every entry — the public /watch, /privacy and /terms pages crashed
to a blank page.
The reads the frontend polls (unread counts, /api/me, the download index,
sync status, subtitle progress) are logged at DEBUG: at INFO they were ~95%
of the stored log on prod. A scheduler job failure and a playlist sync
failure carry event codes, and a Google account without a YouTube channel
has an empty playlist list instead of a warning every six hours.
YouTube's RSS goes dark per channel for hours (prod 2026-09-18/19: 393 of
396 feeds answered 404 for four hours, sticky per channel, so a retry does
not help), and nothing else read a followed channel's new uploads. A
channel whose feed has not answered for `rss_fallback_hours` (admin knob,
default 2, 0 = off) now gets one page of its uploads playlist through the
Data API (1 unit), at most once per window, yielding to the backfill
reserve. Spend is attributed to its own quota action.
- "Load older" is not offered off a loading filter's placeholder rows,
whose cursor belongs to the previous filter.
- A failed older page, stale rows and missing counts each get their own
message (HU/EN).
- Live shows as off once a failed read stopped the polling; pressing it
again reads now.
- Live stops polling on a failed read and marks the list as possibly
stale (with Retry), instead of re-raising the error dialog every tick.
- While live, a refetch reads the newest page only, whatever the cache
holds (a late "Load older", a revisited filter set).
- Copy is disabled and the list dimmed while a filter change loads.
- Dates follow the UI language; the pivot chip uses the shared .chip.
- Tests: query-string wire test, Copy, every facet's skip key.
A request that hung for the full timeout says the way out is hanging, not
that the endpoint is flaky; three of them per channel stretched a poll over
a dead egress to about three times its interval.
Both log viewers put their lines in a NavGrid: j/k (and the arrows) step
from line to line, and Enter opens one exactly as a click does (the line's
button is its `data-nav-open`). A focused line is named by its code and
message.
The console's nav entry is "Szervernapló" / "Server log", its page title:
"Diagnosztika" was already the Settings tab that holds the user's own log,
and two places with one name read as one place.
YouTube's feed endpoint answers the same channel with 404, 500 and 200 in
turn: on prod 182, then 296 of 396 channels failed a poll, and a probe of
24 channels saw 15 fail a first request while 16 had succeeded by the
fifth. A channel that failed once waited for the next poll, 20 minutes
later, so new uploads showed up late.
A feed is now tried up to three times (after 1 s and 2 s) on a transport
error, a 404, a 429 or a 5xx; a refusal such as 403 is not retried. The
last failure is what gets counted. A feed that is really gone costs two
more requests per poll.
Admin → Diagnostics reads the server's stored log: one list, newest first,
where a line opens in place to its full record (time, source and logger,
account, request and job ids, version, message, attributes, stack). A
request or job id narrows the list to that request or job with every other
filter off, shown as a removable chip. Filters: level group, source,
account, event, period and a free-text search; all of them live in the URL
(`l*` keys, which setPage clears like the Plex and Channels keys), so a
filtered view is a link and Back restores it. "Live" re-reads the newest
page every few seconds while the tab is visible; paging into older rows
turns it off. Copy puts the loaded lines on the clipboard as JSON lines.
The facet counts now honour every filter but their own, as the feed's do:
a request pivot narrows them, and a picked level does not zero its
siblings. A value in use stays offered even when the counts drop it.
"Saját napló" and the console draw a line with one shared component,
`LogLine`, whose phone layout gives the message its own full-width line
(sharing the code's line left it a few characters). The level groups
moved to lib/log.ts for both.
The console's own reads are logged at DEBUG on success, like the
healthcheck: at INFO its live tail filled the log with itself.
GET /api/admin/logs pages `log_events` newest first on a (ts, id) keyset:
a browser row's ts is clamped into its consent window, so ts alone is not
unique. Filters AND together: level, source, account (or `none`), event
code (or `none`), request id, job id, a free-text search over the
message, code, request id, stack and attributes (typed text is a literal),
and a time window. Each page names the accounts it mentions.
GET /api/admin/logs/facets counts what the filter chips offer over the
window alone, so a chip row does not shrink as filters are picked.
Admin-only and read-only; reading is not audited (the console's live tail
polls). There is deliberately no "ids after N" tail: several processes
write this table and a batch takes its ids at insert, not at commit, so a
tail keyed on ids could pass a row that commits late.
The feed's channel-name match and the live search's term fold built their
LIKE pattern from the typed text as it was, so `_` and `%` acted as
wildcards: `build_log` also matched `buildXlog`. The term fold also tested
a substring, so a term already present inside a longer word (`cat` in
`category`) was never folded into search_terms. It now compares whole
terms, escaped.
`like_escape` moves from the Plex query module to `app.utils`, beside
`to_tsquery_str`, and every pattern built from typed text goes through it.
- The download and edit failure lines go back after their DB write: a job the
write could not fail is not a failed job, and the worker's child_exit line
already says it failed.
- The download child's runner is the last stop before Python's hook: an
escaping exception is one download.child.failed line with its chain scrubbed,
then exit 1, instead of a raw stderr traceback carrying the URL.
- A crash the database will not record carries its stack once, and the
fail-write line has an event code.
- The job's own generic-extractor name is taken out as a literal (formats.hiding),
so brackets and colons in a direct link's file name no longer leak; the
pattern stays as the fallback. `Downloading just the video <id>` is covered.
- Tests: scrubbed_stack's cause, suppress, unprintable and text-format paths,
the image steps' failure kinds, the watchdog error through the real
_fail_running, exact poster/storyboard messages, ffmpeg skip marker.
- A failure keeps its traceback unless it is yt-dlp's or our own StatedFailure (a
message that is the whole story). A plain RuntimeError, RecursionError or OSError
is also what a bug of ours raises, and its stack is the only lead.
- The stack is scrubbed link by link (logs.scrubbed_stack): a chained DownloadError
under a DB error no longer prints yt-dlp's text unscrubbed.
- Engine hide_parameters: a DB error's message no longer carries the title or path
it was writing.
- The download and edit failure lines go out even when their DB write fails.
- The worker's child_exit/crash/supervisor lines say download.job.failed only when
the job failed there; a crash the DB will not record no longer ends the thread.
Stopping a paused child is download.child.stopped, not a second abort.
- log_safe also covers generic ids with spaces, playlist titles and bare ids.
- The ffmpeg image steps log the failure kind, not the argv with its path.
- The consent contract accepts only the body of a plain consented_trace() check,
and any level that may be TRACE.
- Tests for each; every one mutation-checked.
- yt-dlp's text is scrubbed wherever it is logged, not only in the adapter:
`formats.log_safe` takes out the URL, the `[extractor] <id>:` prefix and
file paths, and the job-failed, refused, edit-failed and backfill lines use
it. Done lines log the asset id, not the title-built path. The PII contract
now bans `rel`/`rel_path`/`title`.
- A traceback goes with a download/edit failure only when it is our own code
failing; for yt-dlp's DownloadError, our RuntimeErrors and OSErrors its foot
is the unscrubbed message.
- yt-dlp's `error` maps to WARNING: it fires on attempts the fallback client
set recovers from; the job's verdict is `download.job.failed`.
- The sink groups a bare `%s` pass-through by its text (numbers folded), and
a process's last batch closes the repeat window, so a short-lived download
child still reports what it dropped.
- Worker-side failures (stall, wall-clock, no asset, cached asset error) log
`download.job.failed` with a cause; they left no line at all before. The
crash, supervisor-error and non-zero-exit lines carry the code too.
- An edit failure is one coded line in `_fail_edit`, for every path.
- `logs.get_logger("__main__")` resolves to the `-m` module, so the worker
and the download child keep their names.
- TRACE lines carrying user content are gated on `logs.consented_trace()`,
not on the process level; a contract test pins it.
- The OAuth token refresh goes through `_upstream_failure`; transport errors
carry reason `transport`. Subscribe recognises a duplicate by reason code
only (the prose fallback was dead).
- Once-only guards for a stale sysconfig override and a refusing HLS session
directory; item_detail splits Plex failures from our own parse bugs.
- The playlist breaker line names the playlist and the action; comments that
were false (runner noqa, `_merge_vtt`, sink grouping) are corrected.
The gap this sprint was scoped against turned out to be smaller than the survey
said, and in a way worth writing down: `log_requests` already records every HTTP
call with its route template, status, duration, request id and user id, and the
audit log already records every admin act on someone else's account. Putting a
line in each of routes/downloads.py's twenty mutating endpoints would have added
a second copy of the first and nothing else. So this instruments what neither of
them can see.
yt-dlp now explains itself to our log. `quiet: True` was doing more than quieting:
with no `logger` its whole account of a download went to the child's stdout, so
"Requested format is not available", a signature-extraction failure or a POT
refusal reached us only as whatever text ended up in the raised exception — the
real reason a download failed was routinely one line the app never kept. Measured
against the pinned yt-dlp 2026.07.04 before wiring it: with `logger` set it calls
`debug` for extraction and progress lines and `warning`/`error` for the rest, and
neither `quiet` nor `no_warnings` gates those calls. `debug` lands at TRACE, being
several lines a second, so the noisy half is the half the user's own consent
gates. The full source URL is trimmed to its host on the way in — it is a row of
someone's download history, and `logs.set_job` already puts the job id on every
one of these lines. The option is set AFTER the adapter merge, like `cookiefile`
and the size cap: a source adapter must not be able to take away the one account
of what went wrong that a failed job leaves behind.
An HLS session now says which pipeline it got (transcode/remux, hw, tonemap,
variants, offset) — everything downstream is about that choice, and it was only
inferable from an ffmpeg command line nobody keeps. A session culled by the
concurrent-transcode cap says so: somebody's playback stops there, the route turns
it into a retryable 404, and "my film keeps rebuffering" had no other explanation
anywhere. The HW→SW fallback keeps its line and gains an event code.
A live search refused by a quota cap says WHICH cap: the 429 is in the request
line either way, but the per-user daily cap is answered by tomorrow and the shared
budget by the admin.
The download and edit job lifecycle lines gain event codes, which is what E22.6's
console filters on — the job id was already a structured field.
tests/test_ytdlp_logging.py; three of its four cases mutation-checked. A fourth
(a `%` in a progress line) survived its mutation — stdlib only applies `%`
formatting to a record that has args — so it is gone rather than kept as a case
nothing can break; the reasoning moved into the comment on `_emit`.
114 exception handlers neither logged nor re-raised. The defect was never that
they are silent — plenty of them should be — it is that their silence said
nothing, so nobody could tell a decision from an oversight. Each one has now been
read. 44 got a line; the other 70 carry `# silent-ok: <why>`.
What the silent ones had been hiding, in the cases that got a line:
- A failed ffprobe sent every HDR file down the 8-bit pipeline, so playback
degraded to a software transcode with nothing saying why; a failed audio probe
collapsed a multi-audio file to one track and the player showed no language
picker; a failed first-PTS probe left subtitles seconds out of sync.
- A subtitle sidecar that existed but would not parse made a PARTIAL track read as
complete, permanently — the viewer loses every cue past the cached window.
- An unreadable crop/trim/cut-list in an edit spec was dropped, so the clip came
out uncropped, untrimmed, or shorter than the one the user built.
- A dropped `max_height` downloaded at best quality against an explicit cap.
- A failed videos.list enrich made every candidate undroppable-from-judgement and
therefore dropped: a working search rendered as "no results".
- An unusable password hash answered as "wrong password", locking the owner out
and telling them they mistyped; a rotated TOKEN_ENCRYPTION_KEY turned every
stored token into "not connected".
- A stored admin setting that would not coerce fell back to the env default while
the settings page went on showing the saved value.
- A one-off InnerTube config failure pinned the whole process to the hard-coded
fallback key, and searches return nothing when that goes stale.
- The HLS sweep reported 0 swept — like a clean run — when it could not list its
own root, leaving every crashed session's segments on disk forever.
Four of them are counted rather than logged per item (dropped cues, dropped cut
list segments, unreclaimed GC files) and reported once per call, because the
alternative is one line per cue over a whole subtitle track.
ruff gains S110 and BLE001. Every blind `except Exception` that remains carries
`# noqa: BLE001 — <why>`, which is the point: catching everything is sometimes
right, and always worth a sentence. plex/sync.py's three best-effort backfill
catches now keep the traceback — a blind catch over a batch that fetches AND
writes could be Plex, the database or our own bug, and `%s` of the exception told
them apart badly. main.py's boot-time log-level read likewise.
logs.event's `code` is positional-only now: `event("x", code=...)` was a TypeError,
and `code` is exactly the word a caller reaches for when the thing that failed has
one of its own.
tests/test_swallowed_exceptions.py is the wider rule ruff cannot see: a handler
that neither logs nor re-raises must say why. Both cases mutation-checked — a
marker removed and a log line removed each fail it, naming the handler.
25 literal names covered 34 modules. plex/client.py, plex/stream.py,
plex/subtitles.py, plex/sync.py and two Plex route modules all called themselves
`siftlode.plex`; downloads/pipeline.py called itself `siftlode.worker`;
routes/channels.py called itself `siftlode.api`. The `logger` column is in every
stored row so E22.6's console can filter on it, and so an operator can pin a level
to one part of the system — neither works when six modules share a name.
`logs.get_logger(__name__)` derives it: app/plex/sync.py -> siftlode.plex.sync.
One name per module, and it cannot drift from the file it sits in.
tests/test_logger_names.py pins the derivation, that everything stays under the
one `siftlode` tree the handler and the tests attach to, and that no module
hand-rolls a name again unless the line says why (`# logger-name-ok: <why>`).
Two do: get_logger's own call, and `siftlode.http`, which is named for the traffic
it carries rather than the module it lives in — it replaced uvicorn's access log
and is what an operator pins when they want request lines and nothing else.
app/scheduler.py called its logger `logger`; every other module calls it `log`.
ruff gains LOG, G, T20, TRY400, TRY401 and S112. Four findings cleared with them:
worker.py logged `str(exc)[:120]` where the traceback was the whole question, and
three prints/f-strings that are deliberate now say so on the line. S110 and BLE001
stay off until S3 — 23 handlers are outstanding and each is a judgement about one
of them; enabling the rules first would just mean 23 blanket noqas.
The E22 redactor is a safety net over addresses and credential params, and every
one of these call sites was leaning on it to do the whole job. What it does not
cover went in whole, and what it does cover came out as a constant.
Not covered, so a real leak:
- routes/search.py logged the raw search term at ERROR. The privacy policy shipped
in 0.88.0 says a search string is kept only at Teljes/TRACE, with the user's own
consent. The ERROR line now carries the shape of the failure (source, candidate
and collected counts) and the term follows on a TRACE line, which nothing emits
unless that consent is in force.
- downloads/backfill.py logged the full source URL at INFO — a row of the user's
download history. Now the asset id and the host.
- youtube/client.py logged the upstream error body, which is prose that quotes the
request back. Every failing YouTube call now goes through one _upstream_failure()
that logs the API's `reason` code, keeps the body for the same consented TRACE
line, and raises a YouTubeError carrying `reason` as a field — callers put that
error in log lines of their own (routes/channels.py) and in a 422 answered to the
user, so the body must not travel in it either.
Covered, so merely useless: routes/auth.py named the account by an address that
reached both sinks as `[email]`, and app/email.py printed recipient lists that read
as `['[email]']`. Both now carry the uid (or a count) and a reason code, which is
also the first half of E22.5 S4's failed-sign-in coverage.
routes/channels.py branched on the text of that error message to recognise an
already-existing subscription. It now branches on `exc.reason`, with the substring
check kept beside it for a body that carried no reason at all.
tests/test_log_pii.py pins the call sites and the family: a source-level contract
test walks every log call in app/ and fails on an argument naming user-typed
content unless the line says why it is safe (`# log-pii-ok: <why>`). Eight sites
carry that marker today, each with its reason. Gate 8/8 green; all five cases
mutation-checked.
E22 (activity trail): a consented, self-expiring diagnostics level with the user's own log visible
before anything is sent, last sign-in, bilingual privacy policy and terms, an admin diagnostics
section, and pickers for every closed-set setting. Plus what the epic's measurements turned up on
the way: doubled chat delivery, Settings on a phone, locale-ignoring dates and numbers, and the
feed poll narrowed to followed channels.
Three round-1 fixes whose tests passed against the broken version too.
The focus restore keeps watching until the element that closed the box is GONE, rather than spending
itself on the first render after the change — the distinction Chrome exposed and jsdom did not,
because jsdom commits the save in one render where Chrome takes two. Driven now through the general
case instead: the user moved the focus elsewhere while the request was out, renders keep coming
while that element is still there, and the restore happens only once it disappears.
The choice test's i18n mock returned an option's bare value, which is exactly what dropping the
`config.choices.*` lookup renders — a Hungarian admin would have seen `on`/`off` instead of
`GPU`/`Szoftver` with the gate green. Marked labels now, still short enough not to move the
segmented-bar budget the same file measures.
The admin config row's stacking had no test at all, only its sibling fix did — and stacking is the
only fix for a text or number key, whose fixed-width input is the wide control. Class contract, as
SearchBar.dom.test.tsx already does for the narrow-header rules, since jsdom does no layout.
Also: the time-zone cleanup deleted TZ, and Node re-reads it on assignment only, so the process kept
the last zone the file set. Harmless until someone appends a test below it.
Picking "Alap" revokes the consent, and a batch still in flight is then answered by the server with
the same 409 a real expiry gets. The 409 arm only checked that the consent session had not changed,
not that this tab was still streaming — so the user's own log gained a "diagnostics level lapsed"
line for a level they had just turned off themselves, plus a pointless `me` refetch. The same guard
also stops a lapse this tab has already announced being announced a second time.
Measured in Chrome by holding the POST at the network layer, switching to Alap and only then
answering 409: the line appears without the guard and not with it.
Three tests that named the session counter never reached it — they settled the refusal while the
stream was already stopped, where `unsubscribe` does the work, so the counter could be deleted from
both arms with the suite green. The refusal now settles after the re-raise, which is the ordering it
exists for. Same for the queue trim on the 429 path: the old test unshifted a batch into an empty
queue, so the trim was a no-op and only the subscribe path was ever measured.
Two reviewers in a row read the admin card's selection against `run_deep_backfill`'s, saw it was
missing the job's `recent_synced_at` filter, and called it a card that counts channels the job will
never touch. It is the opposite: the card is the same backlog figure /api/sync/my-status shows the
user, and a channel still missing its recent pull is one the same job tick fetches first. Narrowing
it would hide genuinely queued work. Written down where the next reader will look.
Round 2 of the E22 review, on what round 1 changed.
`redact_query` decodes a parameter before the text rules see it, which is what lets `%40` be
recognised as an address — but `_SECRET_PARAM_RE` was written for the raw, encoded form, where a
value holds no space, `&` or quote. On decoded text its value class stops at the first one, so
`?state=a%20b` kept its tail and `?code=%20abc` was not matched at all. The parameter-NAME rule now
decides, on the key, before any text rule runs.
`forget_user` dropped the account's level elevation without recomputing the root level, so a process
whose only elevated account had just deleted itself kept building TRACE records to throw away, until
some later request happened to recompute it. Every other path that drops an elevation recomputes.
The sink's writer took the erasure lock and THEN waited for a pooled connection, while every request
path already holds a connection when it reaches that lock — so under pool pressure the writer held a
process-global lock against the very requests that could hand a connection back. Same order on both
sides now; the lock still spans the check, the insert and the commit.
The storage-budget notice said the rest of the hour was not kept, and then smaller batches kept
landing underneath it: the window slides and a refused batch is not charged to it. Says what it does.
Tests: the route's half of the erasure rule had no test — only the sink's did — and a source-level
contract test now covers the third insert nobody has written yet. `logs._erased` is process-global
with an hour's memory while the DB fixture hands serial ids back out from 1, so it is swept before
each test like the rate limiters are.
formatDate read a date-only value as UTC midnight and showed it in the viewer's zone, so the legal pages' last update read a day early west of UTC. Date-only values are shown in UTC. The legal pages' hand-rolled language buttons are the landing page's LanguageSwitcher. formatDateTime and formatDate get the two-language test formatNumber already had.
A segmented bar cannot shrink or wrap, and the five log levels (~300 px) are wider than a phone leaves beside the navigation rail. A choice is a bar only when its labels are short; the rows stack below sm.
Deep-review round 1 of E22.
- After a 429 the stream sent one POST per new event while a full batch
waited; it now stays quiet for 15 s. Past the queue cap the oldest
events go, as the comment always said, a refused batch included.
- A batch refused after the level was lowered came back into the queue
of a later consent in the same tab; each consent is its own session.
- A `me` fetch that left before a level or preference change could put the
old value back for up to a minute. `updateMeCache` writes the cache and
cancels that fetch; usePref goes through it too.
- The consent box's buttons and "revert now" took the focus with them to
<body>; it returns to the level picker. Watched until the button is
gone — in Chrome the saved level arrives in two renders, and a one-shot
check spent itself on the first (jsdom never showed it).
- The countdown line is no longer a live region re-read every minute.
- "Saját napló" rows were keyed by index on a newest-first list, so every
new event remounted every row and dropped the focus off a row button.
The explore purge rebuilt followed_channel's two EXISTS clauses inline, so what is polled and what may be deleted could drift; it now uses the predicate, which moved to sync/explore.py. The env validator and sysconfig each folded a choice value on their own; both call config.canonical_choice. The deep-backfill comment no longer calls recent backfill unconditional.
The bind sat inside the try, so it was already reset when the crash line and the cleanup were logged — the one line most worth finding by job id had none.
Deep-review round 1 of E22.
- A NUL character (`GET /assets/%00` logs the decoded path) made Postgres
refuse the row and with it the sink's whole batch, every user's rows
included. `to_row` replaces NUL in every text column and in attrs.
- An erased account's lines kept arriving after purge_user's DELETE: the
erasing request's own access line, a queued row, a background task.
`logs.forget_user` runs first; later rows are stored without the account,
and the check and the insert share a lock with the erasure.
- An admin setting the whole process to TRACE logged every user's query
string. The query is now logged only under the user's own Teljes consent,
and decoded before redaction, so `%40` no longer hides an address.
- The browser log ingest has an hourly storage budget per account (5 MiB,
one notice when spent) and reads only a JSON Content-Type, so a
same-site text/plain form post cannot write into a user's log.
RateLimiter takes a per-attempt cost for the byte budget.
- The root level is applied under the elevation lock: two concurrent
changes could leave it at the base while an elevation was active.
- Tests for the running sink (level gate, drain on stop) and for the
admin log level applied at runtime and at boot.
- Home tests mocked the api without downloadIndex, so every card's download button logged a
missing-queryFn error (64 stderr blocks per run).
- QueryState rendered without an i18n instance; Modal.fullscreen awaited a synchronous act,
which React reports as an act environment it does not support.
- pytest: Starlette 1.x's TestClient is built on httpx2 and warned on every run without it
(pinned in requirements-dev); alembic.ini declares its path separator.
The 43 react-refresh/only-export-components warnings the gate let through meant an edit to a
provider (or AnchoredPopover, Toaster, plexDetailUi, …) could not be hot-swapped: Fast Refresh
reloads a module that also exports hooks or helpers.
- Each provider's contexts, the types its hooks return and the hooks themselves live in a sibling
context module (navigationContext.ts, channelsContext.ts, …); the provider file keeps the
component. loadAccountFilters moved next to the feed filters' defaults (feedFiltersInit.ts),
following navigationInit/playlistsInit.
- Helpers that sat in component files have their own modules: welcomeMode, downloadTitle,
plexPlayable, channelSync, ui/anchoredPopoverHooks, ui/pageToolbarSlot, ui/pagerContent,
ui/toastLevels, ui/overlayOrder, ui/tableAlign, lib/plexDetail; the cells of the channel columns
and the registry's page wrappers are components files of their own (channelCells, modulePages).
- usePersistedTab, an alias of useAccountPersistedState, is gone; its callers use the original.
npm run lint is now eslint --max-warnings 0, so a warning fails the gate like an error does.
The 12 react-hooks/exhaustive-deps warnings the gate let through:
- ShareDialog and YearFilter built a fresh [] per render while their query loaded, so their
memos recomputed on every render; they share one empty list now.
- VideoEditor's drag effect called a seek/moveBoundary recreated each render; both are stable
callbacks in its deps.
- WizardProvider re-checks onboarding on exactly the fields it reads (not the me object, whose
60 s refetch would reopen a closed wizard).
- Channels, Feed and PlexPlayer list the stable values they already used.
- The player's keydown effect is bound once on purpose (its helpers read refs): said so, with a
disable, as the codebase's other deliberate sites do.
- dialogFocus and the YouTube player's cleanup read refs React may already have let go of; they
use what they captured when the effect ran.
A user can raise their diagnostics level to Részletes (DEBUG) or Teljes (TRACE) for 24 hours from a
new Settings tab, after a consent box that says what is recorded, that the server keeps it and for
how long (the instance's own retention), and that it lapses. Alap is the base and needs no consent.
- users.diagnostics_level/_until (migration 0087), written only by PUT /api/me/diagnostics, which
computes the expiry itself; the demo account cannot raise it. /api/me reports the level in force.
- The server logs an elevated user's requests at that level and stores every line of them
(logs.LevelGate on the stdout handler and the sink; the root is lowered only while an elevation
is active). Teljes adds the redacted query string to the request line.
- POST /api/diagnostics/events stores the browser's log as source=web rows: account from the
session, body capped while streaming, events/attrs capped, re-redacted, events under the
consented level dropped, timestamps clamped, 30 batches a minute, 409 once the level lapsed.
- lib/diagnostics.ts applies the level to the tab and streams only what happens after consent, in
batches, never its own request lines, with a keepalive flush on pagehide; stops on lapse (its
own timer), on withdrawal and on 409.
- Detail hooks at DEBUG: which control was clicked (test id, icon, app copy in English — never user
text, input values or a link's query), visibility/bfcache/resize, filter changes
(lib/filterLog.ts; typed search only at TRACE), player events (lib/mediaLog.ts; media errors are
warnings at every level). Offline/online at the base level.
- "Saját napló" viewer: live, newest first, level groups with counts, search, row details, copy
the filtered events as JSON lines, clear.
- Privacy policy and Terms exist in Hungarian and English (a contract test holds both languages to
the same sections); the policy now describes the server logs and diagnostic logging, self-service
deletion and password sign-in.
Fixed on the way:
- A sync auth dependency ran logs.set_user on a copied context, so the account never reached a
handler's own log lines (measured); a per-request mutable scope carries it now.
- The Settings tab rail left a phone no room (clipped labels, buttons over text): below `sm` it is
a scrolling row above the content.
- PUT /api/me/preferences took a bare dict body; it is a Pydantic model now.
The browser now keeps its half of the one event shape logs.py defines:
lib/log.ts is a levelled, redacted, bounded (2000 events) in-memory ring
with the server's level names and severity numbers. Nothing leaves the
browser yet; sending is the consented diagnostics level (S5).
- lib/http.ts `send` is the one fetch: every request carries an
X-Request-Id and becomes one http.request event. The server echoes the
matched route template in X-Route (only for a matched route, so a raw
path with a value in it is never sent back); without one the client
templates the path itself. req, beacon, the upload XHR, the public
watch page and the legal pages all go through it.
- Hooks: app start snapshot, uncaught errors and rejections, History
push/replace/popstate (a scroll-only replace is not an event; parameter
values only at DEBUG, typed search only at TRACE), notifications, the
error dialog, the crash boundary, failed queries and mutations (key
head only), and the messages socket (lifecycle, frame types at DEBUG).
- Redaction is one contract: backend/tests/fixtures/log_redaction_cases.json
runs against both redact() implementations.
- Lint: fetch, XMLHttpRequest and WebSocket each belong to one logging
module, console to none; a contract test proves the rules fire.
Fixes a socket takeover: the close of a socket released by the last
subscriber cleared the newer socket a quick re-subscribe had opened and
scheduled a third, leaving two live sockets that delivered every message
twice (measured in Chromium: 3 created / 2 live before, 2 / 1 after).
The scheduled RSS poll and recent backfill covered every channel row,
but rows also come from search results, playlist sync and dropped
subscriptions, and nothing deletes them. On prod 582 of 979 polled
channels had no follower, three of them dead feeds answering 404 on
every poll. Both jobs now take subscribed or explored channels only,
and the scheduler card counts the same set. As a deliberate trade-off,
the shared Library no longer receives new uploads from channels nobody
follows.
The log now has a stored, queryable copy for the diagnostics console:
every WARNING and above plus the request line of every request, kept
for log_retention_days (default 7, no "forever") and pruned hourly by
the new log_gc job.
Rows are built on the logging thread from the same redacted fields as
the stdout line, queued without blocking and written in batches by one
background thread on its own connection. A full queue or a failed write
drops and reports instead of retrying. Flood control keeps 30 of any
one message per minute plus a count of the rest; anonymous request
lines are capped at 60 a minute per client, and the client address is
never stored. Deleting an account deletes its stored events.
Measured on prod first: a YouTube RSS outage wrote 11k warnings in four
hours, one per channel per poll. The poll now writes one warning with
per-reason counts and a few examples, and a failure on our side gets
one traceback per poll instead of one per channel.
"Naplózás" (logging) named the audit log in the nav, its page and its
Configuration tab, right beside the new "Naplózási szint" (log level)
under Diagnosztika — two different things under one word. The scheduler
hint also pointed at a "Beállítások → Naplózás" path that does not
exist; it is Konfiguráció → Auditnapló, as the English already says.
log_level, search_source, download_layout and the transcode hw/preset
were typed by hand, and every consumer carried its own guess at what a
typo meant. The value set is now a Literal in config.py: pydantic
rejects a bad env value at boot (any letter case accepted), sysconfig
refuses a bad override and derives the admin picker's options from the
same Literal, and the consumers read the value as-is.
The Configuration page renders a choice key as a segmented bar, or a
dropdown past six options, with translated option names. Its tabs now
follow the registry's order instead of a hand-kept copy that had
already dropped the explore group.
A single-choice FilterSelect now opens on the option in use: in the
browser it could land on the first row instead.
A review round read yt-dlp's source and found the comment behind the previous commit's safety-net
marker did not match it. Measured in the worker with a real account cookie and
`player_client=android_vr`: yt-dlp logs `Skipping client "android_vr" since it does not support
cookies`, drops it, and the request left with no client fails with "Requested format is not
available" — not "No player clients have been requested". That raise sits EARLIER in yt-dlp, before
the cookie-capability removal, so the cookie path can never reach it; and an unknown or renamed
client is skipped with a warning and replaced by yt-dlp's defaults, so the other scenario the
comment named cannot reach it either.
So the marker bought nothing: the symptom that does occur was already retriable here before this
branch. It is dropped, the comment now states what was measured, and the test asserts the real
symptom instead of a message the code cannot produce. The cookie-capability filter itself is
vindicated by the same measurement — without it every signed-in download would burn its first
attempt on a client yt-dlp discards.
Also from the round: an enqueue test for a `source_kind="youtube"` job, the one join nothing else
made (a YouTube job stores a bare 11-char ref and the URL the cookie gate keys on is rebuilt by the
adapter — both ends were pinned apart, the join was not, and a break there would stop every YouTube
cookie silently while signed-out downloads kept working). And copy corrections: the group's only
unhedged claim is hedged, "described the video" loses the pipeline vocabulary, and four Hungarian
phrasings that read as translated English are rewritten.
Measured on the isolated test stack the same night, on the very same video ~90 minutes after it was
answering with the bot message: yt-dlp got the player response and picked its formats, and the CDN
then refused the stream itself — "unable to download video data: HTTP Error 403: Forbidden".
Signed out it did that on every attempt; with the owner's cookie attached the video downloaded in
full. So the wall moves between the player response and the stream, and the text it wears there was
costing the download twice.
retriable: the format URLs belong to the client that minted them, so the next client set is a real
second chance — but this text classifies as `network` and nothing in the YouTube marker list matched
it, so the ladder ended the job on attempt 1 and never tried the fallback.
catalog: `youtube.media_forbidden` for the cookie-less case, so the UI stops calling it a network
error when nothing was wrong with the network, and names what actually gets past it. Scoped exactly
as narrowly as the measurement: another host keeps the generic `network` category, and so does a
download that already carried a cookie — telling a signed-in user to sign in is the failure the
catalog exists to avoid. EN + HU copy.
A YouTube link can fail with "Sign in to confirm you're not a bot" while every
other YouTube download works — YouTube gates INDIVIDUAL videos behind a
signed-in session. Measured on prod job 273: all twelve of yt-dlp's player
clients fail on that one video while its neighbours download fine, the bgutil
PO-token sidecar is live and answering, and the newest yt-dlp fails identically.
So the download is not broken by our stack or a stale yt-dlp, and no retry,
client switch or upgrade gets past it. A cookie is the only way in.
The failure copy already sends the user to Settings -> Account -> Download
authentication, but YouTube was not in the cookie host registry, so that
dropdown could not help them: the app asked for something it did not offer.
credentials: YouTube joins SUPPORTED_HOSTS (youtube.com, youtu.be,
youtube-nocookie.com). The Settings dropdown, the SEC-12 host scope and the
private-asset cache salt all derive from the row, so that is the whole wiring.
adapters: a signed-in YouTube download runs only cookie-capable player clients.
Measured against yt-dlp's own INNERTUBE_CLIENTS table: with account cookies
attached yt-dlp REMOVES every client whose SUPPORTS_COOKIES is False, and a set
it empties that way raises "No player clients have been requested" — which is
not a per-client flake it retries past. Our primary set is android_vr, one of
those, so without this the cookie would have failed the download on attempt 1
and never reached the POT-backed web fallback it exists for. A test pins the
client list against the installed yt-dlp so a version bump turns the gate red
instead of silently un-authing every YouTube download, and the empty-set message
joins the retriable markers as the second line of defence.
errors: catalog entries for the wall with and without a cookie, so a user who
has already added cookies is told they were rejected (YouTube rotates exported
ones) rather than to add some. The marker is quote-agnostic: YouTube writes the
apostrophe as U+2019, so "you're not a bot" with a straight quote never matches
the real text. EN + HU copy.
A deployment printed four different log formats and none of them could be
joined up: uvicorn's log_config.json for the API, a hand-rolled StreamHandler in
main.py for anything not started by uvicorn, logging.basicConfig() in the worker
and in the download child (no timestamp at all), and alembic's fileConfig.
app/logs.py replaces all four with one JSON line per event (OpenTelemetry's log
data model, no SDK): ts, level, severity, source, logger, event code, message,
attributes, plus the correlation the context carries — request id, account,
download job.
Redaction is applied at the HANDLER, not at the call sites: every message, every
attribute and every traceback is scrubbed on the way out, so a secret cannot
leak by being forgotten at one of ~125 call sites. Passwords, tokens, cookies,
secret config values and email addresses never reach a line.
The access log is ours now (`http.request`): the route template instead of the
concrete path, the status, the duration and the account — and never the query
string, which uvicorn's access log printed on every single request (`?account=`,
`?source=`, the `?token=` of a share link). A well-formed client request id is
accepted and echoed in X-Request-Id, so a browser line and a server line join up.
The account comes from `request.state`, filled by the auth dependency: the
middleware is the outermost one, so it runs outside SessionMiddleware and in a
different task context.
`log_level` becomes an admin knob (sysconfig), applied without a restart and
re-applied at startup — best-effort on purpose, since a logging knob must never
be what stops the app from booting. Every compose service gets bounded json-file
logs (3×50 MB): Docker keeps every line forever by default, and the API now
writes one line per request.
Verified on the localdev stack: JSON everywhere including alembic and the
worker, the query strings gone, a supplied X-Request-Id echoed and logged, and
three /healthz hits of which only the one made while the level was DEBUG
produced a line.
`n.toLocaleString()` groups in the BROWSER's locale, so an English UI in a
Hungarian browser printed "1 234 567" while a Hungarian UI in an English
browser printed "1,234,567" — the number half of the date defect fixed in
618ef812. One `formatNumber` in lib/format.ts (a per-language cached
Intl.NumberFormat, like the relative-time formatters beside it) now serves
every grouped figure: Stats, Scheduler, the channel columns and the stats
popover, VideoCard's view tooltip, the ItemCount pill, and formatTotalHours.
ReleaseNotes' `toLocaleDateString(undefined, …)` was the one DATE site the
previous sweep missed; it moves onto formatDate.
The new cases assert both languages and that the two differ, instead of
comparing against the runtime's own default: that default is Hungarian on the
dev box and English on a CI image, so a one-language assertion would pass
under the old bug on half the machines. Verified in Chromium on :5173 with the
browser forced to hu-HU and the UI in English — Stats, Scheduler and the
channel table all keep comma grouping.
Seven sites formatted with a bare toLocaleString()/toLocaleDateString(), so an
English UI on a Hungarian machine printed Hungarian-shaped dates (and the reverse).
They now go through formatDateTime/formatDate, which take the i18n language.
A right-aligned column at the end of every Users & roles row says how long ago the
account signed in, with the exact moment as the hint and as a line in the account
panel; a dash where nothing is recorded, and the demo account says it is not recorded.
formatDateTime formats that moment in the UI language rather than the browser's.
users.last_login_at (migration 0085, no backfill) is stamped by _establish_session,
which the Google callback now shares with password login instead of repeating its
session setup inline. The demo entry does not stamp its shared account. The admin
user list carries the field.
The providers mount for the public landing too, and the saved-views query had no
enabled gate, so every signed-out load logged a 401 for /api/saved-views. The query
now waits for a signed-in account in BootGate's me cache, like WizardProvider.
Deep-review round 4, targeted at round 3's changes (f569b19c..e7f38396).
Accounts
- Switching, adding or signing out an account moved the tab's pin before
the page unloaded, so the players' resume beacons fired on the way out
under the NEW account: one account's playback position saved into the
other. The pin is now handed to the next page (pinOnNextPage) and taken
over at import; clearActiveAccount is gone.
- Switching to a suspended account was refused but left it in the
switcher, refused on every click; a deactivated one was not refused at
all, and made the default it signed every account in the browser out on
the next request. Every account _session_user would refuse — gone,
deactivated, suspended, revoked — is refused and dropped from the list.
Share page (/watch)
- An IPv6 client is limited by its /64 (client_ip becomes client_key, for
every limiter): keyed on the full address, one subscriber could rotate
through endless identities. The comments now state what the per-link
ceiling costs: guessing from five networks can keep a link shut for its
window, and guesses already running when it fills are still checked.
- The link is looked up before the limiter records the attempt, so made-up
tokens no longer grow it by an entry each.
Forms
- Failing the same way twice is announced again: the sign-in, passphrase
and setup errors are keyed on the attempt (a clear and a set in one
handler left the alert node untouched).
- The install wizard's own validation showed the generic error — it threw
plain objects its catch did not read. It sets the step's message.
Tests: a re-pin leaving the previous account's notices behind; the pin
hand-over; switching to an ended account; the /64 viewer; made-up tokens;
the repeated alerts. Each mutation-checked RED for its own reason.
Deep-review round 3 over main..improvement/watch-background-audio.
Account boot
- The notification history and settings, and the hints toggle, are read
when their modules load, which in a new tab (bookmark, typed URL) is
before BootGate pins the account: they read as nobody's, and the tab's
first notice replaced the account's stored history (up to 100 entries)
with a one-item list. They now read again when the tab is pinned
(onAccountPinned, run by setActiveAccount); a notice raised before the
pin stays, after the history.
- A tab pinned to an account still in this browser's wallet but whose
session ended (suspended, deleted, or revoked by "log out everywhere")
got "signed out" from /api/me while the default account was still
signed in, and signing in again as anyone else left it there. A refused
per-tab account now leaves the wallet, and the probe answers as the
default, so the tab reloads onto it.
Share page (/watch)
- The password unlock is limited per viewer (link + client IP), under a
per-link ceiling that counts only wrong passwords. Keyed on the link
alone, ten wrong guesses every five minutes locked out every viewer,
the ones with the password included. client_ip moves to app.ratelimit
so the public routes can key on it.
- Its failure messages and the load-failure screen are announced
(role=alert, the password input described by its error). The sign-in,
passphrase, password-change and setup form errors get role=alert too.
Comments that still described removed machinery are corrected: App's
collapse flags, SettingsPanel's Save bar, ConfigPanel, authed_format_sig.
Tests: the account's history and hints across the pin; a revoked and a
suspended pinned account; the per-viewer limit and the ceiling; the
limiter's exhausted(); the share page's announced failures; a share
link's feed filters persisted. Each mutation-checked RED for its own
reason.
Deep-review round 2 over main..improvement/watch-background-audio.
Account resolution (server)
- A request naming an account that is no longer signed in to this browser
(X-Siftlode-Account or ?account=) now acts as nobody instead of the
session's default account. A tab still pinned to an account signed out in
another tab went on acting as the default: its player checkpoints and
preference saves landed in that account, the messages socket and Plex
playback ran as it, and its Sign out signed THAT account out. A hidden tab
never polls /api/me, so a tab playing in the background kept doing it.
The /api/me bootstrap probe alone keeps the fallback, which is how such a
tab learns who it now is: the app's 401 handler reloads, and BootGate
re-pins the tab.
BootGate
- On an identity change the tab reloads without moving its pin first. The
tree being torn down still runs its unmount saves before the page goes,
and with the pin already moved they wrote into the new account's local
keys (a player volume still pending). The reloaded page corrects the pin.
Share page (/watch)
- Only a 404 says the link is gone. A 5xx, a dropped connection, and the
unlock's own rate limit all rendered "no longer available ... expired or
revoked" to a viewer with a valid link, with no way to try again. A load
failure now offers Retry; too many password tries says so and keeps the
form; an unlock that gets no answer says so instead of doing nothing.
Strings in en/hu/de.
Downloads
- A cookie-authed asset is recognised under the current signature version
and the one before it, so a job queued before a deploy that bumps the
version still downloads with its cookie instead of failing on the login
wall with an error that says no cookie was used.
The app chunk is preloaded through lazyWithPreload, so a failed fetch no
longer leaves an unhandled rejection beside the ErrorBoundary. The panel
layout seed is one helper (seedLayout).
Tests: the stale-tab refusal, its harmless Sign out and the probe fallback;
the pre-bump authed asset; BootGate keeping the pin on reload and staying
mounted through a same-account change; the reveal clock's later-commit
hand-off; the prefs, panel layout and demo-scope seeds from the me cache;
the share page's failure states. Each mutation-checked RED for its own
reason.
Deep-review round 1 over main..improvement/watch-background-audio.
BootGate
- The tab's account pin is corrected to the account the server answered as.
The server honours the pin only for an account still signed in to this
browser and otherwise answers as the default, so a tab pinned to an account
signed out in another tab ran as the default on the server while reading
and writing the gone account's local state.
- A mounted tab whose identity changes (its session ended, or the case above)
reloads instead of rebuilding the tree in place, like every account switch;
that also drops the previous account's query cache. The account key on the
tree goes: the readyFor gate already unmounted it on every change.
- An unanswered setup probe shows the error with Retry, like an unanswered
account probe, instead of the signed-out landing to a signed-in user.
- Comments: /api/me never 401s; the ended-session path is identity → nobody.
Loading states
- LoadingState's reveal delay is one clock across loading states that hand
over to each other (boot screen → page-chunk fallback → the page's own
fetching state), instead of a CSS delay restarted by each, which blanked a
visible spinner for another 300 ms at every hand-off. BootScreen is now the
shared loading visual, full screen, counting from navigation start.
Plex shared link
- Persists only the dimensions the link carries again. Writing the default
filters for an omitted `pf` created the stored-filters key, which is what
tells SavedViewsProvider the account picked filters, so the account's
default Plex saved view was never applied again.
Watch page
- The Repeat toggle uses the shared pressed-pill paint (a solid fill when on)
instead of Button variants that differ only in colour, and its flag goes
through storage.ts readJSON/writeJSON.
- The actions wrap under the title when the row is too narrow, instead of
squeezing the title into a column a word wide on a phone; the title breaks
long words.
Comments that still justified code by the removed adopt effects (usePref,
PrefsProvider, FeedViewProvider) are rewritten against the seed-once model.
Tests: BootGate error/retry for both probes, setup mode, a failed background
refetch keeping the app, the pin correction and the reload on an identity
change; the shared reveal clock; the Plex link persist; the audio signature
actually moved off version 4; a video download (av and v) still remuxed to
an mp4. Each mutation-checked RED for its own reason.
On iPad Brave, a shared video stopped the moment the browser left the
foreground, even with Brave's "Enable Background Audio" on, while an mp3
kept playing. Brave iOS keeps a web <video> going by re-playing one the OS
paused (brave-core ios/browser/web/media/resources/media_backgrounding.ts),
but it only hooks a <video> present at document start or added as a
MutationObserver addedNode that IS a video. React inserts a player's
<video> inside its freshly mounted wrapper in one go, so Brave never saw it.
lib/browserBackgroundPlay re-inserts a mounted <video> at its own position,
once per element: it becomes an added node, the DOM React manages does not
change, and playback is not interrupted. The watch page, the downloads
library player and the Plex player hand their <video> to it. Measured on the
owner's iPad (Brave) with a prototype of the same idea: the video kept
playing in another app and with the screen locked. The YouTube modal is not
ours to change: its <video> lives in YouTube's cross-origin iframe, and
nothing of ours pauses it on backgrounding.
The prototype's two audio-element variants (a muted video following an
<audio>, and an <audio> taking over on hide) went silent in the background on
the iPad and are removed.
The watch page gets a Repeat toggle, on by default and remembered per
browser (LS.watchRepeat; the page has no account): it sets `loop` on the
<video> or the audio-only <audio>.
On iPad Brave a shared mp3 keeps playing when the browser leaves the
foreground, but a shared h264 video stops at once, even with Brave's own
"Enable Background Audio" on. Three variants behind a `?bg=` param on the
/watch page, for a real-iPad comparison; without the param the page is
unchanged.
a: the sound comes from a hidden <audio> on the same file, the <video> runs
muted and follows it (drift correction, user seeks and mute moved onto
the audio; our own catch-up seeks are not copied back, which re-seeked
the sound on every correction and sent both back seconds in WebKit).
b: the <video> plays with sound; an <audio> unlocked by the start tap takes
over while the page is hidden and hands back on return.
c: no audio trick: the <video> is inserted as a node of its own into an
already-mounted wrapper. Brave iOS's background play only hooks a video
present at document start or added as a MutationObserver addedNode that
IS a video (brave-core ios/browser/web/media/resources/
media_backgrounding.ts); the page inserted the video inside a new wrapper
in one go, so Brave never saw it. A re-implementation of that detection
rule reports the default page's video unhooked and mode c's hooked.
Modes a and b were driven in Chromium and WebKit with a simulated backgrounding
(document.hidden forced plus the OS's pause): sound continues, the picture
catches up on return, a user pause stops both. iPadOS's own media policy can
only be judged on the iPad.
The Vite dev proxy had no `ws: true` on /api, so the in-app messages socket
(/api/messages/ws) was an upgrade nobody handled: it never connected on :5173,
and the first reset on it (a closed tab, a dropped connection) threw an
unhandled ECONNRESET that took the whole dev server down. Reproduced with a raw
upgrade + RST (dev server dead); with `ws: true` the backend answers the
upgrade, the dev server survives the reset and a closed tab, and the signed-in
socket is accepted through the proxy.
The Playwright net now runs every flow in three projects: chromium (Desktop
Chrome), webkit (Desktop Safari) and ipad (iPad gen 7 landscape: touch, mobile
viewport). WebKit catches the engine-level differences every iOS browser
inherits; the config and the README say plainly what it does NOT catch: iPad
media refusals. Playwright's WebKit decodes through the host OS, and on Windows
it played an mp3-in-mp4 and an AV1 mp4 that a real iPad refuses, so media
formats are checked on a real iPad against the dev stack over the LAN.
global.setup opts the e2e account out of starting on Home (the Startup setting),
which the feed, channel and player specs need since E24. It only takes effect
with the new-tab boot fix: before it, all twelve of those specs landed on Home
in every project. 49/49 green on :8080 across the three projects.
With Settings -> Appearance -> Startup turned off, every NEW tab or window still
opened on Home; only a same-tab reload honoured it. The provider tree mounted
before /api/me answered, so NavigationProvider's initializer could not read the
per-account opt-out, landed on Home and mirrored ?page=home into the address bar.
When App pinned the account a moment later, the re-hydrate re-read that mirror
and stayed on Home. The same mount-then-patch shape made a new tab start on
default filters, the default theme and the browser language, and correct each
after the first paint.
BootGate (main.tsx, above the providers) now owns the setup-status and /api/me
queries and mounts the tree only once the identity is settled: it pins the tab's
account and adopts the account's language in a layout effect, so the first frame
of the app is already right. The tree is keyed on the account id, which makes
"per-account state is read once, for this account" a structural guarantee.
With that, the whole re-hydrate machinery is gone: useAccountRehydrate, the
reactive active-account store, the markDirty guards, the pin-deferred saved-view
seed and the present-dimension bookkeeping of the shared Channels/Plex links.
PrefsProvider, FeedFiltersProvider, usePref and App's panel layouts seed from the
me cache instead of adopting it in an effect; the theme is applied in layout
effects. The app chunk is fetched in parallel with the probe, and the boot screen
only fades its label in after 400 ms, on one clock across the gate and the
Suspense fallback.
Module switches no longer flash a placeholder either. Measured frame by frame
in Chromium and WebKit, every first visit to a module showed "Loading..." for
17-70 ms: the lazy chunk's Suspense fallback, then often the first fetch's
LoadingState. Pages are now lazyWithPreload components, and App fetches the
chunks of every module the account can reach (plus the channel page) once the
first page is idle; a preloaded page renders synchronously and never suspends.
LoadingState, the one shared loading visual, fades in only after 300 ms, so a
fast load shows nothing in between and a slow one still gets its spinner.
Since a Facebook cookie was added on prod, every reel came down titled
"Video": measured on dev, a signed-in read gives yt-dlp the placeholder
"Video" (an 18+ reel read as Chrome gives "Facebook") while the post's text
is still in the description. A new SourceAdapter.title hook takes the whole
first line of the description in that case, and both title writes (the early
fill and the finished download) go through it, so the row, the file name and
the share preview carry the post's text again. The line is not cut with an
ellipsis: normalize_title's NFKC turns "…" into "..." and collapses it to
".", which read as a finished sentence (seen in dev UAT).
The "Audio (MP3)" profile's extracted .mp3 was remuxed into an .mp4 by the
browser-safe step (the audio spec is clamped to container mp4 too) and served
as video/mp4, which iPad's WebKit will not play (prod job 268). Audio mode now
skips that step, so an .mp3 is served as audio/mpeg and an .m4a as audio/mp4.
Audio downloads version their cache identity on their own (v5), so a cached
remuxed audio re-derives without re-downloading every cached video.
Pause now re-reads the job under the same asset-then-job row lock as resume,
cancel, delete and redownload. Its unlocked write waited for a concurrent
cancel's lock and then set the released job back to paused, a holding status,
so a later delete released the same hold again and could free a file another
user's job still held (measured: the overlapping-request test ends 'paused'
instead of 'canceled' with the old read). The lock test now calls the route
handlers, so it also pins which helper each route uses.
Comments follow the code: resolve_url's contract names both verdicts and that a
failed page read is raised as itself; the inherited-failure diagnosis asks the
cookie gate as it stands when the job is claimed, so its advice fits the cookie
that job's Retry would use; the undecryptable-cookie warning no longer claims a
download goes ahead, since the worker parent reaches it only to diagnose.
A refused Facebook cookie on a group post is no longer "no video in this post":
measured on dev, Facebook answers with its login page (no post id at all), so the
adapter now raises its own POST_NOT_SHOWN verdict and the catalog explains it as
the refused cookie. The adapter owns both post verdicts and the "cannot parse
data" wall wording, so the Chrome retry and the catalog read one constant. The
group-post "no formats" entry is cookie-scoped (with a cookie yt-dlp only gets the
post's video link) and uses the adapter's own group-post test instead of a
"/groups/" substring.
Job transitions that move an asset's ref count (resume, cancel, delete,
redownload) re-read the job under a row lock, asset first, so two overlapping
requests can no longer release the same hold. get_or_create_asset loses an insert
race inside a SAVEPOINT instead of rolling back the caller's pending writes (Retry
lost its cleared error). Every worker-side job failure goes through
finalize_if_running and writes error_detail, so a cancel that landed first is kept
and a failure never shows an earlier one's copy. The worker asks the download
child's own cookie gate (credentials.usable_cookie) when a job inherits a failed
asset.
UI: the Facebook copy no longer points at a Retry button the failure window does
not have, says "paste" like Settings, and the HU wording is fixed
("Újraletöltés" on the re-download confirm). A category this client has no copy
for falls back to the generic sentence instead of a raw key.
Tests: route serialization of error_detail, the probed duration through a real
_download, the page-read cap and a failed read, a Retry off a pending asset, the
confirm-button labels, the lock and savepoint races.
The download center's confirm button repeated the dialog's question
("Re-download this file?"). It now reads the action: Stop download,
Re-download, Remove, Remove from my list.
Given a group post's link, yt-dlp either found no formats or took another
video from the page's feed data, so a "successful" retry of such a post was a
wrong download. With the user's cookie the Facebook adapter now reads the post
page, takes the single video node in the script blocks that carry the post id,
and downloads that video's watch link (the Chrome retry does the rest). No
single video in the post fails with its own message and the group-post copy,
reworded to say the post has no video; the post link is never handed to yt-dlp
as a fallback. Without a cookie nothing changes.
A Facebook or Instagram reel extracts with no duration, so the asset kept a
NULL duration_s although the file plays for minutes, and the Library row had
no trim action. The finalize probe now returns the duration with the codecs
(the file wins, info fills a gap), and runs before the sidecars so the .nfo
runtime gets it too.
Nothing builds from it: both compose files build the root Dockerfile, and the
test image has its own Dockerfile.test. It installed the unpinned
requirements.txt, contradicting the lock header that every image installs
requirements.lock.
Measured on dev: with the user's cookie some reels still answer "Cannot parse
data" and only extract when the request impersonates Chrome. Impersonation is
a second attempt behind that one error, only when a cookie is attached — links
that already download with the cookie keep their plain path, and without a
cookie the same error is the login wall.
curl_cffi is pinned inside yt-dlp's supported range (0.10-0.15; outside it
every target silently reports "unavailable"); the lock only gains the new
packages. A test fails the gate if the locked pair can no longer impersonate.
The login-wall copy tells the user to add their cookies and press Retry, but
Retry kept the job on the shared asset the worker never attaches a cookie to,
so it failed on the same wall. Resume now moves a pending/errored job onto the
asset enqueue would pick with the cookie (never off a file or a download in
flight).
Re-download salted its fresh asset with the job's previous asset id, which the
cookie gate cannot recompute, so even a cookie download rebuilt without the
cookie. The rebuild salt is now the new asset's own id (plus the user's cookie
salt when one applies), and is_authed_asset recognises it.
A failed Facebook download read "Download failed": yt-dlp words the login wall
(18+ reel, closed group) as "Cannot parse data", which no category matched.
Measured on dev with the owner's cookie before writing a rule — the same text is
the login wall without a cookie and a refused cookie with one.
- errors.diagnose(msg, url, cookie_attached) tries a host-scoped catalog first
(all-of markers, cookie state, link fragment) and falls back to the category.
First entries: facebook.login_wall, facebook.cookie_rejected,
facebook.group_post_video. The module doc is the recipe for the next entry.
- download_jobs.error_detail (0084) + backfill of the stored Facebook parse
failures that were unknown.
- The pipeline's failure branch passes the link and whether the cookie went
with it; resume and redownload clear code and detail (redownload never
cleared error_code).
- A job meeting an already-failed asset got the text but no code, so it read
"Download failed" where the first job said why. It is diagnosed again, with
the cookie state from credentials.is_authed_asset (now shared with the
cookie gate); the redacted network text classifies back to network.
- FE: failCopyKeys gives the row and the failure window one lookup — catalog
copy, else the category. Copy in EN and HU; a contract test pins every
backend entry to both locales.
Back, dialogs and menus keep your place, and the keyboard walks every list: five sprints plus three epic-end review rounds (19 → 14, 18 → 12, 14 → 14). Minor: j/k list navigation, s and g-chords, and Back-to-item are features.
Targeted round over 3cd0c525's fix surfaces: 14 raised, 14 fixed.
- VideoEditor: the "only X of Y clips" toast went silent exactly on the job-cap/quota refusal it was
written for (req had already reported the error); it now always shows. The cut list holds still while
Create queues, and a retry continues the "(i/N)" series.
- ChannelPage: Subscribe/Unsubscribe use Button `loading`, "Load more" and ChannelDiscovery's Subscribe
block the click with aria-disabled instead of native disabled; busy Block/Hide no longer light up on
hover.
- listNav: an item can name itself (`navItemAttrs(i, name)`) — Plex posters, seasons, episodes, message
rows and download rows pass their title; focus on a toast's Undo keeps the removed card's slot.
- AnchoredPopover: the hand-back only for focus-managed surfaces (a hover card no longer pulls focus
back); the dead panel-ref check is gone.
- AccountPanel: no confirmation mail offered to the demo account; send_failed copy no longer blames
the message for an unreachable server. Pager comment points at scrollParent.
Whole-epic deep-review of E11 (18 raised, 12 kept; B and G refuted, four latent test-hardening notes dropped),
plus the admin refusal copy gap round 1 surfaced.
- A context menu opened from a card the keyboard is on gives the focus back to the card. floating-ui returns
focus through the opener's first tabbable descendant even when handed a ref, so for a non-tabbable opener the
surface restores it itself (measured in Chrome: j, Shift+F10, Escape left it on the thumbnail link).
- Back finds a named list (a rail) by its name: a rail that appeared above it after the cache expired sent the
focus into the wrong rail. An unnamed list is matched only when the page has the same number of lists.
- The slot kept for a removed item is forgotten once focus moves on to page content (search, a chip), so j after
new results starts at the first visible item; a menu or dialog opened from the item keeps it.
- The list keys reach the cheat sheet only while a list has an item.
- A j-focused card root is a named group while it holds the focus.
- The channel page's Block and Hide toggles keep the focus while their request runs.
- A split export that fails part-way says how many clips were added and deselects them, so a retry adds only
the rest.
- Config save, the one-click share link and "log out other sessions" report failures with their reason.
- VirtualGrid uses the one scroll-parent walk listNav measures against.
- Admin refusals (activate, resend, invite, set-password link) say why in HU and EN instead of "try again";
the email-off special cases collapse into that one reason. The contract reads the codes from localizeDetail.
HU copy points to Konfiguráció → E-mail / SMTP; HU "unreachable" matches the EN change of round 1.
Deep-review of E11 (19 raised, 14 confirmed; 2 refuted, 3 latent test-hardening notes dropped).
- Back from a sub-view (a Plex show page, a Messages thread) restores the item it was opened from
even when focus was still inside the view: the first attempt now waits one task, past the
sub-view's own popstate listener (measured in Chrome: it left focus on <body>).
- The restore listener is installed at load, so the first Back after a reload finds it; a push
cancels a restore still waiting for the last landing's lists.
- Navigating over an overlay asks it again, a render apart, while its entry stays: the Plex
player peels one panel per Back step, so Shift+W over info + stats closed one panel and dropped
the navigation.
- j/k: a native <button> item keeps its own Tab stop; landing twice on the same item no longer
leaks its tabindex; after the focused item is removed (a hidden card) the next j goes on from
its slot when that slot is on screen.
- An abandoned `g` no longer swallows a modifier chord (g, then Mod+K opened the browser search).
- A hover card above a dialog no longer switches its Tab trap off (player, Modal, wizard,
Lightbox): new layerStack `anyBlockingLayerAbove`.
- "Manage views…" closes its menu before opening the dialog, through a shared `closeThenAct`
that ActionMenu now uses too.
- Copy: the EN welcome says "Feed"; the EN unreachable reason no longer claims a change;
stale comments about PlayerModal's scroll lock and dialog contract.
Owner answers to the E11 S5 raises (2026-09-13): 1 → A, 2 → "Feed" in every language, 3 → translate the
proxy label and fix every config key missing in BOTH languages ("maintenance", "tmdb" — old bugs).
Focus across a view change (1-A). Opening a Plex title from Home's rail, a channel from the Channels
table or a title from a Plex grid switches the page or the sub-view, which unmounts the list; Back used
to hand focus to <body>, so the next `j` started over at the top. The YouTube player (an overlay)
already restored onto the card.
- `lib/listNav`: `navPosition()` (list's place in reading order, the page's list count, item index)
and `restoreNavPosition()`: focuses the item once the page has as many lists on screen as when it was
left, polls on a timer (a hidden tab runs no animation frames), gives up after 2 s or at once when
something already holds focus, focuses without scrolling (the scroll restore owns the position).
- `lib/history`: `rememberNavPosition()` stamps `sfNav` onto the entry being left and returns the state
without it (a new entry must not inherit it); one popstate listener restores and spends the stamp,
so a later landing on the same entry (an overlay closing over it) cannot pull focus to an old item.
- Called before every view push: `setPage`, `openChannel`, `enterYtSearch`, `useHistorySubview.open`,
the Channels tab push. `navPosition.wiring.contract.test` fails on any `history.pushState(` without
it (the overlay stack's own push is the one exemption).
"Feed" (2). The module is "Feed" in both languages: every "hírfolyam" in the Hungarian UI (≈45 strings,
suffixed: Feedből, Feeded, Feedben…) and the prose "feed" in English that means the module ("your
Feed", "Hide from Feed"); "RSS feed" keeps its meaning. The welcome email says it too.
Backend ids without a name (3). The Configuration page showed `maintenance`, `tmdb`,
`maintenance_revalidate_batch`, `download_upload_max_bytes`, `audit_retention_days`, `plex_overlay_map`,
`tmdb_api_key` and `tmdb_language` as raw identifiers — the lookup falls back to the id, and the i18n
parity test stayed green because both languages missed the same keys. Added labels + hints (EN/HU),
the two groups (and their tab order), the missing `scheduler.jobDesc.demo_reset`, removed the stale
`plex_sync_interval_min` field, and "YouTube API kimenő proxy". `i18n/backendIds.contract.test` reads
`sysconfig.SPECS`, `scheduler.JOB_INTERVALS` and the audit/quota action enums from the backend sources
and fails on an id without its name in either language, or a name left for an id the backend dropped.
E11 S5 — the HU/EN sweep and the keyboard-only pass over every module.
- The keyboard cheat sheet printed "Space" and "Menu" on a Hungarian sheet: a keycap that carries a
WORD (not a legend every layout prints alike, like Esc, Ctrl or Enter) is now named by the caller in
the reader's language (`formatChord(chord, words)`, `keys.cap.*`: Szóköz / Menü).
- The Plex player's ⌫/Esc line said "Stop & back to feed", but the player goes back to wherever it
was opened from (Home, a Plex page): "Stop & go back" / "Leállítás és vissza".
- Messages: the conversation list is a keyboard list (NavGrid + navItemAttrs, the name button is the
open element; the pop-out button keeps its own Enter). D1 covers every list whose rows open one
thing; this one was missed in S4. Notification cards have no single open action and stay out, like
the download queue.
Sweep, measured: every static `t("…")` key used in the source exists in both languages (the only
misses are template prefixes); the HU values equal to EN are proper nouns and loanwords (Admin, Plex,
Trakt, Shorts). Palette in Hungarian: "téma" makes "Téma: Midnight" the active row ahead of the channel
"Topik - mindig témában", live region "7 találat".
Keyboard-only pass in Chrome, Hungarian UI — 1024×768: every `g` chord lands on its module, `j` rings
the first item (solid 2px accent) on Home, Feed, Channels, Playlists, Plex and Downloads, `s` focuses
the search on Feed, Channels, Playlists and Plex, no page scrolls sideways. 3440×1440: `j` → → ↓
walks Home, Feed (8 columns: 0 → 1 → 9), Channels, Playlists, Plex and Downloads, no sideways scroll.
The help sheet lists every line translated; inside the Plex player "Szóköz / K" and "⌫ / Esc
Leállítás és vissza". Messages: a real `j` rings the row, Enter opens the thread.
Third and last slice of the E11 keyboard layer (S4c). S4a/S4b covered every card grid and rail; the
table-shaped pages — the channel manager, a playlist, the download center — were still Tab-stop
country: a channel row carries a priority stepper, four action buttons and a tag picker before the next
row's name.
- `ui/DataTable keyboardNav`: the rows are the items, Enter clicks the element a column marks
`data-nav-open`. DataTable renders its rows twice (a table, and a card list below `md`); both register
and `lib/listNav` now walks only lists on screen (`checkVisibility`), so `j` past the last row never
carries on into the hidden copy.
- `lib/listNav` leaves a key something before it already handled (`defaultPrevented`): during a keyboard
drag a playlist row's grip moves the row with the arrows, and dnd-kit takes the key on the document
before listNav's window listener. Without the guard ↓ moved the focus off the grip and dropped the drag.
- `ui/NavGrid as="tbody"` for a table whose body is the list (the playlist, under dnd-kit's
SortableContext).
- Wired: Subscriptions and Discovery tables, the card layout (`ChannelLayoutGrid` → VirtualGrid
`keyboardNav`), Hidden/Blocked cards (the ChannelLink name is a channel's open element); a playlist's
rows (the title plays; the grip keeps Space/Enter); the download queue, library and shared lists (the
play button opens, `IconBtn open`; a row without one opens nothing).
- `listNav.wiring.contract.test`: `VirtualGrid` and `DataTable` must say `keyboardNav` like `Carousel`,
`DownloadRow` is a card; the audit log's table is the one exemption (its rows open nothing), and an
exemption that no longer matches a tag fails the test.
Measured in Chrome: Channels table j → row, ring round the whole row; a row walked down keeps 12px above
the viewport edge, walked up clears the sticky header by 35px; Enter opens the channel. Cards 3 columns:
→ 0→1, ↓ 1→4. Hidden and Discovery: j + Enter open the channel. Playlist: j + Enter plays, Escape returns
focus to the row; a keyboard drag (Enter on the grip, ↓) moves the row with focus kept on the grip, and
Escape cancels with the order unchanged. Downloads library: Enter plays a video row, does nothing on a
plain file row.
Owner report: on the Plex browse grid, Tab onto the first card ringed only the poster, and the first
arrow key then moved the ring round the whole card. The poster is the card's own role=button Tab stop,
so Tab gave it the app-wide button ring, while listNav's arrows focus the card root (D2: the ring goes
round the whole item).
The card's "open" element (`data-nav-open` — the Plex poster, a video card's title link) now stands
for the card: when it holds keyboard focus the card root wears the ring and the element's own ring is
transparent. Controls inside the card (watched toggle, actions) keep their own ring.
Measured in Chrome (jsdom evaluates neither :focus-visible nor :has): Plex browse, Tab from the sort
control → poster focused, card outline 2px accent, poster outline transparent; → moves to card 1 with
the same ring. Feed, Tab onto a card's title link → the card is ringed, the link is not.
Second slice of the E11 keyboard layer (S4b). S4a gave the feed, the library and the channel page
`j`/`k`, the arrows and Enter; Home's shelves and the whole Plex module were still Tab-stop country —
a Plex poster is its own Tab stop, and a rail's cards sat behind every control of the cards before.
- `lib/listNav`: a list that mounts every item no longer declares a count or columns — both are
measured off the DOM when a key is pressed, so an auto-fill grid's columns follow its width and a
rail is one row. ↑ on a list's top row and ↓ on its bottom row go on to the neighbouring list, onto
the item horizontally nearest the one left (owner decision D4: ←→ within a rail, ↑↓ to the rail
above or below). The first `j` skips cards a rail has scrolled out to the left.
- `Carousel keyboardNav` registers the scroller; the scroller gets a 4px inset (against a -4px margin,
layout unchanged) so the focus ring is not clipped, and `[data-nav-index]` a 2.5rem inline scroll
margin so a card stepped into view clears the rail's edge fade and paging arrow.
- `ui/NavGrid`: the plain, fully mounted grid that registers itself.
- `navIndex` on PlexPosterCard / SeasonCard / EpisodeCard (the card root is the item, the poster the
open element — the ring goes round poster and title, D2), PosterTile and Home's ShelfTile (the button
is the item); cast, related and collection-strip buttons carry the attrs directly.
- Wired: Home's comfort and resume rails; Plex Browse titles + episodes, Discover rails + genres,
collection view, playlists/collections browse, person known-for + filmography, show seasons, season
episodes, cast/related strips, info-page cast + collection strips.
- `listNav.wiring.contract.test`: every `Carousel` says `keyboardNav`, every card says `navIndex`, and a
card with `navIndex` sits inside a NavGrid / Carousel / VirtualGrid — both halves are opt-in, and
either one missing leaves a list the keyboard silently skips.
The palette got PageUp/PageDown, and the same crawl stayed everywhere else a list opens under a
control: the sidebar's channel filter, the Plex genre/studio/collection pickers, the filter and sort
menus, submenus of every playlist you own. floating-ui's list navigation has only the arrows and
Home/End.
- `lib/listPaging`: the one definition of a page (the rows wholly in view minus one, a fixed 10 with
no layout) and of where it lands (stops at both ends, never on a row it may not take, looking back
toward the start rather than past it). The palette now uses it instead of its own copy, and
`listNav` takes its scroll-parent helper from here.
- `AnchoredPopover`: one keydown handler on the reference AND the surface, so a combobox (keys on its
input, `aria-activedescendant`) and a roving menu (keys on the focused row) both page. It runs after
floating-ui's own, which has by then switched to keyboard modality and scrolls the new row in. A
disabled row is never the target; a submenu's key does not also page the menu around it; with no
mounted rows (closed, a hover card, a dialog) the key keeps its native meaning.
Measured in the browser: the channel filter shows 8 rows and pages by 7 with the active row in view
after the scroll; a menu pages to its last row and back, the page behind it does not scroll; the
palette still pages by 11 of its 12 visible rows.
The jump-to palette lists every module, channel, playlist, view and action — 461 rows on the
owner's account, ~11 of them in view — and the arrows were the only way through it: forty presses to
cross the channel group.
- PageDown/PageUp step by the rows that fit in the list right now, minus one, so the row you paged
from stays in sight at the new page's edge. Measured per press (the list is 50vh and group headings
eat into it); a fixed 10 when there is no layout to measure.
- Unlike the arrows they stop at the ends instead of wrapping: a page-sized jump that lands back at
the top reads as having lost your place.
- Home/End stay the text field's own.
Measured in the browser: 0 → 10 → 20 rows, stops on row 461, PageUp from the end steps 9 (a group
heading is in view), the selected row stays visible throughout.
A feed card cost ~10 Tab stops, so the tenth card sat ~135 Tabs from the top of the page, and the
only keyboard focus ring (a 60% accent outline on the thumbnail) all but vanished on the dark glass.
First slice of the E11 keyboard layer (S4a); Home, Plex and the tables follow in S4b/S4c.
- `lib/listNav`: one window listener over every registered list, walked in document order. `j`/`k`
step item to item (on into the next list — owner decision D4), the arrows move around a grid, Enter
clicks the item's own `data-nav-open` element, so it opens exactly like a click. An item root is
never a Tab stop: it gets `tabindex=-1` only while the keyboard is on it, and keeps it until the next
item takes it, so the player's focus restore lands back on the card. The first `j` takes the first
card you can see, not the top of the list.
- `VirtualGrid` takes part with `keyboardNav` (column count + scrolling an unmounted row in);
`VirtualFeed` turns it on, so the feed, the library and the channel page all have it.
- Keymap: two-step sequences (`g h`) whose pending step belongs to the scope that started it; the
page-level scopes (`page`, `search`, `list`) are quiet while a layer that owns the keyboard is open,
enforced in `dispatch`. A test pins the owner's rule that none of them needs a modifier on the
Hungarian layout.
- `g h/f/c/p/x/n/m/d/s/,` jump to a module this account can reach; `s` focuses the header search box
and selects the old query; Escape in it hands the keyboard back to the page (Plex's typeahead spends
its first Escape on its panel).
- Hover popovers are passive layers: an open tooltip still takes Escape first but no longer silences
the page keys.
- Focus ring (owner decision D2 = B): solid 2px accent around the whole item; the app-wide button/
link/row ring goes from 60% to solid too. The item keeps a transparent resting outline because the
video card transitions `all` — without it every `j` flashed a 3px white ring (measured in Chrome).
- Help sheet: the new keys, in a fixed order after the surface's own; a `g` chord only for a reachable
module; opened over a player or dialog it leaves out the page-level keys and the context-menu key,
which nothing can reach there (the latter pre-existing since E10).
- CLAUDE.md: a canonical-patterns row for list/grid keys (`useListNav` + `navItemAttrs` +
`data-nav-open`, not a private arrow-key handler).
Typing "theme" put the channel "The Meltzone Podcast" (matched only through its handle
`@themeltzone`) above every "Theme: …" row, and Enter opened the channel — the palette listed
matches in pure group order and always highlighted the first one.
- CommandPalette ranks each word: label starts with it > elsewhere in the label > only the hint,
keywords or group name; an item ranks by its worst word, then the sum. The group order stays
fixed, rows are ranked inside each group, and the best row overall starts highlighted. The
highlight is held by id and follows the best match until the arrows or the pointer pick a row,
so a channel list landing mid-typing no longer shifts it.
- A11y: result groups are `role="group"` named by their heading; `aria-expanded` is false with
nothing to choose; the listbox stays mounted (hidden) so `aria-controls` never names a missing
element, and "Nothing matches" sits outside it; a polite status announces the count once the
typing pauses.
- `Column`/`FilterMap`/`filterRows`/`isActive` move from `ui/tableHeader` to `lib/tableColumns`,
and the player's auto/loop modes from `playerModalControls` to `lib/playerModes` — three lib
helpers imported the column contract from a ui component, and `playerModalHooks` its types from a
feature component.
- eslint: `lib/` may not import from `components/` at any depth, type-only and re-exports included;
the boundary contract test proves it fires. CLAUDE.md and the ui README say so.
- keymap: `dispatch`'s docblock sat stacked on `matchesShortcut`'s; moved onto `dispatch`.
`req` opens its blocking dialog for 400/409/422/5xx only. A 403/404/429 was left to the caller and a
502-504 or lost connection got just the self-clearing connection notice, so 34 mutations without a
reporting onError and 15 direct api writes with a bare or missing catch failed in silence: a hidden
channel that snapped back, a share link that never revoked, a confirmed account delete that ended
with nothing said.
- lib/queryClient: one MutationCache reporter. react-query awaits it BEFORE the mutation's own
onError, so it waits a task and skips a failure the site already reported; the toast title comes
from `meta.failTitle`, `meta.errorShownInline` opts out.
- notifyActionError: title names WHAT failed, the line under it says WHY (`failureReason`: a
structured localized detail, else the status). It marks the error reported (`markReported`), so
nothing reports one failure twice; `notifyKnownFailure` for a site's own specific sentence.
- Every silent site named (45 new HU/EN titles, 4 reasons). Stale "the global dialog shows it"
comments corrected on the job actions, channel block, Plex sync, cookies, download enqueue,
account delete and usePref.
- Channels passed the tag hook's channel id to the reporter as the error; the tag and priority hooks
now report themselves, with the real error.
- A contract test keeps every useMutation naming its failure; CLAUDE.md gains the pattern row.
- history.dom.test: the "navigates only once the entries are back" assertion raced an async act's
macrotask flush (1 in 5 runs); it now asserts from a later popstate listener.
Measured in Chrome before each change:
- A dialog opened from a menu lost its focus: the menu row ran its action
and closed in one batch, and floating-ui's focus return (a microtask)
moved the focus to the row BEHIND the confirm dialog's scrim 7 ms after
it opened, where Enter activated the row. MenuItemList now closes first
and runs the action a task later; the dialog also captures the row, not
a dying menu item, as its restore target.
- A side panel opened as a flyout collapsed the moment a dialog opened
from inside it, unmounting the row the dialog hands the focus back to.
useHoverFocusWithin holds while the focus is inside an aria-modal dialog.
- ui/Button's loading state set native disabled; Chrome moved the focus of
the pressed button to <body> and never gave it back. Loading is now
aria-disabled with a click guard that also cancels implicit submission.
- Escape/Enter in the playlist rail's rename field dropped the focus on
<body>. The row refocuses from its ref callback when it re-attaches (a
post-commit focus sticks; the earlier attempt ran before the button
existed).
- PlayerModal was the one aria-modal shell without the dialog contract:
it is now named by its title, traps Tab, restores the focus to its
opener and shares one counted body scroll lock with every dialog.
The overlay history stack let a navigation land ABOVE open overlays and
"buried" their entries with a counter. Measured in Chrome: Shift+W over
About navigated under the still-open dialog, the next Back both undid the
navigation and closed About, and the Back after that did nothing; Shift+W
on Home with the player open did nothing at all. The counter could also be
spent by a different overlay closing first, and callers that could not
report whether they pushed were assumed to have.
setPage and openChannel now close every open overlay and push only once
the history is back in sync (one entry per live overlay, no programmatic
back() in flight, no reconcile queued), so nothing is ever buried and the
whole mechanism goes: navigateOverOverlays, closeThenNavigate, buryUnder,
setPage's boolean, and the three test files that pinned them. The palette
closes only itself before running a row, so a theme picked over the player
keeps the player. Shift+W on Home with the player open now closes it
(owner decision 2026-09-09).
The wait is on state, not on "a popstate": the old 250 ms fallback could
run with back()s still in flight. An overlay that refuses to close within
1.5 s drops the command instead of being navigated out from under.
Sixteen items written from real use: eleven fixed, one measured and closed without a code change
(the micro-stutter is a 23.976 fps film on a 120.0148 Hz display, reproduced identically outside
Siftlode), two parked for a reproducible case, one scoped out to its own sprint. Two of the eleven
were destroying user state — a YouTube video that rewound itself on reopen, and a "clear resume
point" that told Plex nothing about an in-progress item.
Then seven deep-review rounds over the fixes and over each other's fixes: 32 → 24 → 43 → 31 → 22 →
26 → 18 raised. Minor, not patch: item 14 added an admin knob, item 10 a sort control, and items
2/4/5/7 are features.
Round 6 converted three of four `is_demo` 403s to a structured detail and wrote a docstring saying it
had converted all the sites there are. The account-delete route in `routes/me.py` still answered a
hand-written English sentence, which `notifyActionError` discards in favour of the caller's
translated fallback — so the demo user gets no hint that the demo account is the reason. It is
UI-gated today, so the string is unreachable; what was reachable was the false completeness claim,
which is precisely what stops the next reader from looking. The site is converted, and the list is
now DERIVED from the source: a new test walks `app/` for every `is_demo`-guarded 403, asserts each
raises a structured detail, and fails when the count moves — so this file cannot fall behind the code
the way it just did.
The same round's frontend contract guard was failing open. Its pattern required a bare identifier
before `.detail` and excluded `.detail` used as a last call argument, so `setErr(e.detail)`,
`(e as HttpError).detail`, `String(e.detail)` and four more idiomatic forms were invisible — and
because the file set is compared with `toEqual`, a file the pattern cannot see is simply never listed
and the gate stays green over a fresh copy of the contract. Widened to cover cast receivers, optional
chains and call-argument reads; the pinned set is unchanged, so the narrowing had removed no false
positive. A destructured `detail` stays out of reach (a pattern loose enough for it fires on
react-query's own `const { data: detail }`), and that hole is written into the test rather than
papered over with two innocent files.
Its anti-vacuity guard also did not guard anything: the comment says the glob must have matched
something, but the assertion under it read a sibling file by its own URL, which resolves whether or
not the glob enumerated anything. That only mattered while the pinned list was non-empty — the moment
the deferral sweep lands and it becomes [], the whole file would pass over a glob matching zero
files. The scan size is asserted now.
`commit`'s success handler writes the dragged list into the detail cache — round 6's fix, so a
re-entry inside the 30s staleTime renders the accepted order without a request — but then invalidated
only the LIST key, while the YouTube sibling it cites invalidates both. `next` is the membership of
the render that started the drag, and `reorder_playlist` only touches the rating_keys it is handed,
so it never ratifies that list: anything that landed meanwhile was clobbered by the write with
nothing scheduled to correct it. `commit` sets no busy flag either, so a removal really can complete
while a reorder is in flight, and with staleTime 30s and refetchOnWindowFocus off the phantom row
stayed on screen for as long as the view was mounted — and the next drag persisted an order built
from it.
Round 6's test had to be re-pointed, because it was written in a world with no refetch: its mock
answered the pre-reorder order forever, which a server that just accepted the write cannot do. It now
holds the refetch open instead, so it still pins what round 6 actually fixed — the cache is right
WITHOUT waiting for a request — and would fail if the write were deleted. Both halves are
mutation-checked against each other.
Round 6 widened dialog focus ownership to count focus inside ANY `aria-modal` subtree as ours, so a
dialog and the dialog stacked on it closing together would still hand the focus back. That clause has
no way to tell a layer closing WITH us from one that stays open, so a dialog closing on its own — an
in-flight mutation's `onSuccess`, not a click, since a scrim covers it — under a modal raised above it
pulled the focus onto its own pre-open trigger, behind that scrim, where Enter still activated it.
Three verifiers confirmed the mechanism and two corrected the severity down: it self-heals on the
next Tab, which is why this is a containment violation rather than a broken end state.
`lib/layerStack` cannot refine it. Measured against the real hook: at the instant the lower dialog's
focus cleanup runs, the both-closing case and the sibling-stays-open case give identical readings —
`anyLayerAbove` true, the upper layer topmost — because passive cleanups run parent-first and
`useDialogChrome` is declared before the effect that calls `removeLayer`. Gating on the stack turns
round 6's own test red. What separates them is the DOM: React detaches a deleted subtree before
passive cleanups, so a layer closing with us is already disconnected while one that stays open is
not. Both tests are green, and each was mutation-checked against the other.
Verified in Chrome on localdev: opening the cheat sheet from a sidebar button and closing it restores
that button; with the command palette stacked over the cheat sheet, closing the top one leaves the
focus neither on the background trigger nor on <body>, and closing both returns it to the trigger.
Welcome's Lightbox got the trap and the restore in round 6 but not the layer membership the other
shells have, so its Escape and Tab fired from a raw window listener whatever was on top — the shape
the wizard's comment records being bitten by, unreachable today only by a mount ordering nothing
enforces. It joins the stack. Its comment also read as though the four-shell sweep were finished:
`PlayerModal` is still an `aria-modal` surface with no name, no trap and no restore, which is on
ROADMAP.md now rather than implied to be done.
Two ways round 6's own fixes did not bound what their comments said they bounded.
The reclaim budget's window anchor was written only where the window RESET, so it marked the window's
start rather than the last loss — a tumbling window, not the rolling one the constant is named for.
Any loss cadence slower than half the window satisfied the reset on every second loss, so the budget
never reached its bound: measured over 200 loss cycles, 61/70/90/119s apart all gave 200 reclaims and
never an error, while 5s and 30s apart correctly gave two and then the honest answer. The failing
band is exactly the case the ERROR handler names — a transcode that falls behind realtime — and both
committed tests spaced their losses 0s and 10min apart, i.e. outside it. The anchor now moves with
every loss.
And `finish`'s refusal handler lowered the play-through latch, which is the only gate on the 10s
interval, the page-hide beacon and `settle`. All three post `absRef`, which on the play-to-the-end
path IS the end of the media, and the backend reads a position within `_FINISH_MARGIN_S` of the
duration as a finish — so within ten seconds of the toast saying the mark had failed, the item was
marked watched, its position cleared and a scrobble sent to Plex. Round 6 closed the explicit
rollback write and left the other three open; the test it added set `upNext: false`, which tears the
effect down before any of them can fire. The refusal now takes back the CLAIM rather than the latch:
a second flag stops `settle` re-asserting watched, and playing again clears both.
`FINISH_MARGIN_S` duplicated the backend's constant in a second language with nothing relating them,
and its comment had the asymmetry backwards — if the BACKEND's margin grows, this guard starts
admitting a rollback the backend reads as a finish, i.e. re-opens the defect above. Pinned by a
contract test that reads the Python, and the comment corrected.
Round 6 turned `artifact_may_arrive` into a positive rule, but keyed it to the HLS vocabulary in
general rather than to what THIS session produces: the playlist regex accepted master/index/stream_*
for every session and the `seq == 0` branch accepted any variant token unconditionally. The two argv
branches are disjoint — a single-audio session is given `seg_%d.ts` + `index.m3u8` and no `%v` at
all, so `master.m3u8`, `stream_*.m3u8` and every `seg_<variant>_<n>.ts` are names it can never write,
and vice versa — so each of those was read as "an HLS name, so wait and see" and held a threadpool
worker, plus its pooled DB connection, for the full timeout. That is verbatim the round-5 outage the
route's own comment claims it prevents.
Measured through the real route against a live single-audio session, before: master.m3u8 404 after
30.0s, stream_q7z.m3u8 404 after 30.0s, seg_a0_0.ts / seg_zzzz_0.ts / seg_0_0.ts 404 after 20.1s
each. After, on the running localdev API: 404 in 44-316ms for all of them, while index.m3u8,
seg_0.ts and seg_1.ts still answer 200 — nothing is over-refused.
The session now carries the rendition names its ffmpeg was told to write, and `entry` is derived from
them instead of passed. And "one place" now means the argv BUILDS its patterns from the same
functions the parser reads back (`segment_name("%d", "%v")`, `variant_playlist_name("%v")`): the
layout was still spelled out independently in six places, with `_VIDEO_VARIANT` read by no consumer,
which is the drift that caused round 6's HIGH. A test asserts the argv's own patterns are what those
functions produce, and that every name they expand to is one the guard accepts.
The load-bearing MEASURED comment cited ffmpeg 8.1.1 — the Windows host's. The binary that writes
these files is the API container's 7.1.5; re-measured there, the conclusion holds.
The YouTube list got this rule in round 5 — one accepted write, one cache entry
that matches it — and the Plex list got only the rollback half of that fix. Its
success path invalidated `qk.plexPlaylists()`, a disjoint key root, and never
touched `qk.plexPlaylist(id)`: the key `putBack` rolls back to, and the one a
re-entry renders from without refetching.
So after an accepted drag the detail cache still held the pre-reorder order.
Leaving the playlist and coming back inside the 30 s staleTime showed the
reorder undone, and the next drag from there persisted that — silently undoing
an order the server had taken. Measured in jsdom: server 2,1,3, cache 1,2,3, no
refetch.
1 test, mutation-checked.
Round 5 taught `notifyActionError` to discard a plain-string `detail` in favour
of the caller's translated fallback, because `req` leaves 403/404/429 to the
caller and those routes hand-write English. Three places did not get the
message.
**The demo account lost its explanation.** `require_human` refused with a plain
string, so from that commit on the shared demo account got its caller's generic
"couldn't do that" and no hint that the demo account was the reason — and the
gate is not backed by a UI gate everywhere: the comfort shelf has no `is_demo`
check, so the pin button is live and simply fails. A structured `{code: "demo"}`
and an `errors.demo` string in both locales. The two auth routes that hand-roll
the same check were swept with it.
**`ChatThread` carried a second copy of the contract.** It filtered 404/429
itself and then did `e.detail || t(…)`, the pre-fix order, so the central fix
could not reach it and "That member hasn't set up messaging yet." stayed English
under a Hungarian UI. It goes through the shared helper now, and a contract test
pins the remaining raw-`detail` consumers as an explicit SET — never a count —
so a new copy fails the gate instead of shipping. The five on that list are
inline surfaces (a form field's error line, a setup banner) and are deferred
with the ~60 backend routes whose details they print.
**The Plex "Add to playlist" dialog was still silent.** Same shape as the
collection editor round 5 fixed and the YouTube twin that already had the fix:
no catch, so a playlist deleted on another device answered a structured 404 that
nothing reported — the tick never flipped, the gone row stayed, and the floating
promise became an unhandled rejection.
Also: the `ABORTED` sentinel's VALUE was asserted nowhere. The consumer test
hand-built the error it expected, so replacing `xhr.onabort`'s rejection with a
friendlier message left the lane green — and every upload the user cancels would
then raise a red "the upload failed" toast, which is what the sentinel prevents.
Both producer tests assert through `isAborted` now.
And two backend tests were pinning fewer sites than their own docstrings claimed:
the channel-404 loop said "all five" and issued three requests (explore and
subscribe unexercised, the excuse for subscribe naming a different call site
with a different reason slug), and the recipient/job test named three refusals
and requested two — `share_download`'s `not_found("recipient")` had no test in
the suite at all, nor did the locale keys added beside it. Every site named now
has a request behind it.
6 tests, mutation-checked against 7 mutations: the demo detail at both the gate
and an auth route, a re-hand-rolled ChatThread, the silent Plex dialog, the
abort sentinel, and each unpinned 404 — all red on the test that names them.
`MAX_SESSION_RECLAIMS` was cleared by the first fragment of any session, and a
reclaimed session essentially always delivers one — `start_session` does not
return until its first segment exists. So the budget only ever bit where a
session parsed its manifest and then died before a single fragment, which is not
either case its own comment names. Measured: 40 loss cycles produced 41 sessions
and never an error. A transcode falling behind realtime, or two viewers of one
rating_key taking turns, respawned ffmpeg indefinitely — the round-5 behaviour,
still there behind the guard added to stop it.
It counts within a rolling window now. Two losses inside two minutes is a
session that keeps being lost; one an hour apart is not. And the window is reset
per item, because the player stays mounted across a queue advance — otherwise a
budget spent on one episode would deny the next its first legitimate reclaim.
Separately: `finish`'s rollback re-performed the write it was taking back. On
the play-to-the-end path the position it restores IS the end of the media, and
the backend reads a position within its finish margin as a finish — so the
branch named "take the finish back" set watched, cleared the position and
scrobbled to Plex through a second route, while the caller's error toast was
already on screen saying the mark had failed. There is no resume point at the
end of the media to restore; the cache drop is the whole of the rollback there.
The credits-skip caller captures a mid-episode position and still restores it,
which is the case that needs it.
7 tests, mutation-checked: the FRAG_LOADED reset, the missing window, and the
unguarded rollback each red on the test that names them.
Round 5 extracted `ui/dialogFocus` so no shell would hand-roll the focus
contract, then introduced four ways to lose the focus anyway — all four measured
in jsdom against the real components.
**Ownership was narrower than the trap.** `ui/Modal` traps Tab over the whole
dialog on purpose, so a toast's Undo or ✕ stays reachable; ownership was tracked
over the panel alone. Tabbing to that ✕ therefore read as "the user left the
dialog", and since removing a toast fires no `focusin` to re-arm, closing
afterwards skipped the restore and left the focus on <body>. Before this hook
existed the restore was unconditional, so it was a regression.
**A dialog stacked above us is still ours.** A nested `ui/Modal` portals into its
own host, so it is outside the outer dialog's root however wide that scope is
drawn — widening alone could not fix it. Both closing in one commit left the
focus on <body>: the outer skipped its restore, and the child's own target (a
control inside the outer panel) was already detached, because passive cleanups
run parent-first. Ownership now means "inside my dialog, or inside a modal layer
above it", which is what the rule always intended.
**The wizard disagreed with `ui/Modal` on its first line.** `trapTab(e, panel)`,
with neither the dialog-root scope (so its own toast host was outside the cycle
— the connection notice never auto-dismisses, and its ✕ was unreachable from the
keyboard) nor the `container` argument the JSDoc exists for: `panel.contains(
panel)` is true, so focus sitting ON the tabIndex=-1 panel matched no branch and
a Shift+Tab walked into the app behind the scrim. Both call sites now pass the
same three arguments.
**The toast handover was a render-phase read-modify-write.** A `useMemo` read a
module global that the next line overwrote; React re-invokes a component body
and every `useMemo` factory in it on a StrictMode double render, and the LAST
invocation commits — so the second pass re-read what the first had just written,
the takeover host classified its OWN new toast as inherited, and the dev build
behaved exactly like the code round 5 replaced. Snapshotted through a ref keyed
on `isTop`, so a repeated invocation of one render is a no-op.
And the fourth `aria-modal` shell, `Welcome`'s screenshot lightbox, is finally
swept into the same contract: it declared itself a modal dialog while leaving
the focus on the thumbnail behind an 80%-black scrim and letting Tab walk the
landing page underneath.
The headline fix of the previous commit — StrictMode's re-mount taking the focus
off an `autoFocus` field — had no test at all; deleting it left all 1009 green.
It has one now.
9 tests, mutation-checked against 8 mutations, each red on the test that names it.
Round 5 added a guard so a request for a segment beyond the produced region
answers instead of polling out the full timeout — the route is a SYNC one, so
every such wait parks a threadpool worker and, through `current_user`, a pooled
DB connection. Measured against real ffmpeg, the guard could not fire where it
mattered.
`-hls_segment_filename seg_%v_%d.ts` substitutes the VARIANT first: a dual-audio
session writes seg_0_0.ts (video) and seg_a0_0.ts / seg_a1_0.ts (the `name:aN`
renditions). The regex read those as sequence-first, so every video segment
parsed as segment 0 and every audio segment did not parse at all — the guard was
inert on every multi-audio item, which is most of this library. `_seg()` carried
the same inversion: the warm-up waited 8 s for `seg_1_0.ts`, a name ffmpeg never
writes, on every multi-audio session start AND every seek-restart.
The layout is now one constant plus one parser (`segment_name` /
`_SEG_NAME_RE`), with a test pinning them to each other, instead of three
comments that could drift — and they had drifted, all three ways.
The guard is also a positive rule now. It was a blocklist, so anything that was
not a single-audio segment name was waved through: `GET …/anything.m3u8` still
parked a worker for 30 s, while the route's comment claimed a flood of hopeless
names was what it prevented. A session produces its playlists and its segments
and nothing else. `.vtt` leaves `_HLS_CT` with it — ffmpeg runs with `-sn` and
subtitles are a separate route, so that extension could only ever name a file
that will not arrive.
Also here:
- A 5000-digit segment index reached the client as a 500: `int()` on an
unbounded digit run raises past 4300 digits and only OSError was caught. The
name is bounded on the route and the index in the regex — the same bug class
the subtitle routes were bounded for.
- A session torn down between the wait and the read is a 404, not a 500. `_kill`
rmtrees the directory and no lock spans those two steps; the player's recovery
is keyed on the STATUS, so a 500 skipped the reclaim and ended playback with
"couldn't play" instead of restarting at the playhead. The trigger is ordinary
— `loadSession` awaits the new session before destroying the old hls instance.
- Two comments claimed an open player checkpoints every 10 s "whether or not it
is playing", which is the stated reason the 30 s finished-idle reap is safe.
`flush` sends nothing once the finish latch is up, or before a position is
known. The states it does cover — playing or paused mid-item — are the ones
the rule needs; the comments now say that instead of more.
- The route's tests leave the DB lane: the route reads nothing from the database
since the auto-start was removed, and there those four regressions SKIP on a
machine with no Postgres while pytest prints green.
14 tests, mutation-checked: the old parse, the old warm-up name, the blocklist,
both bounds off the absurd name, the unguarded read, and the route dropping
`proc` — each red on the test that names it.
Both found by UAT in a real browser, after the tests for them were already green — which is the
whole reason this repo settles focus and timing claims by running them.
- **The autoFocus fix worked, and then the dialog took the focus back.** React 18's StrictMode runs
every effect mount→cleanup→mount in development: the cleanup handed focus back to the trigger, and
the second mount, finding the focus outside the dialog, moved it to the first focusable — off the
`autoFocus` field a beat after the browser had put it there. Chrome measured it: INPUT (commitMount)
→ BODY (cleanup) → ✕ (re-mount). The effect is idempotent now — it remembers what the dialog was
focused on and restores THAT on a re-mount — and the close-time restore is conditional on the
dialog still holding the focus, which it tracks as it changes rather than computing at cleanup,
where the panel is already out of the document. A dialog no longer yanks focus back from wherever
the user has since moved it.
- **Passing ffmpeg's handle to the segment wait only shortened the FINISHED case.** Measured against
the running localdev API: a request for `seg_99999.ts` on a live session still took 20 195 ms,
because that ffmpeg has not exited and the wait has no other reason to stop. ffmpeg writes segments
in order, so a segment whose predecessor does not exist is not the next one and cannot arrive —
and that is knowable at once. Same request now: 45 ms.
Verified on localdev against the real library (Westworld S01E06, remux, multi-audio): Mod+K opens the
command palette with the focus in its search field and typing reaches it; playback runs on the new
session-scoped HLS URL; a request naming a stale session id is a 404; and a viewer paused for longer
than the finished-session rule keeps their 2.4 GB session across two reaper passes — the progress
checkpoint touches it — and resumes cleanly.
vitest 1009, pytest 1127.
Deep-review round 5. The assertion behind `subtitles._exists` was only red where pathlib re-raises
ENAMETOOLONG: on Linux it does, on Windows the same stat fails with ERROR_INVALID_NAME, which
pathlib's `exists()` already swallows — so the assertion passed with the fix reverted. It forces the
errno now instead of relying on the filesystem. (The gate's pytest lane is the Linux container, so
the branch was never actually unguarded; the test was just dishonest about why it passed.)
Deep-review round 5, over round 4's own fixes.
- Round 4 made a failed reorder roll the rows back to the CACHED detail, because a resync cannot do
it (the server's list is unchanged, so the refetch is deep-equal, react-query keeps the same
`data` object and the effect keyed on it never runs). But nothing wrote an ACCEPTED order into
that cache — only the refetch that follows a successful reorder did. So a second reorder rejected
while that refetch was still in flight rolled the rows back past the first one, which the server
had accepted; the membership guard cannot correct an order (its key is the sorted id set), so the
wrong order stayed on screen for good and the next drag persisted it. `onApply` now writes the
accepted order into the cache before invalidating.
- `PlexPlaylistView.rename` had a `catch` no test could reach, and `commit` (reorder) was not gated
by `busy` the way its siblings are — a reorder in flight while a removal completes is what makes a
rollback ambiguous in the first place. The rename failure path is now covered; `commit` defers.
- `putBack` read `q.data` from the render that bound the failing handler instead of the cache at
rollback time, unlike the sibling `restoreFromCache`. Aligned. Stated plainly: I could NOT drive
that staleness in jsdom — a mutation that restores the old shape leaves every candidate test green
— so this one ships as an alignment with the proven sibling, not as a fix with a red test behind
it. The scenario and the harness limit are on ROADMAP.md.
Two regression tests, both mutation-checked red (the cache write, the rename catch). The
drag-reorder failure path still has no test: dnd-kit's pointer and keyboard sensors do not activate
under jsdom even with geometry and scrollIntoView stubbed, so a test there would have pinned the
harness, not the code. Logged, not faked.
vitest 1009.
Deep-review round 5, over round 4's own fixes.
- `notifyActionError` preferred the server's `detail` over the caller's translated fallback, and
`reportFailure` deliberately leaves 403/404/429 to the caller — so for exactly those statuses the
dozen call sites round 4 converted started printing whatever English a route hand-wrote into an
otherwise Hungarian UI ("No user with that email.", "Not subscribed to this channel", "Unknown
job"). A STRUCTURED detail is still preferred: `req` has already run it through `localizeDetail`,
so it is both localized and more specific. A plain string is not.
- The other half of the same defect, on the backend: round 4's `get_or_404(what="channel")` sweep
reached exactly ONE of the five identical sites in channels.py and shipped the HU/EN string with
it, so the Block button said it in Hungarian and the four controls beside it in English. All five
are converted, and the subscription / share-recipient / scheduler-job 404s with them (two new
reasons in both locales).
- `PlexCollectionEditor`'s add/remove, take-over and delete were try/finally with no catch — in the
very file round 4 edited to give `createNew` one. 403 and 404 are the caller's to report, so the
tick simply never appeared and a CONFIRMED "delete collection" did nothing, silently.
- `UploadDialog` is the only `uploadReq` caller and kept an empty catch whose comment still claimed
the global error modal had it. It had not since `reportFailure` became shared: a proxy 502 raised
only the self-clearing connection notice and a 413/403/404 nothing at all, so the dialog snapped
back to its pre-upload state with no error named. The user's own Cancel stays silent — and since
a cancel and a network drop both carry status 0, that difference is now a named `isAborted`
rather than a string literal at each end.
Regression tests for each (5 vitest, 2 pytest, plus two existing assertions corrected), all
mutation-checked: 6 mutations, each red on the assertion it is named after. vitest 1007,
pytest 1127.
Deep-review round 5, over round 4's own fixes.
- Sessions are keyed by rating_key alone while a session's DIRECTORY is per (key, offset, tag), and
the artifact URL carried no session identity — so once a seek-restart or a second viewer of the
same item took the registry slot, the old viewer's request for `seg_12.ts` was answered 200 with
the new session's twelfth segment: bytes from a different `-ss` offset under `-copyts`, on a
timeline the client is not on. Wrong content with a success status is the one failure a player
cannot notice, and round 4's reclaim-on-404 could never fire on it. The id is in the path now (so
the playlist's relative names resolve back into the same session), a mismatch is a 404, and the
player reclaims at the playhead. The entry playlist's auto-start is gone with it: it could only
ever begin at 0, silently discarding the viewer's position.
- Round 4 exempted paused viewers from the reaper by keeping EVERY finished session for the full
ten-minute idle window — one whole segmented copy of a film per item, on the volume the download
center shares. The signal it needed already existed: an open player checkpoints its position every
10 s whether or not it is playing, so that route now touches the session and the periodic pass is
back on the short rule.
- POST /session's opportunistic reap was never scoped to the key being started: it walked the whole
registry with the 30 s finished rule, so pressing play on ANY item — even a direct-play one, the
call ran before that early return — killed another viewer's paused session. It is gone; the
periodic pass owns reaping.
- A segment that can never arrive no longer polls out the full 20 s: the producer's handle is passed
to `wait_for`, so a finished ffmpeg answers at once. This is a sync route, so each such wait parked
a threadpool worker AND a pooled DB connection, and an ordinary seek past the produced region was
enough to do it.
- The DataError handler's re-raise branch — the half that keeps a genuine DataError a 500 — is
pinned, so simplifying it to an unconditional 422 cannot pass in silence.
Six regression tests (two route-level, in the DB lane), each mutation-checked red on the assertion
it is named after. pytest 1124, vitest 999.
Deep-review round 5, over round 4's own fixes.
- Round 4 added a `finish` latch so a played-through item records no checkpoint at the credits.
On a NATURAL play-through it was raised a beat too late: per the HTML spec the browser sets
`paused` and fires 'pause' BEFORE 'ended', so the pause checkpoint went out while the latch was
still down and raced the watched mark — both requests read "not watched yet" from their own
snapshot, so Plex counted one watch twice, and where Plex's reported duration runs more than the
finish margin past the real media the checkpoint wrote a resume position AFTER the scrobble. The
pause that ends the media no longer flushes. The regression test that was meant to cover this
modelled the end as a bare 'ended' event — a browser that does not exist — and is now the real
order.
- The watched mark was fired and forgotten (`.catch(() => {})`) while the latch patched the cached
detail to watched / position 0 regardless, and round 4's once-per-id start guard meant the
corrected detail arriving later was never used: an episode whose mark failed reopened at 0:00
with a Watched badge the server disagreed with. `finish` now takes the in-flight write and takes
itself back if the server refuses it — the real position is reported, the lying cache entry is
dropped, and the failure is said out loud instead of swallowed. Both callers (the natural end
and the credits auto-skip) hand it over.
- The reclaim of a 404'd HLS session had no bound. `parsed` cannot provide one — it belongs to one
hls instance and every restart builds a fresh one — and the server answers 404 both for "the
session is gone" and for "that segment is not there yet", so a transcode falling behind realtime,
or two viewers of one item taking turns, respawned ffmpeg forever behind a spinner that never
became an error. Two consecutive reclaims, reset by the first fragment that actually loads, then
the honest failure.
Five regression tests, each mutation-checked red on the assertion it is named after.
Deep-review round 5, over round 4's own fixes.
- Round 4 gave `ui/Modal` a persistent portal host so a fullscreen transition would stop tearing
the dialog down. The host was attached in a `useLayoutEffect` — one commit phase AFTER React
runs `autoFocus` in `commitMount`, and `focus()` on a disconnected node is a no-op. Every
autoFocus in every dialog was dead: Mod+K opened the command palette with the focus on the ✕,
so typing searched nothing and Enter closed it; the same for the download rename field and
every non-danger confirm. The host is attached in a `useInsertionEffect` now, a whole phase
earlier, and the whole re-parent — including the focus save/restore across the move, which
`appendChild` needs because it is remove-then-insert — lives in one `useFullscreenPortalHost`.
`ui/AnchoredPopover` had a second copy of that re-parent WITHOUT the focus restore, and a
comment claiming focus survived it; both now come from the hook.
- `ui/Toaster` classified a toast raised in the SAME commit as a host takeover among the ones it
inherits, because it read the store live at the takeover render. A dialog that notifies and
closes in one handler (UploadDialog) therefore put its toast up with no role, into a region
still `aria-live="off"` — announced by neither path. "Inherited" is now what the outgoing host
last had on screen, and a toast that is NOT inherited is announced even inside the unarmed
window.
- The onboarding wizard renders its own shell, and Escape ordering was all of the dialog contract
it had: no role, no label, no initial focus, no trap, no restore. A keyboard user tabbed out of
the first-login wizard into the app behind the scrim. The focus half of the contract is now one
shared `ui/dialogFocus` that both `ui/Modal` and the wizard use.
Every fix has a regression test, each mutation-checked (6 mutations, all red on the assertion
they are named after).
Deep-review round 4, the overlays.
- Toast hosts were ranked by mount time, and PlayerModal swaps its host on every fullscreen change:
leaving true fullscreen with the download dialog open put the player's host back on top, so toasts
were drawn under that dialog's scrim and a click on one closed the dialog. Each overlay takes a
rank once, when it opens (`useOverlayOrder`), and hands it to every host it renders.
- A dialog open while fullscreen is left keeps a stable portal container (moved, not re-created):
React tore the portal down when `fsRoot` changed, resetting a half-filled form and dropping focus
to <body>. Focus is put back after the move.
- The onboarding wizard is a layer, not a raw document listener: one Escape used to dismiss the
error dialog over it AND end onboarding, skipping the write-scope step.
- `navigateTo` (a toast's "View", clicked inside the player's own host) goes over the overlays it
unmounts, so the player's Back-to-close cleanup no longer pops the page it just opened — the same
fix Shift+W already had. The access-requests toast action too.
- The live region arms itself a task after it takes over inherited toasts: React flushes effects in
the same task as that render, so the "silent inheritance" arming was a no-op.
- The overlay-host guard matches class tokens in any order (PlexPlayer's root was never checked),
globs `ui/` too (ui/Modal was not checked either), and requires the rank.
Deep-review round 4, the playlist surfaces.
- A failed move or reorder relied on the resync to put the rows back — but the server's list is
unchanged, so the refetch comes back deep-equal, react-query keeps the same `data` object, and the
re-seed effect keyed on it never ran: the moved row stayed gone, the rejected order stayed on
screen. Both restore from the cached detail now.
- A failed removal puts its row back itself: when the server is unreachable the resync fails too,
and round 3's "keep the list on a failed refetch" guard then kept showing it as removed.
- The Plex playlist page (remove / rename / delete / reorder), the add-to-playlist popover and the
tag manager had no failure handling at all: a list or tag deleted elsewhere (a 404, now a
structured `not_found` the request layer leaves to the caller) failed in silence and an
optimistic removal stayed removed. Each says it and resyncs; deleting a Plex playlist that is
already gone just leaves the page.
- Playlists' own loading/error branches use the shared `LoadingState` / `StateMessage` (the loading
line had no live region).
- The "player stays mounted" tests count mounts: a remount ended with the same flag as staying up.
Deep-review round 4, the "one report per failure" contract round 3 introduced.
- `req` marked gateway (502/503/504) and network failures "already reported" because they raise
the connection notice. But that notice is about connectivity and removes itself on the very next
answer — which every caller's own resync refetch fetches at once — so a delete that hit a
keepalive reset flashed the notice and was then reported nowhere, the list still there. Only the
durable reports (the error dialog, the 401 sign-out) count now; the caller says the action failed.
- A connection notice the user closed swallowed every later one for the rest of the visit; it is
raised again.
- `req` and `uploadReq` share one failure policy (`reportFailure`). The upload copy had drifted: a
502 opened the blocking "Server error (502)" dialog and cleared a notice that was still true.
- A taken Plex collection name is a structured 409, localized, and the create/rename calls are
quiet: it was an English dialog plus a Hungarian toast. `notifyCollectionEditError` is gone.
- The mutation onErrors that toasted unconditionally after a non-quiet request (invite approve /
deny / add, demo whitelist, audit clear, scheduler, share, full history, edit, config tests, tag
attach) go through `notifyActionError`; the playlist delete's hand-rolled copy of it too (it takes
a level now). The user role / suspend / delete actions had no onError at all: a user deleted in
another tab failed silently and stayed listed — now said, and the list refetched.
Deep-review round 4, the Plex player.
- A 404 on an HLS session that WAS playing means the server reaped it (a long pause, a deploy);
hls.js never retries a 4xx, so playback ended with "couldn't play". A new session at the playhead
now takes its place (the frontend half of the paused-viewer reap fix). A session that 404s before
its manifest is still a plain failure, so it cannot restart itself in a loop.
- Leaving an item that ended, or that the credits auto-skip left, sent a final checkpoint at the
credits (racing the watched mark, and pushing a resume point to Plex after the scrobble) and
patched "new, at 49:55" into the cache — Prev reopened it at its last seconds. `finish` settles it
as watched at 0, said before the pause whose own checkpoint could land first.
- An audio switch, an A/V offset change and every seek control read `abs`, which is 0 until
playback ticks: a resume at 20:00 restarted from 0:00. They use the shared `playhead`.
- A seek while a restart is pending always restarts (the old media's `seekable` described the old
session, and the pending answer overrode the newer seek).
- A superseded direct-play session's loadedmetadata/canplay are generation-gated and detached (they
seeked the next item to the previous one's resume point).
- The session starts once per item: a reconnect refetch, the info panel's watched toggle or a
language switch no longer restarts the film at the last checkpoint.
- Tests for the session generation's failure paths and the item-change bump, which had none.
Deep-review round 4, backend half.
- The periodic HLS reaper (round 3) applied the "finished and unfetched for 30 s" rule that only a
NEW session request may apply. A remux finishes long before the film, and a paused player with a
full buffer fetches nothing, so a one-minute pause killed the session and playback died ~30 s after
resuming. The periodic pass is `reap_abandoned` now (the long idle limit, finished or not).
- A subtitle `publish` that could not keep its output (full / read-only cache volume) returned
quietly, so every poll queued the window and the whole-container pass again. It reports failure
and the pass is backed off like a failed read; a cached VTT that cannot be READ is answered
`unavailable` (`cache_unreadable`) instead of scheduling reads that can never land.
- The subtitle routes bound `rating_key` / `sub_ord` (they become a cache file name: a 250-digit
one was ENAMETOOLONG, which pathlib's exists() re-raises -> a 500), and the cache helpers no longer
throw on such a name.
- One app-wide handler turns psycopg's "text fields cannot contain NUL" DataError into a 422: round 3
guarded only the by-rating_key lookup, and `contains=`, `contains_group=`, reorder/bulk key lists
and the collection search still 500'd.
- The hand-written `db.get(...) is None -> 404 "Unknown ..."` siblings use `get_or_404` with a
`what` (channel / video / user), so the Hungarian UI no longer shows English for a gone row.
Deep-review round 3 of bug hunt 2026-09.
- The YouTube and Plex progress bodies are bounded by POSITION_MAX_S (the int4 position columns): a
finite position of 2^31 or more was a DataError, i.e. a 500 — the sibling of round 2's non-finite
fix.
- plex/stream.py promised "a periodic reaper", but reap_idle ran only inside the NEXT session
request: after the last playback an abandoned session (its ffmpeg still working, its directory a
whole segmented copy of the film) lived until someone pressed play again. A lifespan task reaps
every 60 s.
- Tests for what round 2 left untested: the 422's body keeps the list shape the UI localizes, and
stream_session refuses non-finite start/aoff.
Deep-review round 3 of bug hunt 2026-09 — the sibling of the collection page's fix (d349bc90).
Discover's toggle flips the Surprise draw too, but its resync refetches only the rails (refetching the
draw would reshuffle it under the viewer), so a refused toggle stayed flipped there. toggleWatchState
takes an optional `revert`, called on a failure only; the queryKeys comment that claimed the watched
toggle invalidates the Surprise key now says what the code does.
Deep-review round 3 of bug hunt 2026-09.
- The detail's error branch applies only when there is nothing to show (`isError && <no data>`, the
QueryState convention): one failed refetch — on focus, after a move or a removal — replaced a
loaded list with "couldn't load". The error line is announced (role=alert).
- Tests for the two branches of "the player survives a change of selection" that round 2 left
untested (a list that fails to load, no selection).
Deep-review round 3 of bug hunt 2026-09.
- get_or_404 / owned_or_404 answer a structured {code: "not_found", reason: <what>}
(errors.notFound.* in HU+EN, generic fallback). The structured-404 fix of round 2 had gone into the
ad-hoc `_own_playlist` (CLAUDE.md's "Not" pattern) while the shared helpers stayed string-only, so
views, tags, pins, Plex playlists, invites and users still 404'd in English. `_own_playlist` now
goes through owned_or_404; admin's hand-rolled user lookups use get_or_404.
- A move into a list that is gone names the TARGET (reason "move_target") — "this playlist doesn't
exist any more" was read as the list on screen, which still exists.
- Every refusal in the playlists router is a {code: "playlist", reason} (playlists.errors.* HU+EN)
instead of a bare English string shown verbatim in the Hungarian UI.
- `req` / `uploadReq` mark the errors they already put on screen (the error dialog, the connection
notice, the 401 sign-out), and notifyActionError skips those. The watched toggles, the saved views
and a playlist delete toasted a fault the dialog was already showing; Playlists' two hand-written
status filters now go through the same check.
Deep-review round 3 of bug hunt 2026-09 (the round-2 overlay toast host, finished).
- Inside an overlay the toast stack sits above the bottom band (bottom-24, the UpNextCard rung) and
lets clicks between cards through: at the app's own spot it covered the Plex transport row and
PlayerModal's docked bar and took their clicks while a toast was up (measured: play/pause, ±10,
stop, prev/next, mute all hit the toast).
- ui/Modal and the onboarding wizard mount an overlay host too, so a toast raised under any dialog
is readable and a click on it no longer closes the dialog; the Modal's Tab trap spans the whole
dialog, so a toast's action and dismiss are keyboard-reachable. A test now enforces the
convention: a component that covers the viewport at z-overlay must mount <Toaster overlay />.
- PlayerModal's host moves into the stage in true fullscreen (outside the element holding the lock it
was painted under it).
- A host that takes over shows the toasts it inherits silently (no second role=alert, its region
aria-live=off until they are in), and each countdown bar starts as far in as its toast already is
instead of restarting while the real dismiss timer does not.
- The overlay-host registry uses lib/store instead of a hand-rolled listener triad.
Deep-review round 3 of bug hunt 2026-09.
- loadSession checks, after its await, that it is still the newest call and the player is still
mounted. A late answer used to replace what was playing (Next twice quickly: the slower transcode
landed last and played the middle item under the last one's title, recording its progress there)
or attach a stream to the detached <video> after Back, never destroyed.
- The <video>'s clock is trusted only once the new session has answered. go()'s pause() fires a
timeupdate AFTER the reset to 0 (measured in Chrome), so the previous item's position was sent as
the next item's subtitle position and saved as its progress (final, pushed to Plex).
- Next settles the item being left (final checkpoint + reopen-cache patch) before switching: the
progress cleanup ran with the position already reset and skipped, so every Next dropped the last
stretch watched.
- A seek-restart puts its target into the position state too; the ref alone was re-synced back to
the pre-seek spot by the restart's own render.
- The unmount teardown captures the <video> at mount: React detaches refs before an unmount's
cleanups, so the stop/detach never ran.
- The tracks panel exposes the selected audio/subtitle track via aria-pressed.
Verified in real Chrome on localdev (Westworld S1E6 → Next): the item left was settled at 783 s, the
next asked for subtitles at its own start (0), and no progress was written for it.
Deep-review round 3 of bug hunt 2026-09.
- `failed` is now final only for a track that can never be text (bitmap, no item/track/file). A Plex
hiccup or a stretch ffmpeg could not read answers `unavailable`: said, polled again every 5 s, and
the <track> stays mounted when cues exist elsewhere (gen > 0). Re-picking the selected track asks
the server again at once.
- Within LOOKAHEAD_S of a covered stretch's end the next window is read ahead, instead of only once
the playhead has fallen off it (subtitles dropped out at every edge while the full pass was backed
off or slower than a window lasts).
- Windows and full passes run on separate pools with separate in-flight caps, so a window never
queues behind (or is refused by) minute-long full passes — the pool comment promised that and
nothing enforced it.
- A pass that times out is TERMinated and keeps what it reached (published as partial) instead of
being killed and discarded.
- Lapsed failure memos are swept on every write (they were only pruned when the same key was read
again, so polling fresh keys grew the process without bound).
- `publish` no longer merges into a sidecar with no VTT behind it, or claims a region whose VTT write
failed, or overwrites an unreadable track with one window.
- A NUL in a rating_key names no row (it was a DataError, a 500, on every Plex route) and no longer
500s the status poll.
Tests: 16 new, each mutation-checked (18 mutations, all red on the test that names them).
The player's "still alive" flag — every play() is gated on it, so a late one cannot start a detached
<video> after Back — was only ever LOWERED, in an effect's cleanup. React may run a cleanup and mount
again (StrictMode does exactly that in development), and then the flag stayed false for the life of
the player: it never started by itself, and a seek back past the loaded region, which restarts the
session, left it paused. Found on localdev (Westworld S01E06: playing → seek back → the picture
settles, paused). Production does not double-mount, which is why only dev showed it — and why
several earlier localdev measurements "stayed paused".
The effect now raises the flag in its setup. A new test renders the real player under StrictMode and
checks it plays once the media is ready; red without the fix. Verified on localdev: it starts by
itself, and keeps playing through a backward session-restart seek.
The feedSortOptions test compared each option's default direction against SORT_DEFAULT_DIR — the
implementation's own table, so a wrong entry passed — and that import was the export's only user
outside its module, which kept knip from seeing it had become dead after F3. The test now states the
directions, the table is private again.
C1 moved the optimistic flip onto the key the page reads — and so made visible that nothing corrects
it: the resync only invalidated `plex-library`, so a failed toggle stayed "watched" beside its own
error toast, and the collection's other cached sort orders stayed stale after a good one. The
resync now takes this page's `detail` prefix too, as the Discover and person pages do for theirs.
The C1 test holds the request open, so it still pins the optimistic write on its own.
- D3 snapshotted the removal's TARGET, but whether the player offered Remove still followed the
selected list: launched from Watch later (no Remove, by design), selecting another list under it
made Remove appear — and delete from Watch later. The snapshot now carries the launch list's
editability too.
- Selecting another list under an open player (the command palette works over it) put the page into
"Loading…", and the player was rendered only in the loaded branch — it vanished mid-playback and
came back at its launch video. It now sits at one place in the tree whatever the page is doing.
- A removal takes the video out of the CACHED list as well as the rows: the player's progress
checkpoint rewrote that entry before the refetch landed, handing the membership guard a list that
still held the video — the row came back and the undo history went.
- The watched-state callback refreshed the selected list, not the launch list (D3's sibling).
- `_own_playlist`'s 404 is structured (`{"code": "playlist", "reason": "not_found"}`) and localised:
the bare "Unknown playlist" reached the Hungarian UI in English, which also made round 1's new
`removeFailed` string unreachable. Both Remove and Move report through `notifyActionError` now.
- The "viewer moved on under the confirm" test stepped back only once, which lands on the same video
whether or not the removed one left the queue; it now takes the second step that tells them apart.
The app's Toaster lives in the content column, which is a stacking context (its `@container` implies
layout containment), so both full-screen players — portalled to <body> at the same z-index — painted
over every toast. Round 1's "a failed Remove is reported" fix was therefore still silent inside the
player: its toast sat under an 80% black, blurred scrim (elementFromPoint returned the scrim), and a
click on it closed the player.
An overlay now mounts its own `<Toaster overlay />`, and exactly one host shows the toasts: the most
recently mounted overlay, else the app's. Same component, same look — and inside the aria-modal
dialog, where a screen reader can still reach it. A click on a toast inside an overlay stays there.
Both players get one (the Plex player's also works in native fullscreen, where nothing outside the
fullscreen element is drawn). Verified on localdev: the removal failure reads above the scrim,
bottom-left, and clicking it leaves the player open.
- The <track>'s `error` never reached `onSubTrackError`: it does not bubble, and React 18 attaches no
listener of its own to a <track> (measured in Chrome and jsdom — `onError` 0 calls, `onErrorCapture`
1). Round 1's remount-and-give-up was dead code behind it. The hook now hands the player
`onErrorCapture`, and the test fires a real non-bubbling `error` at a rendered <track>.
- Once reachable, the retry burned its whole budget within milliseconds of an outage (a remount
fetches at once) and the give-up outlived turning subtitles off and on. Retries now wait (2 s,
doubling), running out is reported at once, and picking the track again starts a fresh budget.
- Until the first timeupdate the poll sent `sessionK` — still 0 while the session request was out,
and the PREVIOUS item's start after a move to the next episode. It now sends where the session was
asked to start, and the track choice belongs to the item it was made for, so the next item is not
asked about the old ordinal at the old position.
- A failing poll over a stale `partial` answer was silent: react-query keeps the last good data, the
track stayed "showable", and the notice only rendered for an unmounted track. The hook now decides
what the player says (`subNotice`), independently of whether a window is mounted.
The JSON parser under FastAPI accepts `Infinity`/`NaN`, and both progress checkpoints truncate the
position with `int()` — a hand-written checkpoint came back as a 500 with a traceback. Their bodies
now refuse non-finite floats, and the app's validation handler — FastAPI's own shape — echoes a
non-finite input by name: the default one failed to serialise its own error (JSON has no NaN), which
was a 500 by another route. Sibling of the subtitle routes' non-finite query parameters.
Deep-review round 2 of the 2026-09 bug hunt, over the round-1 fix diff.
- A window was LATE by the distance to the keyframe its input seek landed on, and then doubled for
good once the full pass merged in: a cue already running at the window's start came out at a
negative time, and the webvtt muxer's default shifted the whole output so it sat at 0. Measured on
the real Westworld S01E06 at 25:00: every window cue +5.005 s, 682 cues merged instead of 608.
`-avoid_negative_ts disabled` keeps the times exact (the parser reads ffmpeg's signed-field
spelling of a negative time); now 608/608 on that file, and a real-ffmpeg test pins it.
- A full pass that failed was re-armed every minute while the viewer watched what it did publish —
a whole-container read per minute. It now backs off for an hour; windows keep running meanwhile.
- A failed WINDOW set the track's failure memo, so any uncovered position read "failed" — a final
answer that ends the player's poll, even with the full pass about to deliver, and one any account
could plant with an absurd `at`. It now marks only the span it failed to read; "failed" is given
once the full pass AND a window there have both failed.
- Windows join by what they COVER, not a bucketed identity: a backward seek no longer waits behind a
window that cannot cover it, and a playhead crossing a bucket edge no longer queues a second read.
The queue is bounded per track and overall.
- shutdown() sets a flag a worker checks before it spawns ffmpeg (one still in its Plex lookup used to
start a read after the kill sweep); queued-job cancellation and the lifespan call are now tested.
- A window starts where ffmpeg is told to seek (rounded to the ms it gets), a rounded-up millisecond
carries into the minute (no more `00:00:60.000`), a key's first publish claims nothing before its
VTT lands, and `at`/`offset`/`start`/`aoff` refuse non-finite values (`at` also an absurd one).
The D1 regression test patched the cache and clicked at once; react-query delivers a cache write to
its observers on a setTimeout(0), so under the gate's parallel load the click could beat the page's
re-render and read the old row (748, not 830). Test-only race — a human reopen comes seconds later.
Stable across three full-suite runs; the D1 mutation still turns it red.
Deep-review R6 — the small items:
- The video card's channel name was still a <button> (pre-existing): ctrl/middle-click did nothing
there while the title above it and the player's channel name honoured them. It renders through
ChannelNameLink now, which grew a forwarded ref (the clamp measurement), pass-through attributes,
a `tooltip`, and an opt-in `stopPropagation` so no click reaches the card's row.
- ChannelNameLink rendered an empty, still-tabbable <a> for a channel with no title yet (a stub first
seen through a live search; pre-existing at this site). It falls back to the translated "Channel".
- The feed's SortKey→SortOption mapping existed twice in Feed.tsx; one `feedSortOptions` in
feedSort.ts serves both modes (DIRECTIONLESS is module-private again).
- The backfill refresh's comment claimed a one-page cost. It refetches every loaded page of the
grid, and that is the right price: backfilled videos are older, so they land below the head.
Tests for call sites that could be deleted with every lane green: the channel page running the
backfill refresh (knip had been blinded by the hook test's import), Feed handing the grid the live
results in the chosen key AND direction, the card using the link. Plus the alt-click arm of the shared
click predicate, and a plex filter-chip test that claimed it made no request while firing a real one.
Verified on localdev: the feed's channel names are anchors (same 14 px muted look), a plain click
opens the channel in-app. 7 mutations, each red on its assertion.
Deep-review R5 — five defects in the Remove button f76a2afb put inside the YT player:
- It removed from whichever playlist was selected by the time it was pressed, not the one the player
was launched from; the selection can change under an open player (the command palette opens over
it). The launch list is now part of the player snapshot, and the optimistic row removal only
touches the list actually on screen.
- A 403/404 failed in total silence: the catch claimed the global dialog had reported it, but `req`
deliberately leaves those to the caller. It now notifies, as moveItemTo already did (the silent
catch predates this round; the full-screen consumer did not). New key playlists.removeFailed, HU+EN.
- It computed the next item from the cursor as it was when the button was pressed, but prev/next and
auto-advance keep working under the confirm dialog: the viewer was thrown back onto a video they had
moved past. It reads the live cursor, and if they moved on it just drops the item from the queue.
- The button was disabled for the confirm's lifetime, so the dialog's focus restore hit a disabled
element and a keyboard user who cancelled was left on <body>, outside the aria-modal player. A ref
guards the double press instead.
- Its accessible name was a bare "Remove"; it now names its object, like the row control.
The call-site wiring (step on, drop from the queue, close on the last item, leave everything alone
when the removal did not happen) finally has tests. Verified on localdev in the real YT player: a row
reopened at the 136 s it had just stored; Cancel returned focus to the Remove button; a confirmed
removal stepped 1/3 -> 1/2. 10 mutations, each red on its assertion. Scratch list and positions reset.
Deep-review R4. 1630a3df wrote the YT player's stored position into the react-query cache so a
reopen could not rewind it — but only the feed actually read it back:
- Playlists renders from its own undoable copy of the list, re-seeded only when the SET of ids
changes, so the patched position never reached it: close a video at 830 s, reopen it from the
playlist, and it resumed at the old spot and the next checkpoint wrote that over the 830. A detail
with the same membership now rebases its fresh rows into every snapshot, keeping order and undo
history. (The ids-only guard is older than this round; the write that depended on it was new.)
- Live YouTube search results carry the same per-user position and open the same player, but their
cache was never patched. It is now — patched only, never invalidated, since refetching an
API-sourced search page spends 100 quota units.
Tests for what had none: a Playlists harness (PlayerModal stubbed to record its launch props) and a
PlayerModal harness with a fake IFrame API that records where each item is started — the latter pins
that stepping back to an item watched earlier in the same session resumes at THIS session's position.
3 mutations, each red on its assertion.
Deep-review R3. When 889d2acf put the sort into the collection detail's query key, two things on
the page did not follow it:
- The watched toggle's optimistic write kept the key's old spelling. setQueryData matches exactly,
so it landed on an entry nothing reads, and its resync only invalidates the library root — the
click succeeded server-side and changed nothing on screen (badge, icon, aria-label) until a
reload; a second click toggled it back. One `detailKey` now serves the read and the write.
- Every sort change was a key with no data yet, so the whole page — hero, grid, and the Sort control
with its open panel — was swapped for a spinner, and focus fell to <body>: key and direction could
not be set in one visit. The previous page now stays on screen while the new order loads, for the
SAME collection only (stepping to another must not show this one's hero meanwhile).
Verified on localdev (Rocky Collection): the toggle flips the poster; IMDb rating then Descending set
in one open panel, the hero never blanked, order reversed. 3 mutations, each red on its test.
Deep-review R2, plus one defect found while verifying it on localdev.
- The status poll re-armed every second after a FAILED request: `data` stays empty on an error,
so it read as "still preparing" for as long as the player was open — and the request was not
quiet, so a persistent 500 re-opened the global error dialog every second. It now backs off to
10 s (a blip still heals on its own) and is quiet; the player already shows the failure inline.
- A <track> whose own VTT fetch failed never fetched again: with the server's answer unchanged its
key was unchanged, so React kept the dead element — silently blank subtitles. The key (now built
in the hook, where it is tested) carries a failed-load count; after two remounts the track is
reported as unavailable instead.
- Found in UAT: until the first timeupdate `abs` is 0, so after a resume every poll said at=0 and
the first window was cut around the head of the film (a resume at 20:00 polled at=0 for as long
as it stayed paused). The poll reports the session's start until playback ticks. And the window's
dedup identity is the track plus a window-length bucket of its start — keyed on the track alone
(R1), the right window queued behind that wrong one.
The poll's start/stop rules finally have tests (every earlier one resolved a single fixed answer).
Verified on localdev against Westworld S1E6 resumed at 20:00: the polls carried at=1197, one
subtitle line on screen at 21:02, the poll stopped at `ready`; the real file's window (56 cues) +
full pass (608) merged to 608 — the old merge would have served 664. 11 mutations, all red.
Deep-review of the bug-hunt round (R1). The background extractor from ed4ab4e1 held, but its seams
did not:
- Window + full pass doubled every cue in the first hour. merge_vtt de-duplicated on the raw timing
text, and the two passes spell a timestamp differently (shift_vtt writes HH:MM:SS.mmm, ffmpeg's
webvtt muxer drops the hour below 1 h; shift_vtt(-0.0) never normalised the full pass). Timing is
now parsed and re-emitted canonically, keyed with the cue text so two cues sharing a time survive.
- A full pass that exited non-zero was published as complete: "ready" forever, subtitles ending
where ffmpeg died, and no eviction to ever retry. It now covers only up to its last cue, is never
complete, and records the failure memo.
- The window's dedup identity held the live playhead, so every 1 s poll queued another read of the
file. It is keyed on the track now, and a queued job re-checks coverage before reading anything.
- A sidecar track waited behind an unrelated embedded full pass (the full job is per item). Its
window job now fetches it whole — one small request.
- The <track> fetch ran the scheduler at position 0: an unrequested window over the head of the file,
and a refusal of a window that was on disk. It is a pure read of the cache now.
- publish() is serialised (a lost update became permanent once complete was set); the pool is shut
down and running ffmpeg children killed at app shutdown (they held interpreter exit for minutes);
?at=inf is a 422, not a 500; and both subtitle routes use current_user instead of the cookie-only
poster gate, which let a suspended or epoch-revoked session keep scheduling reads (pre-existing).
_extract_and_publish finally has tests of its own — every earlier test stubbed it out. 19 mutations,
each red on the assertion that names it.
Items 2, 4, 5 and 7 of the bug hunt, one round. They are independent, but they are all the same
kind of thing: an action that exists somewhere else in the app and is missing where you actually
want it.
**5 — the channel name in the YT player is a link again.** It was a `<button>`, so middle-click and
Ctrl/Cmd-click did nothing on a thing that looks exactly like a link (the YouTube icon beside it IS
an anchor and opens a tab). It is now a real `<a>` whose href is the same `?chan=<id>` URL
`openChannel` pushes, with a plain left click still taking the in-app path — that path does more
than navigate, since the player must close and reclaim its own Back-to-close history entry first.
The "is this click ours or the browser's" test is now ONE predicate (`isPlainLeftClick`), which
VideoCard's card link also uses, so a second such link cannot forget the middle-click arm.
**2 — "Remove" from inside the player.** The parent owns the removal (the SAME `removeItem` the row
menu and the inline ✕ use: one confirm, one optimistic update, one undo-stack rebase), and the
player owns what happens next, because it is the only side that knows which item is playing — its
launch queue is frozen at mount. It drops the item, plays what followed, and closes when the list
runs out. That forced a real fix in Playlists: the player's mount was derived from `shown` every
render, so anything that dropped the launch row yanked the player off the screen mid-playback. That
was a latent edge (change the view and it happens) and the normal case for this feature. The parent
now snapshots what it launched with, and only onClose takes the player down.
**4 — "Move to <the last list you moved to>".** The submenu is two levels deep and moving a run of
videos into the same list is the shape this gets used in. The remembered id is a per-account pref;
the row is derived from `moveTargets`, which already excludes the list you are looking at, so it
cannot offer a move to where the video already is. Remembered only on a SUCCESSFUL move — a
shortcut to a move that failed is a shortcut to failing again. Absent, not disabled, when there is
nothing to repeat: there is nothing to explain.
**7 — the live-YouTube results can be sorted.** Client-side, over the page already loaded. Not a
new `order=` param, and this is the decision worth recording: the owner's case is "even one page of
results", i.e. reorder what is on screen; a re-query would REPLACE the results, which is not what
"sort these" means; and the zero-quota scrape source has no order parameter at all, so a
server-side control would silently do nothing (or quietly cost 100 quota units) depending on which
source served the page. Only keys a search hit carries are offered — `subscribers`/`priority`
describe a subscription, which a search hit is not, and `shuffle` on a ranked list is noise. Its
own pref, so sorting a search page never re-sorts the library behind it.
Verified in the browser on localdev against the real account: the channel name renders as
`<a href="…&chan=…">` and a ctrl-click / middle-click is left to the browser while a plain click
still opens the channel in-app; removing from the player stepped 1/3 → 1/2 onto the next video and
closed on the last one; the row menu grew "Move to “…”" only after a move and only on a list that
is not the target; and the results sort reordered by views 41.7M → 29.8M → 24.2M → 15.8M. (The
pane's own ctrl-click arrives without the modifier — a harness limit, so the modifier cases were
driven as real MouseEvents.) Scratch playlists and the touched prefs were cleaned up afterwards.
22 vitest, mutation-checked against 14 mutations (no href at all · href without the rest of the
query · every click hijacked in-app · the middle-click arm dropped from the predicate · plain click
no longer in-app · always jumping to the top of the list · the removed item left in the queue · no
clamp when the last item goes · an empty queue not reported as "nothing left" · sort-then-reverse
flipping ties · unknown values first · titles compared numerically · shuffle offered on search
results · direction ignored) — each red on exactly the test that names it. Item 4 has no test of
its own: its rule is enforced by construction, not by a check.
Two halves of the same report ("Collectionben nem működik a rendezés?"). The pasted browse URL
sorts correctly — that part was working. What did not:
The Collection INFO page had no sort control at all and a hardcoded release-year-ascending order.
It now shows the browse grid's Sort control, with the browse grid's vocabulary, because a second
sort vocabulary on a neighbouring surface is exactly the kind of drift this repo keeps paying for:
`PLEX_SORT_KEYS` is now one tuple in `plex/query.py`, and a test pins BOTH implementations to it —
browse's key→column map (extracted from the handler to be reachable) and the collection page's
key→member map. A key added to the vocabulary without teaching one of them fails the gate instead
of putting a dead option in the menu.
Ordering a collection cannot be a single ORDER BY: its two legs are different tables, so the merged
member list is sorted in Python. Members with no value for the key sink to the END in both
directions (a movie with no rating is not "the worst rated" — the browse grid's `nullslast()`), and
ties always resolve title-ascending, so flipping the direction reverses the ranking rather than the
alphabet inside it. `lastwatched` needed the show leg's `resumed_at`, so the page now takes ONE
rollup pass that yields both the badge status and the recency instead of the status alone — without
it that menu entry would have ordered every show as never-watched. The default stays release-year
ascending: a franchise reads in release order, and that is what the page did before.
The other half: a shared Plex link carries the collection filter as bare rating_keys, so a link
opened cold had no name for them — the sidebar token rendered `17045` and the chip could only say
"Collection". `GET /api/plex/collections` takes a `keys=` CSV now, and a display-only hook resolves
just the missing ids. It deliberately does NOT write them into `collectionTitles`: that map is
hashed into the Plex query keys, so storing a resolved label there would split the cache from the
identical state and refetch the grid for a piece of text.
Verified in the browser on localdev against the real Plex library: the movie collection's menu
offers Length and the show collection's does not (the same scope rule as browse); rating-ascending
reorders 8.1/7.4/7.2/6.6/6.5 correctly; `lastwatched` on a show collection brings the in-progress
shows up; the choice survives a reload (it is a server pref, on its own key so opening a collection
never re-sorts the library behind it); and the reported URL now reads "Pirates of the Caribbean
Collection" in both the sidebar and the chip.
12 pytest + 8 vitest, mutation-checked against 14 mutations (direction ignored, nulls first, no
stable tiebreak, unknown key untouched, wrong default, show recency dropped, empty `keys=` falling
through to everything, a key missing from either sort map, every id refetched, ids unsorted,
fetched titles unmerged, the chip ignoring them, the hook's result never reaching the builder) —
each red on exactly the test that names it.
"Get full history" on a channel page left the list on the cards the channel already had. The
count above it climbed into the thousands, the grid did not move, and only a browser F5 brought
the rest in.
The mechanism: the backfill runs in a BackgroundTask, so the PATCH that starts it returns before
a single row is written — and the mutation invalidates `qk.feed()` on that RESPONSE, which just
refetches the OLD catalogue. Nothing afterwards refetches it: the feed's infinite query has no
interval, and the app-wide defaults are `staleTime: 30s` + `refetchOnWindowFocus: false`. The job
emits no event either (the realtime layer only carries messages today), so the channel detail's
4s live poll is the only thing in the app that sees the job progress — and it refreshed only
`qk.channel`.
So the poll now drives the grid too: on a tick where the stored count moved (or the backfill
finished), it invalidates `qk.feed()` and the `qk.feedSummary()` behind the count pill. The window
is the poll's own (`deep_requested && !backfill_done` on the previous reading), so a detail
refetch caused by some other mutation — each of which invalidates the feed itself — cannot come
through here twice.
Measured on localdev against the real account, same instrument, `document.visibilityState`
forced visible (the poll deliberately pauses on a hidden tab, and a headless pane reads hidden —
without that the first measurement was about the harness, not the fix):
before — CASCADA 19 -> 176 stored: the header updated live to "162 normal", the grid and its
count pill sat on "5 items" for 50s.
after — fern 54 -> 139 stored: "54 items" -> "139 items" by ~12s, no reload.
6 vitest, mutation-checked against six mutations (no invalidation at all; grid only, count pill
forgotten; predicate ignored; no channel-identity guard; completion with an unchanged count
dropped; poll-window guard dropped) — each one red on exactly the test that names it.
Bug hunt 2026-09, item 14 — "how does the multichannel downmix work, and is it
normalised?" It isn't: `plex/stream.py` has always encoded `-c:a aac -ac 2 -b:a
192k`, i.e. fixed stereo with ffmpeg's default downmix matrix and no loudness
handling at all, which is why a film mastered for a cinema arrives with whispered
dialogue and deafening action.
New admin knob `plex_audio_loudnorm` (Configuration → Plex), DEFAULT OFF: it makes
a film markedly louder and narrows its dynamic range, which is a taste call rather
than a fix. It sits at the end of the Plex group, after the software-transcode
knobs — it is an audio concern, not one of the libx264 ones.
-af aformat=channel_layouts=stereo,loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000
Three things about that chain are load-bearing and each has a test:
* the downmix is FORCED FIRST rather than left to `-ac 2` — measuring 5.1 and
folding it down afterwards would normalise something other than what is heard;
* the trailing resample is not decoration: `loudnorm` runs its own resampler at
192 kHz internally, which the AAC encoder will not take;
* one `-af`, no stream index, so EVERY audio rendition of a multi-audio file is
normalised — not just the first.
The knob is also part of the session directory name (`…_multi-ln_+0.00`), because
it changes the audio the segments contain, and a session left over from before the
admin flipped it must not be mistaken for one that honours it. The suffix is
appended with a DASH: `_SESSION_DIR_RE` treats the tag as one underscore-free
field, and that regex is the only thing keeping the orphan sweep off what may be a
shared scratch path. A test pins both halves — the name changes, and the sweep
still recognises it.
Measured end-to-end on the app's own HLS segments (a real 5.1 AC3 file, same item,
same 160 s offset, same ten segments): OFF -35.2 LUFS, ON -17.2 LUFS. On a 60 s
bench: I -27.6 → -16.6 LUFS with LRA 13.3 → 9.8 LU. A/V sync is untouched — under
this pipeline's own `-copyts` the audio start PTS is identical with and without the
filter, so the 3 s lookahead desyncs nothing.
Direct-played files are served untouched and stay so; a 5.1 mix is always `remux`,
so the reported case is fully covered.
Bug hunt 2026-09, items 8 and 12 — the two things the Plex player was measurably
lying about.
Item 8: picking a subtitle track showed nothing for a long time, and the reported
workaround was to stop/start until it suddenly worked. Measured on prod against a
17.06 GB MKV with two embedded SubRip tracks:
full pass, one track 154 s
full pass, both tracks in ONE run 111 s
-c:s copy (no WebVTT conversion) 143 s
from 1:00:00 to the end of file 66 s
a 630 s window at 1:00:00, both 8 s
So the conversion was never the cost: ffmpeg streams every byte of the container
between its seek point and its stop point, and the array delivers ~140 MB/s. The
only lever is reading less. Three changes follow from that:
* a window around the playhead is extracted and published FIRST (seconds), and
the full pass completes behind it;
* every embedded text track rides that same single read — picking Hungarian used
to cost a full pass and then switching to English cost another one;
* none of it happens inside a request any more. The endpoint answers 202 while a
background worker extracts, the player polls /status (carrying its position, so
the window follows the viewer), and one ffmpeg run finally has a timeout.
-copyts is deliberately absent from a windowed pass: with it ffmpeg ignores -t and
reads the whole container anyway (measured — a [0, 600 s] window still took the
full 145 s), so a window comes back on a zero-based clock and is shifted to
absolute time here. Cached tracks written by older builds have no coverage sidecar
and count as complete, so nothing is re-extracted after the upgrade.
The <track> is no longer mounted before the server has something to put in it; a
"Preparing subtitles: …" notice stands in, and a track that can never be shown says
why instead of leaving the viewer to conclude the picker does nothing.
Item 12: "Bitrate" read a constant 211 kbps on every remux, because it was the
master playlist's BANDWIDTH — which ffmpeg derives from the one stream whose rate
it knows, the -b:a 192k AAC track, plus 10%. It is a measurement now: bytes per
second of MEDIA from hls.js FRAG_LOADED, accumulated per fragment type and summed
(a multi-audio session's video and audio renditions cover the same media seconds,
so pooling them would report half the truth), and an honest dash until the first
fragment lands. The panel also finally names the audio and subtitle track in play.
Verified in the browser against the real Plex library, and every new test
mutation-checked.
The button deletes the local row and mirrors the un-mark to Plex, but it decided whether
to push from the previous STATUS alone (prev_status != "new") — and an in-progress item's
row is status="new" carrying a position, which is exactly how the importer maps a bare
viewOffset. So for the single case the button exists for, nothing was ever sent to Plex:
the offset stayed, and the next pull imported it straight back. The user cleared Protector
twice and it returned to Continue watching at 92s both times.
Push now when Plex holds anything to clear — a view count, or a resume point. "hidden"
stays Siftlode-only and pushes nothing, which is what this endpoint's docstring always
claimed and the status-only guard contradicted.
One unscrobble clears BOTH viewCount and viewOffset — measured against the live server from
a watched+offset state and an offset-only one, so the push itself needed no change. The
client docstring that claimed "clears viewCount" is what sent the first diagnosis down a
wrong path (a timeline write with time=0, which does not clear an offset at all); it now
records what the server actually does.
Verified end to end on the real Plex server: the button leaves viewCount=None viewOffset=None,
and a full 44,885-item re-import brings back no row for it.
The player checkpointed every 5s and the server stored it, but nothing wrote the new
position back into react-query: the close path only marked qk.feed() stale with
refetchType "none", and nothing refetches the feed until it remounts (staleTime 30s,
refetchOnWindowFocus off). So the resume read the position the cache had been FETCHED
with, and the next checkpoint then persisted that older value over the newer one.
Measured on localdev: played to 830s, closed (server had 830), reopened the same card
without a reload -> the row was back to 751s. 79 seconds of progress destroyed by a
reopen, and the card's progress bar never moved either.
The Plex player already solved this for its detail query, with a comment describing the
identical failure; this is the feed-side twin that was missed in that sweep. patchCachedProgress
writes the position the SERVER stored (the endpoint clears trivially-early and near-finished
checkpoints to 0, so mirroring our own reading would disagree with the row) into every cached
feed page and playlist detail, keeping array identity where nothing changed. A session map
covers the second half: the launch queue is frozen at mount, so stepping next->prev back to an
item played earlier in the same session resumed from that snapshot.
Verified in the browser: 811 -> close 816 -> reopen 825, and the card now shows Continue/Restart.
Round 4 reviewed round 3's commit. Nothing in production behaviour was wrong; what was wrong
was the round's own test and its comments, which is the failure class this loop keeps
finding.
- The new NavigationProvider case named after the `&& !channelViewRef.current` clause never
reached it. With storage cleared and a bare URL the provider lands on "home" (E24's own
default), not "feed", so asking for "feed" was an ordinary page change and the guard's
first conjunct short-circuited. Deleting the clause left all 828 tests green — a dead nav
control (the rail's own module stops closing an open channel page) with no red anywhere.
The test now asserts the precondition and calls `setPage` with the page that IS current.
- Both new Home cases rejected a promise and then asserted after one fixed macrotask.
React-query notifies through `notifyManager`'s own `setTimeout(0)`, registered after any
timer the test schedules, so the assertion raced it; two reviewers measured intermittent
reds. They poll for a signal that the rejection LANDED instead — the reroll button
releasing, which is also the honest one for the empty-answer case, where the empty state is
already on screen while the press is in flight and asserting it early would pass either way.
- `buryUnder`'s comment still opened with "one rule for both entry points" ten lines above the
paragraph saying the rule is complete only for one of them, listed the non-reporting palette
rows without `openChannel` — which has its own `replaceState` no-push branch and is reached
from PlayerModal too — and pointed at a ROADMAP.md this repository does not contain.
- `history.dom.test.ts`'s header claimed its layers close by genuine Backs only, which two of
its own tests contradict; and `CommandPalette.ordering.dom.test.tsx` still claimed to be
"the only assertion in the suite" doing what the file corrected in round 3 also does. Round
3's message said that pair was handled and only one side had been.
The provider test was re-mutation-checked (dropping the clause now goes red), and both
hardened Home tests still go red against their own reverts. Seven consecutive full runs green.
Gate: ruff, ruff format, 3x tsc, eslint (0 errors), prettier, knip, vitest 828, pytest 1005.
Round 3 reviewed the round-2 commit across six dimensions. Fifteen findings, five survived
three adversarial refuters each; three died on 2+ refutations (a keyboard-focus claim that
is a property of `ui/Button` at all six of its `loading` call sites, a contract-test
false-pass that needs a deliberate revert plus a self-incriminating comment, and a
MAX_EXCLUDE coupling whose slice is unreachable from the real client).
- `rerolling` was still wrong for a FAILED press. `data === undefined` is true forever for a
key whose request failed (`retry: false`), so the next unrelated mutation re-disabled the
reroll button — the same defect round 2 reported, one branch over. `!isFetched` is the
flag that remembers a key settled; `!isError` would not have worked, because query-core
resets a data-less query to "pending" for the duration of a refetch, hiding the error
exactly when it matters.
- The hero fallback used `??`, which cannot tell "this budget's last pick resolved to NO
hero" from "nothing remembered". So a press that failed after an empty one resurrected the
card the page had already excluded as seen and just said it had nothing beyond. The tagged
slot is now authoritative for its own budget.
- `setPage`'s boolean — the input the whole burial rule reads — was asserted by nothing.
The type fences the SHAPE, so deleting a `return` fails tsc, but swapping the two values
passed typecheck and all 822 tests. NavigationProvider gets its first render test, on the
value AND on the effects the early return skips.
- `closeThenNavigate`'s door into `buryUnder` was equally unpinned: reverting that one call
site to the old unconditional burial left the suite green. It has the mirror case now.
- The comment claiming one rule serves both entry points was overstated. `buryUnder` fails
OPEN, and `CommandItem.run` is typed `() => void`, so only the palette's bare
`() => setPage(page)` rows forward the boolean at all. Nothing regressed — that path buried
unconditionally before — but the prose said more than the code does, so it now says what is
true and points at the deferred fix. Same for a "the only assertion in the suite" claim
that the file it cites as precedent also makes.
Every new test mutation-checked red for the right reason, both edges where there are two.
Gate: ruff, ruff format, 3x tsc, eslint (0 errors), prettier, knip, vitest 828, pytest 1005.
Nine of the round's ten surviving findings. The tenth (a failed shelf reorder leaving an
order the server rejected on screen) is on ROADMAP.md: its obvious remedy reintroduces the
overlapping-move defect the code already documents, so it wants a design, not a review edit.
- `navigateOverOverlays` buried the live overlay entries before knowing whether the
navigation pushed one. `setPage` no-ops when the page is already current and Home is the
default landing page, so Shift+W over a player ON Home marked its entry buried under a
push that never happened: the unmount consumed the mark instead of reclaiming the entry,
and the next browser Back was a press that did nothing. `setPage` now reports whether it
navigated, and one `buryUnder` rule serves both entry points — `closeThenNavigate` takes
the same no-op navigation from a palette command for the page you are already on.
- The hero could answer a new time budget with the previous one's pick. The chip row resets
seed/seen/press but cannot reset an answer that already resolved, and `keepPreviousData`
chained through the disabled seed-0 query — so a reroll under "10 perc" showed the film
picked for "Bármennyi" while it loaded, and kept it if the request failed. `lastPick` now
carries the server's own budget echo, and the placeholder is scoped to the budget.
- The reroll button spun and swallowed clicks after mark-watched, bookmark, pin and shelf
moves: the pick key sits under the invalidated `discover` prefix on purpose, so raw
`isFetching` was true for background refetches. It now means "a press of mine is
outstanding" — which still covers the first press, where there is no placeholder.
- Failed writes on Home toasted the page's LOAD-failure string over a fully loaded page;
they get their own key (HU+EN). The empty-state reroll had no `loading`, so the press was
unacknowledged and a second click skipped a tier. `danger` moved into the shared unpin row
so the same command is not red on the shelf and neutral in the rail below it.
- Two comments claimed things the code does not do: the `exclude` cap guards no "unbounded
scan" (it never reaches SQL), and the route test that named the cap sends three keys.
Tests: the burial rule gets its own file (it is the one assertion that lets a reconcile
issue a real programmatic back()); App's keymap wiring gets a source contract, because
dropping it left all 818 tests green; `add_pin`'s IntegrityError arm was reached by nothing.
Every new test mutation-checked red, both edges where the predicate has two.
Gate: ruff, ruff format, 3x tsc, eslint (0 errors), prettier, knip, vitest 822, pytest 1005.
`add_pin`'s IntegrityError handler assumed the unique constraint was the only way in and
re-raised otherwise, so a target row pruned between `resolve_ref` confirming it and the
INSERT (a Plex sync dropping a removed item) tripped the FK and handed the user a 500.
Semantically that is the same "no such thing" the pre-check already returns. Untested: the
race needs two concurrent transactions.
The five per-kind unique constraints had no test at all — the route's own dedupe returns
before reaching the DB, and the migration drift check filters constraint diffs out as noise,
so they could go missing with every lane green.
Frontend, all from the same review: the shelf's unpin row is now `danger` like every other
destructive row in the app; the hero's artwork button says what it will actually do and
names the item instead of always announcing "Play"; the empty-budget message offers a reroll
rather than `StateMessage`'s "Retry", which promised a re-run of something that had not
failed; `setState` and the watch-later toggle catch their rejections instead of leaving an
unhandled promise and a click that silently does nothing.
`StateMessage` gained a `compact` rung, which retires the frontend's only two `!important`
overrides — they existed to beat the primitive's own padding from the call site, and were
the copyable idiom for the next surface that wanted a compact empty state.
Dead vocabulary removed or made load-bearing: `DISCOVER_BUDGETS` was exported, re-exported
and consumed by nothing while `Home.tsx` re-typed the same four bands (a `readonly
DiscoverBudget[]` accepts any subset, so a fifth band would have typechecked green and
rendered no chip); `PIN_KINDS`' value export had no consumer at all; `useComfortPin` no
longer publishes `pinIdOf`/`toggle`, which nothing outside the module calls.
Three comments corrected against the code they describe: a pointer to a `resolve.py` that
was never written, the file header's claim that Home defines no card component of its own,
and the `seen` window still quoting the 8 that was measured and abandoned for 32.
The rest of the review's survivors are logged on ROADMAP.md under "Deferred — E24 epic-end
deep-review (2026-09-08)" — the ones whose dissent was substantive, and the ones whose fix
is wider than the finding.
`test_only_a_show_row_pays_for_a_resolution` seeded no watch state, so every rail query
inner-joined its way to zero rows and the loop body — with it the assertion — never ran. It
now seeds a video, a movie and an episode, and asserts all three kinds are present before
the loop, so it cannot go vacuous again.
The Plex half of the surprise rail had no test for either of its promises: every test that
reached `plex_surprise` passed budget="any" against a library with no state rows, so the
duration bounds and the already-started exclusion could both be deleted with the suite
green. The YouTube half has had tests for the same two rules since S1 — the asymmetric half
of one principle.
`plex_item` and `plex_show` — two of the five pinnable kinds — were pinned by no test at
all, leaving the rating_key → internal-id translation and both shelf arms uncovered,
including the self-reference cycle that once 500'd the shelf. And `GET /api/discover/pick`
was exercised only through its domain function, so the route's own query-string translation
could regress silently; the new test excludes all three candidates, which is what proves the
whole list parsed rather than one unsplit string.
`PIN_COLUMNS`/`PIN_KINDS` claimed in a comment to be the one place the pin vocabulary is
spoken and had no readers anywhere: `resolve_ref` re-spelled the column names and the route
re-typed the kind list. Both now drive off the constants.
The `_NO_TS` sentinel was introduced as a crash fix and is not one. The key it replaced,
`(ts is not None, ts)`, cannot raise — tuple comparison finds the first UNEQUAL element via
`==`, so two null keys compare equal and `None < None` is never reached; three independent
runs confirmed it over every permutation. The rationale now says what the sentinel actually
buys, lives once in `plex.rollup`, and the third rail that was still on the older spelling
has been swept to match. Its test is renamed and re-scoped to the mutation it does catch.
Frontend: the reroll's second press asserted `expect.any(Array)`, which cannot tell an
accumulating exclusion window from a replacing one — both that and SEEN_WINDOW = 1 (each
reinstating the measured nine-card cycle) left the suite green. And `onInfo`'s jump type was
untested while `onPlay`'s was pinned twice, so pointing the ⓘ affordance at the player
typechecked and shipped green.
All eight fixes mutation-checked; every mutant died on its intended test.
Shift+W was dead whenever anything was open. The global handler called `setPage` directly,
so the page change unmounted the player (it lives inside the module column) and that
unmount's reconcile reclaimed the entry the navigation had just pushed — the player closed
and the page snapped back. `closeThenNavigate` cannot serve here: a global key does not
know which overlay is open, or whether one is. `navigateOverOverlays` is the same fix one
step shorter — bury the live overlay entries first, so the unmount consumes the marks
instead of issuing `history.back()`.
`q.isError` had no `&& !data` guard, alone among 33 call sites and against the convention
`ui/QueryState` documents: react-query flips status to error on a failed REFETCH while
keeping the payload, and every mutation here invalidates the discover prefix, so one blip
while marking a card watched blanked a fully populated landing page.
The reroll's key rooted at "discover-pick", outside the prefix every mutation invalidates,
so a rerolled hero outlived the page: play it, finish it, and it was still the answer with
a zeroed progress bar. It now sits under the same root. A FAILED reroll also rewound the
hero to the card the page loaded with, several presses back, because a new key per press
means `keepPreviousData` (pending-only) cannot cover an error; the last resolved pick is
held instead.
The bookmark on a rail tile never updated — `VideoCard` reads `video.saved` from this
payload and keeps no optimistic state — so the star stayed empty and the next click
re-sent the same call.
The shelf reorder wrote a captured snapshot back on failure rather than re-asking the
server, which its own comment promised: two overlapping moves each reverted to their own
snapshot and settled on an order neither side held, and since `before` is the whole
payload a rollback also discarded any rail refresh that had landed meanwhile. It now
invalidates the discover PREFIX on settle, which also fixes the sibling budgets keeping
the pre-move order.
The hero/rail de-duplication matched one key, so for a Plex series — the rail row is the
show, the hero is the episode it resolves to — the same series printed twice; it now
matches both keys, as `hero.pick`'s own exclusion already did. And when the hero empties
the resume rail, the rail is hidden rather than announcing "nothing half-finished right
now" under a hero captioned "where you left off".
Ten regression tests, all mutation-checked. Three of them were rewritten after the first
mutation round showed they could not go red: two asserted before the state under test had
settled, one matched a title the Plex tile does not render.
The resume rail built its own SELECT instead of going through `filtered_query` and copied
only three of its predicates, so it dropped the per-user channel blocklist the module
docstring claims it inherits. Blocking a channel never touches `video_states`, so a
half-watched video from it kept its resume position and headed the rail — and, at the
default budget with the resume tier leading, became the landing page's hero: the app
answering "what should I watch" with the thing the user had just banned. The clause is now
`feedquery.not_blocked_cond`, shared with `filtered_query` rather than copied a second
time; the feed's SCOPE clauses stay deliberately unapplied, for the reason `yt_video_row`
already gives.
`next_position` returned the shelf's row COUNT while nothing ever renumbers positions —
`remove_pin` deletes, and every FK cascade removes a pin when its target disappears. Past
the first hole the count under-counts, so a new pin landed in the middle of the shelf and,
past the second, on a position another pin already held. `max(position) + 1`, the idiom
`routes/playlists` already uses.
`next_episode` took the FIRST in-progress episode in broadcast order while `plex.rollup`,
which drives the show page's Resume button, takes the last. One show therefore had two
different "continue watching" answers, with the Home tile ranking it by its most recent
touch and then offering the episode abandoned weeks earlier. The new test asserts the two
implementations against each other rather than against a literal.
All three mutation-checked: each new test goes red for the defect it names.
S4's reachability half. Home already led the nav order; what was missing was every
way IN other than the rail.
- Settings → Appearance → Startup: "Start on Home", the documented escape hatch for
the landing override. Deliberately still localStorage and not a server pref —
`loadInitialPage` answers before /api/me lands, and a landing module decided after
the first paint is a visible jump. Its two stored values are now ONE exported
constant, so the writer and the boot-time reader cannot drift on a magic string.
- The command palette gets "Meglepetés" / "Surprise me": it navigates to Home and
rerolls the hero on arrival. No second "Mit nézzek?" row beside it — the Home
module row already matches that phrase through its descriptive title.
- A global Shift+W jumps to Home. A chord, not a bare `w`: the Plex player binds bare
letters and `w` sits on `s`, so a blind reach for the subtitle key would have thrown
a viewer out of the film.
The palette's request crosses a lazy module boundary, so it is a token that waits
(lib/homeIntent) rather than an event fired into an empty room. Browser-measured:
taken with a compare-and-clear, because StrictMode's double-invoked mount effect
otherwise rerolled twice and landed on the page two picks deep.
HU/EN parity kept; UAT on localdev in both languages, including the empty shelf.
All three were measured before they were touched, and the diagnosis moved twice.
**Back from a Plex title played off Home landed on the Plex module's root.** One
click pushed three history entries: the page navigation, the sub-view opened by
the `jumpTo` hand-off, and — under StrictMode, whose effect body runs twice
before `setJumpTo(null)` re-renders — that same sub-view a second time. So the
first Back visibly did nothing and the second stranded the user on a Discover
page they had never visited.
A cross-module hand-off is the CONTINUATION of the navigation that brought you
there, not a second navigation, so it now REPLACES the page entry instead of
stacking on it (`useHistorySubview.open(view, {replace})`, asked for by the jump
target). Replacing is also idempotent, which is what disarms the StrictMode
double-run. The intent rides on `PlexJumpTarget` rather than being inferred,
because the header typeahead jumps from INSIDE the module, where the entry being
replaced would be a sub-view the user really was looking at.
**"Mást kérek" cycled nine cards and never left the resume tier.** Measured:
twelve presses, nine distinct titles, period nine, every one `tier: "resume"` —
the shelf and the surprise tier were unreachable by pressing the button. Two
causes multiplied: the tiers were tried in a fixed priority order over a 20-row
resume rail, so the button just walked down it; and the client's rolling
exclusion window of 8 guaranteed the wrap.
The press count now rotates which tier answers first (`start`), so each press
offers a different KIND of answer — the user's call, given the choice between
that, a wider window, and a surprise-only reroll. The fallthrough is kept, so a
press does not come back empty because the tier it led with had nothing. The
window is matched to the server's own cap rather than a smaller number of ours.
**A half-watched series on Home opened the show page.** A movie already went
straight to the player; only shows did not — and the comfort shelf and the hero
both resolved a show to its next episode while this rail alone did not, handing
back six seasons to choose between. The rail row now carries `next`, and the
client already played `next ?? card`, so this is a backend fix.
Affording it needed `queries.next_episode` to stop loading every episode of the
show into Python to pick one of them (236 rows for Friends, 227 for JAG) — three
ordered LIMIT 1 probes instead. The show→episode rule now lives in ONE function
(`rails.playable`), which is what the shelf, the hero and the rail all call; two
implementations are how they came to disagree.
Ten mutations run, all ten red for the right test — including a first pass where
the press count reached `api.discoverPick` and stopped there, invisible because
every Home test mocks that module. The slice now has its own URL test.
Verified in a clean browser tab against the live account: one Back returns to
Home, the rotation gives resume/comfort/surprise across nine presses with no
repeat, and Westworld opens at S1E1 rather than at its show page.
S3 wires the shelf's editor. The gesture is the E10 right-click menu, so the
decision "this is comfort media" can be made where the thing is — the feed, a
Plex grid, a channel row, a playlist — rather than only on the page that lists
the shelf.
One shared piece, `components/comfortPin.tsx`: the five pin-kind constructors,
the shelf query, and ONE `ActionMenuItem` that switches Pin/Unpin honestly (and
says "not known yet" with a disabled row until the shelf has answered, since a
confident "Pin" over something already pinned is exactly the quiet lie the house
rules forbid). Surfaces that already have a row menu compose the item in
(`Playlists`, `PlaylistsRail`); the rest mount `ComfortPinMenu`.
Two structural notes:
- The feed's menu lives in `VirtualFeed`, not in `Feed` — that is the list, and
it is also what gives the channel page the gesture for free, since it renders
`<Feed channelScoped>` all the way down.
- Cards take `menuProps` as a FUNCTION of the row, not the built props object.
`triggerProps` is stable across renders while its return value is not, and a
fresh object prop would blow past `VideoCard`'s memo for every card in the
virtualized feed on every parent render.
`DataTable` and `ChannelLayoutGrid` grow a generic `rowProps` so a channel row
answers the same gesture in both of the manager's layouts; the table stays
domain-free and never learns what a menu is.
On Home the shelf tile's menu is the whole editor: four move rows (front / left
/ right / end, reusing the playlist menu's ordering idiom) plus the unpin, over
menu rows rather than drag & drop — dragging inside a horizontal, scrolling
Carousel fights the scroll, and menu rows are keyboard-reachable for free. The
reorder is optimistic on the cached payload and re-asks the server on failure
rather than showing an order that was never accepted.
The exhausted shelf tile (a pinned channel with nothing unwatched left) is
`aria-disabled`, not `disabled`: measured in Chromium, a disabled button still
receives contextmenu and pointerdown, but it loses focus — and with it Shift+F10,
the only keyboard route to the menu it most needs.
Verified end to end in the browser against the live account: all five pin kinds,
the reorder (persisted across a reload), the unpin, and the honest Unpin label on
a resume tile that is already on the shelf.
E24 S2. A new `home` module in layout C+ (the mockup the owner picked): the
time-budget chips on top, one large hero that answers them, then the comfort
shelf and a cross-source resume rail. Home is the landing — deliberately OVER
the remembered last module, since landing on whatever you happened to close the
app on is the habit this epic exists to replace; `LS.startOnHome = "0"` opts an
account back out (Settings toggle lands in S4).
No third card component: each unified card carries the native feed / Plex
payload, so the rails render through the existing VideoCard (`cardsSmall`) and
PlexPosterCard, and `aspect` decides the tile shape without branching on source.
Plex playback hands off to the Plex module via a new `jumpTo` type — the player
lives there with its lazy hls.js and its provider tree, and mounting a second one
on the landing page to save one hand-off would be the expensive way round.
Four defects found by driving it in a real browser against the live account, each
now pinned by a mutation-checked test:
- **The reroll gave the same card back.** A new seed only re-draws the SURPRISE
tier; the resume tier deterministically returns the most recently progressed
thing that fits, so anyone with something half-finished pressed a dead button.
`/pick` now takes an `exclude` list of `<source>:<id>` keys and the client keeps
a rolling window of 8. Excluding a SHOW also excludes the episode it resolves
to — otherwise the same suggestion comes back wearing a hat.
- **"Nothing to suggest yet" flashed over a full library** whenever a reroll was
in flight: the new query key has no data for a beat. An empty state standing in
for a loading one is the dishonesty the house rules name outright. The hero now
falls back to the loaded payload while a pick is pending, and only trusts a null
once the pick has RESOLVED.
- **A budget change replaced the page — chips included — with a spinner**, so the
control you just clicked vanished under your cursor. `keepPreviousData`, the
same reason the feed uses it.
- **The hero printed twice**, once as itself and once as the first resume tile.
Two smaller UI calls made from the same session: the budget caption sits NEXT to
its chips rather than in SegmentedToggle's justify-between mode, which on an 80rem
page throws the answers to the far edge; and the feed takes the YouTube mark its
own toolbar already uses, since Home now owns the house icon.
Gate green: ruff, ruff format, 3x tsc, eslint, prettier, knip, vitest 780,
pytest 989. Verified end to end on localdev against the real account: budget
chips re-answer (matched on REMAINING time — a 51-minute video with 2:46 left is
a ten-minute answer), the reroll walks three distinct heroes, and a Plex show
tile opens its drill-down in the Plex module.
E24 S1. The anti-paralysis answer layer, served from our own Postgres with no
upstream call: one hero suggestion, one CROSS-SOURCE resume rail, and the user's
own comfort shelf.
Survey first, because the roadmap line was wrong about the starting point: Plex
already ships the whole trio (E5's Discover rails, including a reshuffling
Surprise me), the YouTube side ships none of it, and nothing anywhere answers
the question across both. The data already existed on both sides — VideoState
and PlexState are structurally identical, the feed has an `in_progress` filter
and a seeded shuffle, and the YT player already resumes position.
- app/discover/budget.py — the time bands. "How much time do you have"
(10 perc / 30 perc / egy este / mindegy) replaces 15/D's mood questionnaire:
deterministic, answerable from a column we have, and it picks the source for
free. Matched against REMAINING time, so a film with 8 minutes left is a
ten-minute answer. An unknown runtime fits only the unbounded band.
- app/discover/cards.py — a thin unified card that carries the NATIVE feed /
Plex payload, so the page renders with the existing card components instead of
a third one. `aspect` is the one new field: a rail needs to know poster from
16:9, and that is a property of the artwork, not of the module.
- app/discover/queries.py — the SQL, reusing feedquery and plex.rollup rather
than the Plex Discover ROUTE, whose LibraryFilters narrowing belongs to a
surface that has a sidebar. One seed drives both halves so a reroll moves
them together.
- app/discover/hero.py — resume -> comfort -> surprise, with the chosen tier in
the payload so the client labels it honestly instead of guessing.
- comfort_pins (0083): five nullable CASCADE-ing FKs under num_nonnulls(...) = 1
rather than a flag or a polymorphic string ref. A flag cannot order a shelf or
hold a channel; a string ref would let a deleted library item leave a dead tile
on the one surface whose whole job is to be reliable.
Two honest-state rules the tests pin: the surprise draw excludes anything
already started (the resume tier may have skipped it for not fitting, and
offering it back as "Meglepetés" would contradict the question), and which
source leads the draw follows the seed, so the Plex library can actually answer
"egy este".
Gate green: ruff, ruff format, 3x tsc, eslint, prettier, knip, vitest 770,
pytest 987.
The Continue-watching and Surprise rails sorted on `(ts is not None, ts)`. That
key LOOKS null-safe and is not: two rows that both lack a timestamp compare
equal on the flag and then compare `None < None`, which raises — so a show whose
rollup has no `resumed_at`, or a movie state written before
`progress_updated_at` existed, could 500 the rail instead of sorting last.
Replaced with an explicit epoch sentinel, and named it beside the other rail
constants so the sibling rail E24 adds cannot re-derive the same broken key.
The browse scope is a pure DB query with no request state, but it lived in
routes/plex/_shared.py. E24's Home surface needs the same scope from a domain
package, and a domain package importing from routes/ inverts the one-way
dependency CLAUDE.md draws.
Moved to app/plex/query.py and renamed to content_libs (public — it is a domain
helper now). Swept all five call sites and both tests in one go rather than
leaving a re-export shim: the canonical-patterns table already names the
routes.admin re-export as the anti-pattern.
Round 3 reviewed round 2's diff. Trend across the loop: 32 findings over
the whole epic, 15 over round 1's fixes, 11 over round 2's. What it caught
is mostly the same shape as before — a guard narrowed until it was wrong
in the other direction — plus one item this branch had now got wrong
three different ways.
Backspace ownership over the player, third and final answer. Round 1
deferred to nothing (the cheat sheet took the key and the player stopped
underneath it). Round 1's fix deferred to `anyLayerAbove` (the player's
own gear/tracks popovers push layers too, so it stopped closing them).
Round 2's fix named the sheet and palette (the app-wide ErrorDialog is
neither, so the player tore down under it). The rule that actually holds
is not a list of overlays: the player's own panels answer first, because
handleBack's cascade is what closes them, and anything else layered above
takes the key.
The rail's inline rename is settled by measurement rather than argument.
Two rounds reasoned about whether a React passive effect lands before or
after floating-ui's return-focus microtask and reached opposite
conclusions; a jsdom harness built to decide it reproduced no failure for
ANY shape, because focus never enters the menu there — so it was deleted
rather than kept as a test that discriminates nothing. Checked in a real
browser instead: the field opens and holds focus. The shipped fix works,
and the comment now says what was measured rather than what was inferred.
The same check found that round 1's OTHER rail fix never worked at all.
Escape does run its own handler (typing then Escape leaves the name
unchanged, so it cancels rather than blur-commits) and the row button is
focusable when focused from outside — yet focus still lands on <body>.
Three explanations, none of which survived. So the refocus machinery is
reverted to the shape that shipped before this epic and the gap is
documented where the next person will hit it, with the evidence and an
instruction to instrument before theorising. A fourth guess would have
been worse than a known bug.
Also: the two comments in Playlists.tsx that still claimed the disabled
menu rows explain themselves — untrue since round 2 reverted `inert`, and
exactly the comment-vs-code drift CLAUDE.md calls the source of the worst
bugs here. And the move-failure toast printed HttpError.detail verbatim,
which for the reachable 404 is the backend's bare English "Unknown
playlist"; only a structured detail has been through localizeDetail, so
only a structured detail reaches the toast now.
Round 2 reviewed round 1's own fix diff, which is where a fix round does
its damage. It found 15 issues in 792 lines — a much higher density than
round 1 found in the whole epic — and most were regressions round 1
introduced. So this follows the rule rather than fighting it: revert the
fixes whose mechanism was wrong, narrow the ones whose guard was too
broad, and stop reaching for a third variant.
REVERTED, because the mechanism does not work:
- The `inert` menu item. Round 1 swapped a native `disabled` for
`aria-disabled` so a row's "why is this off" tooltip could be reached.
floating-ui's list navigation skips `aria-disabled="true"` exactly as it
skips `disabled` (isListIndexDisabled) — and AnchoredPopover's own
comment, written before this branch, already said so. The swap fixed
nothing and made the mouse worse: without native `disabled` the row took
pointer events, the full active highlight and DOM focus, then no-opped
on click. The underlying finding is logged where the prop was, unfixed
and honest: the reason has to go somewhere a disabled row is not.
- `useContextMenu` owning the focus restore. Turning floating-ui's
returnFocus off platform-wide traded one bug for two: Playlists spreads
triggerProps on a bare <tr>, so restoring to it is a spec no-op and
Escape stranded focus on <body>; and with dismissOnScroll now on, the
restore's plain focus() scrolled the row back into view, undoing the
scroll that closed the menu. Patching that needs three more guards on
one fix. The original bug — the rail's rename field dying on arrival —
is fixed at the field instead: it focuses from an effect, which is
scheduled after the microtask that was blurring it.
NARROWED, because the fix was right and the guard was not:
- PlexPlayer's Backspace deferred to `anyLayerAbove`, but the player's own
gear and tracks menus register layers via useDismiss — so Backspace
stopped closing them and did nothing at all. Only the cheat sheet and
the palette own that key, so it names them.
- `historyDepth = 0` also desynced the counter from overlays the
navigation does NOT unmount (About and ReleaseNotes live outside the
page column), leaving one Back to both navigate and close them. The
entries are marked buried and consumed silently by reconcile instead.
- The move's error toast fired for every status, so a transport fault
surfaced twice on top of req's own dialog. Scoped to 403/404.
- The palette's moved-pointer guard could not judge the FIRST move of a
session and went stale across a close. `e.movementX/Y` asks the browser
directly and needs no state.
- `useKeyScope("contextMenu")` runs once per hook, and the Playlists page
mounts two — so the cheat sheet rendered the chords twice under a
duplicated React key. The scope stack dedupes.
- The `enabled:hover:` sweep missed the half that lives in CSS, so a
disabled glass button still lifted. `.glass-hover:not(:disabled):hover`.
- ShareDialog's migrated buttons passed a colour className that the
variant's own colour class outranks under clsx, so Revoke hovered
neutral and a password-protected link looked unprotected. The colour
goes on the icon, as the Copy button beside them already did.
- `matchesShortcut` took an untyped id and ids are only unique within a
scope, so `back` would have answered for the wrong player. Keyed on
scope + id, both literal.
And the systemic one: round 2 reverted eight of round 1's behaviour fixes
as a single mutation and all 763 tests still passed. Round 1 tested the
three fixes that were easy to test and none of the rest. So the guards
with the widest blast radius are pinned now, each mutation-checked to
fail for its own reason: the global keymap's repeat/allowInInput/disabled
handling (a new dom test), the cheat sheet bound to the real
useContextMenu rather than a scope stub, the scope dedupe, the history
burial, and the Button accessible-name union — whose @ts-expect-error
fixtures turn a widened type into a red typecheck lane.
The chipLane contract test that round 1 rewrote to count per lane counted
the import line as a lane, giving every file one free mirror — so the
exact hole it was rewritten to close was still open. Mutation-checked
both ways this time.
Finishes round 1. Part 1 took the runtime bugs; this takes the prose that
had gone false and the three "the comment says so" gaps behind it.
CLAUDE.md is auto-loaded into every agent session and ui/README.md is
not, so an architecture paragraph that still described a flat components
bag was the most expensive stale sentence in the repo: the next agent
adding a popover reads it, sees no platform layer, and writes the generic
component into components/ — the invisible-layer failure this epic exists
to end. It now names components/ui/ and says the boundary is lint-enforced.
The canonical-patterns table gained the three rows the epic established:
ui/Button over a hand-rolled <button>, a keymap row over a private keydown
listener, and disabled-when-busy vs inert-when-inapplicable.
ui/README.md's inventory was written in this branch's first commit and
never learned about CommandPalette, ContextMenu/useContextMenu,
KeyboardHelp or gridColumns — in a section whose own closing rule is that
a second component painting the same thing is how the last inconsistency
happened. It also claimed "everything is rem-derived" while VirtualGrid's
minCol/GRID_GAP are deliberately px; that is now stated as the exception
it is, and a new section records which promises here are tests rather
than sentences.
Three contracts stopped being prose:
- A text-less Button must carry a name. "Required for iconOnly" was a doc
comment, which is why the migration shipped two nameless icon buttons
through eight green lanes. It is a union now, covering both spellings
(iconOnly, and size="icon"/"icon-sm"), satisfied by label, aria-label or
title. It caught one more on the way in: Banner's dismiss took an
optional dismissTitle, so it could render unnamed — it falls back to
common.close now.
- gridColumns' gutter arithmetic. Every existing assertion survived
replacing floor((w + GAP) / (minCol + GAP)) with the naive
floor(w / (minCol + GAP)): the fixed widths agree, the capped cases are
cap-bound, and the loops compare columnsFor against itself. Pinned at
the exact-fit boundaries where the two first disagree (536 and 828).
- ShareDialog's link row. Only Copy was migrated in S1, leaving one 2rem
button between two 1.75rem ones — and on a coarse pointer .touch-target
grew only the migrated one to 2.75rem while the destructive Revoke
stayed small.
Also: the maxCols docstring said 8 columns of 384px where the test it
shipped with asserts 369.
The epic-end review found 32 verified issues. This is the first fix round:
the three highs, the user-facing mediums, and the tests that were passing
for the wrong reason. Docs and the remaining low-severity tail are next.
The boundary rule was the worst of them. Both `no-restricted-imports`
regexes were written in plain "..." literals, where `\.` is an unknown
escape that collapses to the any-char `.` — so the rule read like a
boundary and matched far less than one. `../../lib/api` (the barrel every
feature component imports) and `../../components/Feed` (the same file
`../Feed` names) both sailed through a green gate. String.raw, a third
pattern for the second spelling, and a vitest over ESLint.lintText so a
rule that silently matches nothing can never look like a clean codebase
again.
closeThenNavigate waited for exactly one reclaim popstate. With a second
Back-closable layer still mounted — a palette opened over the player —
the navigation unmounted that layer too, and its reconcile backed over
the entry the navigation had just pushed: "go to Channels" closed the
player and landed on the feed. It now waits for the LAST programmatic
back, then adopts the entries the navigation buried, and falls back after
250ms rather than hanging if no popstate is ever coming.
The rail's inline rename died on arrival. With no domReference, floating-
ui's returnFocus falls through to a module-global stack and restores some
unrelated element from an earlier popover — blurring the field into its
own commit-and-close, from the second popover of a session onward. The
context menu now owns the restore: back to the row, but only if the row
is still there and nothing else has claimed focus.
Also:
- Backspace under the cheat sheet stopped Plex playback and dropped to
the grid; `back` now defers to any layer above, like `escape` beside it.
- A move invalidated only the source list, so the target kept serving
pre-move contents for 30s while the rail already counted N+1.
- Every move error a user can reach is a 404, which `req` leaves to the
caller — so the row snapped back in silence and the shipped moveErrors
strings were unreachable.
- swallowClick armed on long-press but was disarmed only by the click it
was armed for; any press that produced no click ate the next tap on the
whole list.
- The context menu stayed parked over whatever row scrolled under it.
- overflow-clip on the chip lanes ate the focus ring's top and bottom.
- "Why is this off" rode on disabled rows, which show no tooltip and take
no focus: a new `inert` item keeps the reason readable.
- Shift+F10 was advertised app-wide but wired only on Playlists; it has
its own scope now, and useContextMenu matches through the shared parser
instead of its own literals.
- Mod+K could not close the palette, `h` machine-gunned on auto-repeat,
and both were bound above the branch that mounts their overlays.
- Palette options were in the tab order under aria-activedescendant, and
a scroll-synthesised mousemove stole the keyboard selection.
- The OSD live region was created together with its own text.
- Ending a rename stranded focus on <body>.
- The Button variants' hover halves are `enabled:` — the one migrated
site that had guarded its own lost the guard on the way in.
- HU builtinLocked named the list "Watch later" in a Hungarian sentence.
Tests: the chip-lane contract counted per FILE, so a second hand-rolled
lane in an already-compliant file was invisible; the closeThenNavigate
ordering test only passed on a popstate left behind by its neighbours and
failed alone, so it moved to its own file.
The viewport contract has two ends, and only the floor was enforced. An auto-fill grid
answers "more room" with more columns, never bigger ones, so it settles just above its
minimum column at any width: measured at 3440px, the feed laid out 11 columns of 264px.
A feed diluted across an ultrawide fails the contract as surely as a broken one at the
floor.
Multi-column feed views now carry a `maxCols`; past it the leftover width flows into
the items. At 3440: cards 11x264 -> 8x370, cardsSmall 15 -> 11x264 (still visibly the
dense card, and never as wide as cards gets), tiles 7 -> 6x498. Each cap is set so the
item tops out near 1.5x its own minCol, which is what keeps a view recognisably itself.
A cap can only bind ABOVE the width at which that many columns already fit, so it is a
pure ultrawide affordance — 1024, 1920 and 2560 lay out exactly as before. Verified in
the browser at all three widths and across all five views.
The Channels grid needs no cap: the page's own 96rem --content-mw already bounds it.
Also renames VirtualGrid's `maxCol` to `colMaxWidth`. It was never a column count — it
is the single-column reading-width cap — and `maxCol`/`maxCols` one letter apart is the
kind of drift that has bitten this repo before.
The column maths moves to ui/gridColumns.ts as a pure function so both ends of the
contract are unit-testable without a layout engine; the tests assert the cap's numbers,
that it never binds at the floor, and that the dense card cannot grow into the standard
one.
The four chip lanes render an intentionally unbounded hidden "mirror" so the shared
fit core can read each chip's natural width. An absolutely positioned box still
contributes to its ancestors' SCROLLABLE overflow, and `visibility: hidden` does not
exempt it — and nothing between the toolbar band and <html> clipped it.
At the 1024x768 floor that meant the channel manager's document scroll width was
1258px: the whole app scrolled 249px sideways into dead space and dragged the fixed
header to x=-177. The same mirror was also 228px wide inside the Tags column's 144px
cell, which pushed the table to 990px inside its 906px container — i.e. it broke
DataTable's "the table never exceeds its container" contract and truncated channel
names to "The...". One cause, two symptoms.
The lane now carries a shared `chipLaneClass` = `relative overflow-clip`, so the
contract lives in one constant instead of four hand-rolled class strings. `clip`
rather than `hidden` because it creates no scrollport, so nothing can ever scroll the
mirror into view. Measurement is untouched: offsetLeft/offsetWidth are layout values
and a clip does not change layout.
Measured after: document 1258 -> 1024, sideways scroll 249 -> 0, table 990 -> 921
with no cell overflow, and the "+N" collapse still fits exactly as before.
A source-level test asserts every component rendering a mirror uses the lane class —
the drift a fifth hand-rolled lane would introduce, which no DOM test can catch since
jsdom has no layout engine to overflow.
Reaching a channel three modules away cost a rail, a page and a scroll.
Now it costs a keystroke and half a word: Mod+K searches modules,
channels, playlists, the saved views applicable where you stand, and the
app-wide actions.
Scope agreed with the product owner: navigation + actions, but only the
reversible local ones (the shortcuts sheet, the theme). "Sync from
YouTube" is deliberately absent — it is a long-running job against a
quota, it wants the progress and error surface the Channels page already
gives it, and one mistyped Enter is a worse failure than the walk it
would have saved.
Matching is word-wise and accent-folded, because this is a bilingual app
and nobody reaches for the accent keys in a hurry: "pod thevr" finds
TheVR Podcast and "penzugy" finds pénzügy. The arrows walk one flat
result list so crossing a group heading is invisible, and the field
carries the combobox/listbox aria tree that virtual selection needs.
ui/CommandPalette is domain-free (items are a prop, per the ui/ import
rule); AppCommandPalette supplies them. The shell is Modal again, with a
new `top` placement so a search-first dialog grows downwards instead of
jumping as results change.
Also fixes a bug the palette hit on its first run, and that PlayerModal
had already hit once: closing a dialog reclaims its Back-to-close
history entry with a programmatic history.back(), which undoes any
pushState made before it lands — so a command that navigates only closed
the palette. The rule is now closeThenNavigate() in history.ts with a
test, and PlayerModal's inline copy is gone.
The global `h` moves out of KeyboardHelp into useGlobalKeymap beside
Mod+K, so one listener owns the whole `global` scope and dispatch's
exhaustiveness keeps meaning what it says.
The Plex player had a shortcuts overlay nobody else did, so the YouTube
player's Space/F/arrows and the Plex browser's Backspace were documented
nowhere. ui/KeyboardHelp is that sheet for every surface, opened by a
global `h` - not the conventional `?`, which is a Shift chord on a
Hungarian layout, while `h` is the key this app's reader already has in
their fingers from the player.
It renders the shared table filtered to the scopes that are LIVE: a
surface registers its scope with useKeyScope while mounted, and only
while its keys really do something - PlexBrowse deactivates its own
scope inside the player, where its Backspace no-ops, so the sheet cannot
promise a "Back one level" the handler declines to perform. Same rule
for the `when` rows: ⇧←/⇧→ are listed only inside a series, an answer
the player publishes because the sheet cannot ask.
No new overlay primitive, after checking: Modal already portals into
document.fullscreenElement ?? document.body (so this works over a
fullscreen player), registers an Escape layer, traps focus and handles
browser Back. Third time this epic that the thing was already built.
Shift+F10 is in the table as `handledLocally` - documented and sharing
the chord parser, but matched by useContextMenu on the focused row.
dispatch neither routes it nor demands a handler for it, so the
exhaustiveness check stays honest about what it actually covers.
Also: A/S have cycled the Plex audio and subtitle tracks since E5, but
silently - the only way to see where you landed was to open the tracks
menu, the exact round-trip the shortcut exists to avoid. cycleAudio and
cycleSubtitle now return the track they moved to, and the player flashes
it over the video.
Six surfaces each owned a private keydown listener with its own key
literals and its own idea of "am I typing?", and they had drifted:
PlexPlayer matched `case "a"` without checking modifiers, so Ctrl+A
(select all) cycled the audio track, and its typing guard missed
<select> and contentEditable where the other two surfaces covered both.
lib/keymap.ts is a TABLE, not a dispatcher. Each surface keeps its own
listener and closures - PlexPlayer's refs are not worth re-plumbing -
but the chords, the modifier rules and the typing guard live in one
place, and dispatch() binds a scope's rows to handlers through a mapped
type, so a row without a handler (or a handler without a row) is a tsc
error rather than a comment someone forgets.
Matching is now exact on modifiers, which is what kills the Ctrl+A bug
and lets Shift+arrow be its own row beside the bare arrow. Escape opts
out of the typing guard (allowInInput) so it stays the way out of a
player's own subtitle-offset field.
The player's shortcut sheet renders helpLines() instead of a hand-copied
array beside the handler - the two used to be edited separately, which
is how a sheet ends up documenting a key that no longer exists. Labels
move to the new `keys` i18n namespace (EN + HU), and a test asserts
every row is named in both.
S2's headline was never a new Menu: `ActionMenu` already sits on `AnchoredPopover`
(arrows, typeahead, Escape, anchor-flip). What was missing is an INPUT PATH into it, so
this adds `useContextMenu` + `<ContextMenu>` over the same `ActionMenuItem[]` and the same
`MenuItemList` — a row cannot grow a right-click menu that looks or behaves unlike its own
⋯ menu. Three ways in: right-click anywhere on the row, long-press on touch/pen, and
Shift+F10 / the Menu key. The LEFT button is untouched and keeps the row's own action.
One popover per list, anchored to a virtual element at the pointer (native behaviour, and
a table of 200 rows would otherwise mount 200 floating contexts to keep 199 shut).
Platform bits it needed: `hoverAlso` on AnchoredPopover (a submenu falls open under a
mouse and is tapped by a finger — mouse-only, so a tap's synthetic mouseenter can't race
the click toggle) and `elementRef` on PopoverItem (the submenu anchors to the very
menuitem that opens it, where aria-haspopup/expanded belong). `ActionMenuItem` becomes a
discriminated union — a row either fires or opens a submenu — plus `danger` and
`separatorBefore`.
First consumers, both in Playlists:
- item rows: play · move to another list (a submenu of every other playlist, one gesture)
· to top / up / down / bottom · remove. Reordering goes through the same undoable store
as the drag handle and is disabled under a view for the same reason, with the same
explanation rather than silence.
- rail rows: rename (in the row that was right-clicked, keeping its cover so the rail
doesn't jump) · delete. Both now run through `usePlaylistActions`, which also takes over
the detail header's pencil and trash — the delete dialog's YouTube branch and the
"what gets selected next" rule exist once, not twice.
- the rail gains a "recently changed" sort key off the new `updated_at`. NOT the default:
a rail that reshuffles itself while you look at it costs more than it saves.
The one-step move S2 needs has no honest client-side shape: "add to the target, then
remove from the source" leaves the video in BOTH lists whenever the second call fails,
and the reverse order loses it entirely. So it is one endpoint in one transaction —
POST /api/playlists/{id}/items/{video_id}/move — which re-parents the existing item row
(so `added_at` survives), recomputes `dirty` on both sides, and is idempotent when the
target already holds the video.
Also publishes `updated_at` on the playlist summary, and bumps it explicitly for the
edits that only write `playlist_items` (add / remove / reorder / move). UpdatedAtMixin's
`onupdate` fires on an UPDATE of the playlist row, so without `_touch` the rail's coming
"recently changed" sort would have seen renames and nothing else.
S1c, the last part of S1. The contract now lives in components/ui/ and
in one CSS recipe instead of in ~160 call sites:
- Minimum target 2.75rem, on coarse pointers only (.touch-target). A
32px icon button is fine with a mouse -- precise pointer, hover to
confirm the aim -- and not fine with a thumb, so it grows exactly where
there is no precise pointer, which on this app's real second device is
the tablet. It grows the real box rather than painting an invisible
::after hit area, because two expanded hit areas on icon buttons 4px
apart would overlap and the overlap fires the WRONG button. `link` is
excluded: it is text inside a sentence.
- 2.75rem rather than 44px so the target obeys the text-size slider like
everything else; verified that the type/geometry ladder was already
rem-derived off --font-scale, so nothing else was needed there.
- The ladder is documented as a closed set in Button.tsx, with the site
counts it was derived from, and ui/README.md states the contract.
Measured in the browser: fine pointer 36x36 and 34px-tall buttons
(unchanged), coarse pointer the same buttons at 44x44 and 44px tall.
1024x768 renders identically. 3x tsc, eslint, knip, prettier and 690
vitest green.
Third sweep, 20 more sites: the rest of AdminUsers (the access-request
and invite clusters), Scheduler, SettingsPanel, SetupWizard, About, Feed,
KeyGate, OnboardingWizard -- and, importantly, four platform components
that were still hand-rolling their own buttons: Modal's close, Banner's
dismiss, SidePanel's two toggles and QueryState's retry. A platform layer
that bypasses its own primitive is the same defect one level up.
Hand-rolled buttons with their own geometry: 110 -> 39; Button call sites
37 -> 106. What is left is deliberate, and it is the list Button's own
docstring excludes plus two families this pass refused to force: video
overlay controls painted on white/N, drag handles, chip removers,
accent-solid pills, the bg-card family on the signed-out screen, and
sites whose paint is conditional (`suspended ? warn : neutral`) or
otherwise has no variant yet. Forcing those through a className override
would be unsafe without tailwind-merge, which is exactly the hazard this
sprint documented.
3x tsc, eslint, knip, prettier, 689 vitest green; UAT on Settings and the
About dialog (its close button and full-width CTA are both migrated),
clean console.
Second sweep, 34 more sites: NotificationsPanel (14 -- eight identical
accent text links, three ghost icon buttons, two ghost text actions),
Welcome's five auth links, KeyGate, Messages, NavSidebar, ChatThread,
ShareDialog, ChannelPage, PlexPlaylistView, PlexPlaylistAdd,
PlexCollectionEditor and plexViews.
Moves the font-weight out of Button's BASE and into each variant, so the
`link` variant can be semibold (which is what every text link in this app
looks like) while the action variants stay medium, and no element can end
up with two font-* utilities.
Welcome is the one signed-out surface this session cannot see, so only
its byte-identical link swaps were taken; its primary submit and the
bg-card button family are left for a pass that can UAT the screen.
3x tsc, eslint, knip, prettier, 689 vitest green; UAT on Notifications,
Messages and the nav rail, clean console in a fresh tab.
Counting the un-migrated call sites showed the primitive and the app had
drifted into two different looks: the majority of hand-rolled buttons
hover to accent (outline) and fill with card (ghost), while Button
hovered to fg and filled with surface. That split is the inconsistency
this epic exists to remove, so the primitive follows the majority rather
than the other way round -- 16 existing call sites shift slightly, ~90
hand-rolled ones stop being special.
Adds the variants the call sites prove are missing (outline-accent,
outline-danger, outline-warn, link), an icon-sm size for the p-1.5 icon
family, and a size=none so a link owns no button padding; renames
danger-outline to danger-soft so it cannot be confused with
outline-danger (red at rest vs red on hover). Two new tests lock the
link-size default and the rule that a variant never adds a second
font-weight utility -- without tailwind-merge, two font-* utilities are
decided by stylesheet order, not class order, so that is a real hazard.
Migrates AdminUsers, AuditLog, Channels, TagManager, Playlists,
PlaylistsRail and SettingsPanel (Button call sites 37 -> 52). Left alone
deliberately: conditional-paint sites, video-overlay white buttons, drag
handles, chip removers and card-click targets -- the families Button's
own docstring excludes.
3x tsc, eslint, knip, prettier and 689 vitest green; visual UAT on Users,
Access requests and Playlists at 1280x900, no console errors.
The app HAS a platform layer -- AnchoredPopover (22 importers, with
arrows/typeahead/Escape/anchor-flip), QueryState (21), Modal (17),
PageShell, DataTable, Tabs, Tooltip and ~26 more -- but it sat flat among
~160 feature components with nothing marking it as one. Several of those
files describe themselves as 'the one place' or 'canonical' and were
still bypassed by hand-rolled markup, because a layer nobody can see is a
layer nobody uses; three separate inventory passes of this codebase
misread it the same way.
Moves the 33 domain-free modules into components/ui/, rewrites the
imports in 89 files, and makes the boundary a lint rule rather than a
convention: inside ui/ a one-level-up import (a feature component) and
any lib/api slice are errors. The rule was verified to fire on both and
to leave generic lib/ helpers alone. README.md states which side of the
line a component belongs on and why.
No behaviour change: 3x tsc, eslint, knip, prettier and 687 vitest tests
all green; no backend file touched.
Round five. 47 -> 34 -> 15 -> 7 -> 6 raw findings, and this round's sharpest was the
loop's own blind spot: **the branch did not pass the gate.** `prettier --check` is one of
its eight lanes and I had been running the other seven, so an unformatted `onError` from
round one sat on the branch for four rounds — `siftlode publish` would have refused it,
with no --skip flag to get past.
**The restart hint could still strand the restored data.** Round four said the scripts no
longer print a command they cannot resolve; they did. The COMPOSE_FILE fallback only fires
when `docker-compose.yml` is ABSENT, so in a source checkout it never fires and the hint
expanded to a bare, pasteable `docker compose up -d` — which reads the other file, whose
volumes are `pgdata`/`downloads` rather than `siftlode_pgdata`/`siftlode_downloads`, and
recreates the database on an empty one. When the file is unresolved the scripts now print
`-f <the compose file you started with>`: not runnable, which is the point.
**A comment naming the wrong owner.** `formats.py` still said a zero budget is "a refusal
for enqueue to make" — the premise round one disproved, three files away from the two
docstrings that now say the opposite. Anyone acting on it would delete the worker's refusal
and re-open the original inversion, so it now says where the refusal actually lives.
Also: the reset-flag rule was rewritten in three consecutive rounds and pinned by nothing —
two tests now cover a fragment losing its token, both by a pasted verify link and by
navigating away entirely; `backup.ps1` deletes a partially-copied dump before throwing, the
guard round four gave the docs but not the script it mirrors; and one comment that read as
if the asset were untouched by a line four below it.
Gate green, all eight lanes: ruff, ruff format, 3x tsc, eslint, prettier, knip,
vitest 687, pytest 950.
Round four, on round three's diff. 47 -> 34 -> 15 -> 7 raw findings.
**The printed restart command could strand the restored data.** Round three replaced
`start` with `up -d` because `start` ignores a newly pinned tag — but printed it bare. In
the git-clone layout BOTH compose files exist, so the scripts' COMPOSE_FILE fallback never
fires and a bare `docker compose up -d` reads `docker-compose.yml`: different volume names,
locally built images, no IMAGE_TAG at all. `stop` and `exec` had been fine there (they
resolve running containers by project and service), but `up -d` RECREATES — so the operator
pastes the hint straight after a successful restore and gets `db` on a new empty volume and
a first-run setup wizard, with their data in an orphaned volume. Verified: the two files
resolve `siftlode_pgdata` vs `pgdata` and different images.
The scripts no longer print a command that recreates from a file they did not resolve. They
name the compose file when they resolved one, and otherwise say to start it the way the
operator normally does — with the caveats on their own lines rather than in a parenthetical
at the end of a very long one.
**The reset flag is now decided where the live hash is already parsed.** Round three cleared
it in the token effect, which only runs when a NEW verify token arrives — so the back
button, or re-pasting an already-consumed verify link, still left the banner promising a
recovery whose token was gone. Setting it from the hash on every fragment change is both
smaller and closes those.
Also: the doc said `start` "still carries the old tag" four lines after "pin the older
IMAGE_TAG", where "old" then reads as the tag being rolled back TO — it now reuses the
scripts' unambiguous wording; the Windows recipe removes a partially-copied dump before
throwing, so a mid-stream `cp` failure cannot leave a truncated file under the final name;
and `$WarningPreference` is pinned beside `$ErrorActionPreference` so an inherited
SilentlyContinue cannot drop the half-restored warning, with the comment corrected to say
what the warning stream actually is.
Gate green: vitest 685, pytest 950.
Round three, on round two's diff. The trend across the loop is 47 -> 34 -> 15 raw
findings; the ops half kept producing the sharpest ones because the gate cannot run it.
**`docker compose start` ignores a newly pinned IMAGE_TAG.** Round two removed the
automatic restart because it re-ran `alembic upgrade head` — and then told the operator to
run that exact command by hand, in both scripts and the docs. `start` relaunches the
container that already exists, which still carries the tag being rolled back FROM, so
pinning 0.79.0 and following the printed instruction brings 0.80.0 back up and re-applies
its migrations. Verified against a real daemon: after changing the pinned tag, `start` kept
the old image and only `up -d` picked up the new one. Both scripts and the doc now say
`up -d`, with the pin-first caveat where the operator reads it.
**The reset flag outlived its token.** Reverting round one's "each link clears the other"
went one step too far: a `#verify=` link pasted over a standing reset notice replaces the
whole fragment, so the reset token is gone — but `resetPending` stayed true, and being
checked first it hid the confirmation while promising a recovery that signing out could no
longer complete. The flag is now cleared when the fragment it describes is gone, which is
narrower than what was reverted and does not blank the signed-out card.
**A test that could not fail.** The shared-asset regression test seeded the asset as
`pending`, and `_reset_asset_pending` only acts on `downloading` — so the release half was a
no-op and deleting it kept the suite green. Seeded as the worker actually leaves it now.
Also: the malformed-URL guard answers with urlguard's structured detail rather than a bare
English string, so both halves of one typo class get the same localized copy, and the third
site in that module handing user input to `urlparse` was swept with it; the asset is
released after the job is terminal, not before; the Stats 403 toast gets the wizard handle
its own comment claimed it had; the pasted Windows recipe sets `$ErrorActionPreference`
inside the block (a scriptblock inherits the caller's, so a `SilentlyContinue` profile
swallowed every throw) and cleans up the container-side dump on the failure path too; the
restore warning moved to the warning stream so a `> log` redirect cannot file it away; and
two comments narrowed to what the code does.
Gate green: vitest 685, pytest 950.
Round two of the review loop, on round one's own diff. Two of its findings were
regressions the fixes themselves introduced, which is exactly what the loop is for:
**A per-user refusal was writing onto a shared row.** MediaAssets are deduped across
users, and the generic failure handler puts the message on the asset — right for an
upstream failure, wrong for "your storage is full". Two users queuing the same video share
one asset, so the first user hitting their cap failed the second user's job with the first
one's private reason, telling someone with an empty library to free up space. The refusal
now raises `_OwnerRefused`, caught separately: the job fails, the asset goes back to
`pending` for whoever else holds it. Pinned by a test that drives `_download` directly —
the refusal happens before extraction, so nothing needs stubbing.
**The restore scripts restarted the app, undoing the restore.** `docker compose start`
re-runs the container command, and that runs `alembic upgrade head` — so on the rollback
path this documents as recommended (restore the dump, THEN pin the older tag) the newer
image immediately re-applied the migrations the restore had just undone, and on a FAILED
restore it put the app back on a half-restored schema. Both are the opposite of why the
stop was added. Nothing is restarted automatically now; the scripts say what to run and
when, and the docs' snippets say the same.
**Reverted rather than patched:** the OnboardingWizard toast (the wizard already renders
that failure inline, next to a Retry button — three recoveries for one failure), and
`useVerifyEntry`'s "each new link clears the other". The latter blanked the signed-out
card's only banner, where nothing renders the reset flag to replace it. The two states are
independent again; what changes is that `dismiss` peels ONE layer, which is what the
original finding was actually about.
Also: `Stats` uses the YouTube helper rather than the bare one, so the route's English 403
detail no longer beats the translated fallback under a Hungarian UI; `urlparse` guarded
alongside `.port` (an unclosed bracket raised just as late), the reason renamed `malformed`
since "bad port" misdescribes it; three docstrings narrowed to what the code actually does
(the worker's byte check is per-job, not an aggregate ledger, and not on the edit path;
the redirect residual leaks a reachability oracle, which the note had denied; the
invalidation helper does not cover suspend); the pasted Windows recipe wrapped so `throw`
can stop it; the bash one-liner's failure branch no longer exits 0; and the tests that were
missing for the round-one fixes — the account panel's error paths, the invalidation set,
and the shared-asset case above.
Gate green: vitest 685, pytest 950.
The deep review of the branch raised 47 findings, 18 of which survived adversarial
verification. Grouped by what they actually were:
**The size ceiling inverted at its boundary.** `remaining_bytes` clamped to 0 for a user
already at their storage cap, and `build_ydl_opts` reads 0 as "no limit" — so the one
user over quota was the only one downloading with no ceiling at all. `check_enqueue`
could not catch it: it ran while the job was queued and the user was still under the cap.
The arithmetic moves into `quota.remaining_bytes` (where every other max_bytes decision
lives, and where it can be tested), and the worker now fails the job outright rather than
proceeding unbounded — as a non-retryable `quota_exceeded`, since retrying cannot help
until space is freed.
**Silent failures in the new account panel.** All three actions went through `quiet: true`
endpoints, which suppress the global error dialog on the promise that the caller reports
inline — with no `onError`, that promise was broken and a refusal rendered as nothing. On
the broken-SMTP instance this panel exists for, an admin would read silence as "the mail
went out". The co-invalidation set and the email-off predicate move to `lib/adminUsers`,
shared with the pending-access queue, so the two copies of these mutations cannot drift
apart again — which is how half of this was lost in the first place.
**A rollback recipe that destroyed data without working.** `alembic downgrade -1` steps
back one migration, but a release routinely ships several (0.80.0 shipped four): it drops
the newest one's columns and leaves the schema at a revision the older image also cannot
resolve, i.e. the crash loop it was meant to escape, minus the data. The dump restore is
now the recommended path, and the alembic route asks the target version for its own head
revision instead of counting backwards.
Also: redirects named in the urlguard docstring beside the DNS-rebinding residual they
share a fix with (SEC-17); a malformed port refused as `bad_port` instead of escaping as
a 500; the pull cooldown extended to the sibling route spending the same QuotaAction;
restore stops the app before dropping its tables; the PowerShell cleanup can no longer
decide the script's verdict on 5.1, nor report the wrong exit code; the documented
one-liners hardened to match the scripts; the registry's retention window stated where
pinning is advertised; the two banners made mutually exclusive so neither can shadow the
other; `hover:bg-hover` (a utility this project never generated) replaced by the
`selectable` recipe; the clipboard helper reused; and the sync cooldown's copy swept to
the two callers that discarded it.
Verified: gate green (vitest 678, pytest 949), and the panel, the row hover and the reset
banner UAT'd on localdev.
Design option C, chosen by the user 2026-09-05. Nothing in the admin UI
ever showed an account's GATE STATE: an admin could see a "Pending
approval" chip but not whether the address was confirmed, whether SEC-10
had cleared the password, or which Google account was attached — so
every support conversation started by guessing. Clicking a row now opens
a drawer that says which gate the person is actually stuck behind, with
the recovery actions under that readout rather than in the row.
The drawer is `Modal placement="drawer"`, not a second dialog: the layer
stack, ESC, the focus trap, Back-to-close and the scroll lock are the
hard parts, and duplicating them is how two shells drift apart. It
slides in, and honours prefers-reduced-motion like the rest.
The link itself (SEC-15, SEC-16, and the everyday "no SMTP and someone
forgot theirs") reuses the existing token machinery and the SAME
`#reset=` entry the mailed link uses, so the signed-out card consumes it
with no new flow. It is a distinct token KIND for one reason: a mailed
reset arrives in the mailbox, so completing it proves ownership and
confirms the address; an admin-issued link proves nothing about the
mailbox — the admin hands it over by whatever means they choose — so it
must not satisfy the email gate. Claiming otherwise would be the same
false attestation SEC-15 is about.
Issuing does not revoke the current password: a misclick must not lock
anyone out. USING it rewrites the hash and bumps the session epoch,
which is what evicts a squatter — SEC-16's actual remedy. Spending
either kind burns the siblings of both, so a completed reset cannot
leave a live admin link behind. The non-consuming probe learned the new
kind too, or every admin link would have opened onto "this link is
spent". The audit log records that a link was issued, by whom, for whom
— never the link, which is a live credential-setting secret and the last
thing that belongs in a long-lived log a human reads.
Not gated on account state on purpose: it is useful for a pre-empted
address, a revoked password, a Google-only account that wants one, and a
plain forgotten password. Narrowing it would only make it refuse exactly
when the admin needs it. The shared demo account is the one exception.
Verified on localdev, both languages, end to end: issue from the panel
-> the signed-in tab shows the honest "reset links open in a signed-out
window" banner with the token still in the address bar -> signing out
draws the "Set a new password" form, which is only possible because the
probe recognises the new kind. Gate: pytest 925 -> 942, vitest 676.
DX-17, three one-liners the epic's canonical sweep did not reach:
`routes/setup.py::setup_admin` still resolved the wizard's admin by
literal `User.email ==`. The wizard can be re-run, and E9.5's premise is
that two spellings of one Gmail mailbox are one person — so a second run
under the other spelling would have created exactly the duplicate admin
row the epic exists to prevent.
`routes/admin.py::_decide` hand-rolled `db.get` + 404 where the file's
newer endpoints use `get_or_404`. There were TWO such sites, not the one
the review named; both are converted, per the house rule that a
principle gets fixed everywhere in one sweep.
`useVerifyEntry`'s `hashchange` listener recognised `#verify=` but not
`#reset=`, so pasting a reset link into a tab already running the app
was silently ignored — the same silence that made the verify half a bug.
It now notices, and says out loud that reset links need a signed-out
window; the fragment is still deliberately left in the address bar
(review round 4), so signing out from that tab recovers it.
DX-16: `.catch(() => setOutcome("invalid"))` could not tell a spent
token from a 502 mid-deploy or a dropped connection, and by then the
fragment is cleared — so a network blip was reported as "that link is no
longer valid", pushing the user to request a fresh one on the strength
of a false statement. Only a 400 carrying {code:"verify"} is the
server's verdict now; everything else gets its own outcome, with no
resend CTA, because the token is untouched and retrying it works. The
rule was already written one file away for the reset-link probe: "a
network blip must never be cached as 'this link is dead'".
Two existing tests asserted the old conflation by rejecting with a bare
Error("400"); they now reject with the real HttpError. The errors are
built inside the rejection, not hoisted: mountAt calls vi.resetModules(),
so an HttpError imported earlier is a different class identity and
`instanceof` would quietly miss.
Gate: vitest 671 -> 676, pytest 924 -> 925.
SEC-12: the download box accepts an arbitrary http(s) URL and yt-dlp fetches
it from the WORKER, so any approved user could aim the server at the compose
network, the LAN or a cloud metadata endpoint — and read the result off
job.error, where "connection refused" vs "HTTP Error 401" vs "timed out" is a
port scanner. downloads/urlguard.py refuses a URL whose host resolves to a
loopback/private/link-local/reserved address, at enqueue AND again in the
worker (DNS can move in between, and the worker is where the socket opens).
A network-class failure on a `url` job now reports one fixed sentence instead
of the raw transport text; the error CODE is still classified from the
original, so the localized explanation is unchanged. DNS rebinding is
explicitly NOT closed — that needs the connection gated inside yt-dlp — and
the module says so rather than implying more than it does.
SEC-13: `noplaylist` only demotes a video-inside-a-playlist URL to that video,
so a URL that is purely a playlist or channel still expanded into every
entry — one enqueue, unbounded downloads. `playlist_items` pins it to one.
Size was unbounded too: `check_enqueue` compares the CURRENT footprint against
the cap, and a download's size is unknown until it lands, so one job could
overshoot by any amount; the owner's remaining budget is now handed to yt-dlp
as `max_filesize`, applied AFTER the adapter merge so no source adapter can
raise a limit the quota layer set. A join's cut-list is capped at 100: an
accurate join emits two filters per segment into one ffmpeg filter_complex.
SEC-14: the YouTube budget is instance-wide, so one user holding down a
manual sync spent the day for everyone. Per-user (not per-IP — the cost
belongs to the account) 3-per-10-minutes limiters on the two bulk pulls,
subscriptions and playlists, with separate budgets so one does not close the
other. The review also listed /rss, /backfill and /enrich; E3 deleted them,
and search stays unlimited on purpose — it is interactive, not a bulk pull.
Gate: pytest 896 -> 924 (none skipped), vitest 671.
DX-10: the repo invited people to clone, build and self-host while carrying
no LICENSE at all — which legally means all rights reserved, so none of that
was permitted. Adds the verbatim GNU AGPL-3.0 text, the SPDX id in
package.json and a README section. AGPL rather than MIT deliberately: this is
a server application, so the case worth covering is someone running a
modified copy as a network service.
DX-12: .env.example documented PLEX_MEDIA_HOST_PATH -> /plex-media and the
README advertised the Plex library, but only the localdev and home compose
files carried the mount — on both PUBLIC files the module could read Plex's
catalogue and then fail to open a single file, because it streams the media
itself rather than proxying Plex. Both api and worker now mount it read-only
with an empty named volume as the fallback, and self-hosting.md gains the
section that explains the server URL/token, the path map and the mount
together (it had never mentioned Plex).
DX-15: the worker had no init: true on either public file, though localdev
and home both carry tini with the reason written out — without PID 1 reaping,
a watchdog hard-kill of a download's process group orphans its ffmpeg/yt-dlp
grandchildren.
Verified: 'compose config' resolves on both files, the Plex mount comes out
read_only: true on api and worker, and init lands on worker (not just the
bgutil-pot sidecar). Gate green: vitest 671, pytest 896.
DX-13: both shell scripts ran bare 'docker compose', which has no config
file to find in the layout docs/self-hosting.md tells people to use (only
docker-compose.selfhost.yml is downloaded) — they now fall back to it, while
a source checkout keeps resolving docker-compose.yml as before. The two
PowerShell scripts moved the dump through the PS pipeline, which corrupts
binary on 5.1 and cannot express -Encoding Byte at all on 7 ('Byte' is not a
supported encoding name), so they simply failed there; they now write inside
the container and 'docker compose cp' it across. backup.sh also wrote its
output straight into backups/, leaving a truncated file that looks like a
real backup when pg_dump fails — it writes a .part and promotes on success.
Both restores report a non-zero pg_restore rather than printing 'Restored'
over it. Docs now state that a backup is the dump PLUS .env: without
TOKEN_ENCRYPTION_KEY every encrypted secret in the restored database is
unreadable, which the old one-line 'back up the volume' never said.
DX-14: 'latest' moves with every release and migrations run on every boot,
so self-hosters had no way to choose when they move and no way back. Docs
gain IMAGE_TAG pinning, a rollback recipe (image-only vs schema-bearing) and
the migration-failed-at-boot case; CHANGELOG.md is generated from
releaseNotes.ts so the file cannot drift from what the app shows, and
'siftlode release' refuses on a stale one rather than regenerating it into
the clean tree it just demanded.
Verified: dump + restore round-trip on both platforms against localdev into
a scratch database (8 users, 262822 videos, schema 0082_invite_lang), and
IMAGE_TAG=0.80.0 resolves on both services in 'compose config'.
Release notes for the epic: bilingual mail, one-mail-one-click approval, order-independent gates
with named states, canonical Gmail identity, the admin lifeline for sign-ups stuck outside, and the
four-round closing review that converged 12 -> 4 -> 3 -> 2.
Round 4 came back converged — nothing above low, and both findings in one family. This is the half
worth fixing, because the branch introduced it.
Only the signed-out auth card consumes a `#reset=` token, and S2 moved the address-bar cleanup up to
a hook App mounts unconditionally. So opening a password-reset link in a browser that already holds a
session erased the token from the URL while nothing had read it: the page loads, nothing is said, and
clicking the link again does exactly the same, because the token is untouched and the session is
still there. That is the dead end this epic exists to remove, and `main` did not have it — the strip
used to live inside the card, which a signed-in visitor never renders.
An unspent reset fragment now stays, which restores the recovery that shape always had: signing out
reloads with the fragment intact and the form is waiting. Once the card has spent the token a later
reload meets S4's named dead-link screen, which is true. The affirmative version — a banner telling a
signed-in visitor that reset links open in a signed-out window — is logged with DX-15, along with the
`hashchange` half that still only recognises `#verify=`.
Three low findings, all in what the review rounds themselves wrote.
The comment on `test_approving_an_unverified_account_keeps_its_password` still asserted the argument
rounds 1 and 2 refuted — that dropping the approval-time revoke was safe because `password_login`
reads `email_verified` first — while the docstrings on the two functions it describes now say the
opposite. Two contradictory "why" records for one rule is how the round-1 takeover primitive got
reasoned into existence in the first place, so the test comment now carries the corrected account
and names SEC-16.
`test_a_later_registration_leaves_a_link_already_in_flight_alive` asserted a subset relation that is
vacuously true on an empty set, and the guard that made it non-empty had gone out with the revert.
It now pins both halves: the old link survives, and exactly one new one appears.
Round 2's own fix — activation moved to after a successful send — was unpinned; putting it back
above the send left the suite green. The failed-invitation test now also asserts the account was not
activated by a request the UI reported as a failure.
Round 1 fixed the address pre-emption chain by letting a later registration replace the password on
an unverified account and delete its outstanding verify tokens. Round 2 read that code and showed it
was worse than what it fixed, so it goes back out.
It closes nothing. The rule is symmetric and an attacker can simply register last — on a timer,
inside the five-per-five-minutes budget — while the owner registers once. What it does add is two
primitives an unauthenticated caller did not have: overwrite a real registrant's chosen password,
and delete the confirmation link already sitting in their inbox (the approval mail's 7-day token is
an ordinary verify token, so the blanket delete took that too). Worst of all it fired on the rows
SEC-10 left stripped — password_hash NULL, password_revoked_at set, active — installing an
attacker's password where the entire state means "there is none", and clearing the stamp that is the
only reason password-reset still accepts them. That is a straight takeover which did not exist
before, in the code written to prevent one.
The owner's recourse needed no new primitive and already worked: password-reset accepts a
pre-empted account, proves the mailbox, rewrites the hash and bumps the session epoch. What remains
is the bounded window between a confirmation click and that reset, which is SEC-16 — logged with
this attempt written down, so the next person does not reach for it again.
Two smaller round-2 findings go with it. `upsert_pending_invite`'s new exact-spelling tie-break ran
before any status preference, so it could deterministically pick a DENIED sibling over a pending one
and reopen it — the very outcome its comment claimed to prevent; the status preference now comes
first. And `send_invitation` activated the account before the send it might have to report as
failed, so a request the UI called a failure had quietly let someone in; activation now follows a
mail that actually left. The verify token minted to render that mail still commits either way — it
is single-use, expires on its own and was mailed nowhere, and the note says so rather than leaving
the next reader to wonder whether it was missed.
Eleven findings survived adversarial verification; these are the fixes.
**Address pre-emption.** S1 dropped the approval-time password revoke, arguing `password_login`
refuses an unverified account before it reads `is_active`. Two verifiers showed that holds only
while the address stays unverified — and S6's approval mail exists to get the real owner to end that
state, at which point the squatter's password works. While an address is unverified the NEWEST
registration now owns it: the password is replaced and every outstanding verify token deleted, so
the owner takes the row back simply by registering, and a link already in flight cannot confirm it
for anyone else. The residual (an owner who confirms and neither registers nor resets) is logged as
SEC-16 beside SEC-15, where the re-attestation path it needs already lives. The comments that
asserted the old, false invariant now say what actually guards this.
**The entry URL.** S2 moved `clearEntryUrl()` out of the signed-out auth card into a hook App mounts
unconditionally, and it blanket-stripped the whole query string, the fragment and `history.state` on
the FIRST commit. That broke first-run setup outright — the wizard mounts only after its query
resolves and then finds no `?token=` — and silently ate `?link=`/`?oauth=` (no toast, no refetch
after connecting Google), `?v=`/`?chan=` deep links, and scroll restoration. It now clears only the
fragment and the two one-time flags it owns, and preserves `history.state`.
**The env allow-list.** S3 canonicalised the Invite branch of `is_allowed` and left the
ALLOWED_EMAILS/ADMIN_EMAILS fallback matching literally — the 2026-09-03 lockout with a different
source of truth, plus a shadowing trick where an unauthenticated request under a sibling spelling
permanently masked an env grant. Canonical on both sides now, and the same for the delete dialog's
warning and the ADMIN_EMAILS role bootstrap.
**Honest states.** `send_invitation` stamped "I already wrote to them" before a fail-soft background
send; it sends inline and records only what left. The admin-side resend answered `{ok: true}` for a
dropped mail; it now says so. Both also activate/serialize consistently with the other decide paths.
**Correctness sweep.** The 7-day approval link said "expires in 168 hours" — durations render in
days once there is more than one. `_register_account`, `upsert_pending_invite` and `add_invite`
picked among canonical siblings with no ORDER BY and no exact-spelling tie-break, so which account
got the re-send, and whether a denied row was reopened over a pending one, was up to Postgres.
`purge_user` deleted every canonically-equal invite, revoking a surviving sibling account's access.
`ConfirmProvider` wrapped its ReactNode message in a `<p>`. Approving no longer leaves the
stuck-sign-ups list stale. The refusal panels announce and take focus.
**Tests.** Three mailers rewritten this epic never rendered in any test; every `send_*` now renders
in both languages. The orphan-key guard could not fail (it scanned the catalog that defines the
keys) and had no reverse direction. Newly pinned: canonical activation, the surviving sibling's
grant, a failed invitation and resend, an expired reset link, a throttled login's silence, the
per-language admin fan-out, and that the entry URL keeps what it does not own.
Twelve mails carried hardcoded English while the app itself has enforced HU/EN parity since E5.
A mail is the one user-facing surface nobody can re-render by switching the UI language: it arrives
once, in whatever language we picked, and stays that way. That made the epic's own headline mail —
"one mail, one click" — English for a Hungarian reader.
The text moved to a catalog (`mailtext.py`) with a `text(key, lang, **vars)` renderer, and the parity
rule is now a test rather than a habit: every key must carry both languages AND the same
`{placeholders}`. A translation that drops `{link}` sends a mail with no link in it; one that invents
a placeholder raises inside a background task nobody is watching. Both are silent in production.
Every `send_*` takes the language as its second argument, resolved by the REQUEST handler and never
inside the mail — these all run as background tasks, after the request's session is gone, which is
the same reason the approval mail computes its link in the handler.
Where the language comes from, in order: the account's own preference; failing that, the language the
person asked us in; failing that, English. The middle one is new — `invites.lang`, migration `0082` —
because the mails an access request produces go to an address that usually has no account yet, and
the signal does exist: the page their form was submitted from, or the locale Google reported for a
sign-in we refused. Captured rather than derived, for the third time in this epic (`google_email` in
S3, `preferences.language` in S6 taught the same lesson): a signal that exists only in one moment
cannot be backfilled later. So the admin is asked in exactly one place — the invitation to an address
they typed in themselves, where nobody has ever told us anything.
That send is now a dialog rather than a bare click, which is also where the "you already wrote to
them" warning belongs — it used to live only in a tooltip the admin may never hover, guarding the one
action whose failure mode is mailing a real person twice.
S6's registration `lang` field turned out to be dead on arrival: the backend accepted it, the
frontend never sent it, so every account has been storing nothing. The request builders now stamp the
UI language themselves — a signal that has to be passed in by every caller is one every caller can
forget, which is exactly how it shipped unset.
Admins are notified one mail per language group instead of one blast to everyone. They are people
with their own UI language; a single message would have to pick one of them and be wrong for the rest.
Also removes a duplicated `invited_at` column declaration on `Invite` (two identical definitions
landed in S6).
The funnel's common ordering made a person open TWO messages: one saying their access was approved,
another asking them to confirm their address. The approval mail now branches on what they actually
still have to do — carry the confirmation link, point at sign-in, or say a confirmation mail follows
once they register — so the usual case is one mail and one click. A mail that guesses wrong is worse
than a plain one: telling someone to sign in while their address is unconfirmed contradicts the
screen that will refuse them.
That embedded link lives 7 days rather than the usual 24 hours. Nobody sits waiting for an approval —
it can easily be read on Monday after a weekend, and an expired link spends the one-mail promise and
puts them back on two. The risk is small: the token only ever confirms an address; it signs nobody in
and sets no password.
The most likely root cause of the 2026-09-03 report that a confirmation mail "never arrived" is here
too, and it was never a delivery failure: re-registering an address that already had an unconfirmed
account returned in SILENCE — same cheerful "check your inbox", no mail sent at all. It now re-sends
the link. Nothing new leaks: the response and its timing are unchanged, and the public verify/resend
already does exactly this for the same input.
Whitelisting stays silent, and that is a decision, not an omission. The operator opens a door as a
possibility — usually already agreed with the person — and a row can sit approved for weeks with
nobody registering; a mail fired automatically there would be nudging someone who never asked.
Telling them is its own act on its own button, `invited_at` records it so a second click is a
decision rather than an accident, and the button only appears where nobody is behind the address yet:
"the door is open, come in" is meaningless to someone already inside.
Two things UAT caught that no test would have. Every fresh registration appeared in BOTH admin lists
at once, because the stuck-sign-ups filter keyed on "unconfirmed" — the rule is now "the address may
sign in, and this account still can't", which leaves an undecided request with the queue that owns
it. And the invitation button was offered on every approved row, half of which are existing users.
Also: the confirmation mail finally says what its link's rules are (once, 24 hours) — the reset mail
always has, while this one left the reader to discover both by meeting a dead link. The card's
"didn't get the email?" moves up under the form, and the three waiting messages name the spam folder,
which is where a first message to a fresh recipient sometimes lands.
Registration now records the language the sign-up page was in. Nothing reads it yet — the bilingual
templates are S7 — but it is the only moment that choice exists anywhere the server can see, and a
preference nobody recorded cannot be backfilled later.
UAT on localdev: re-registering re-sent the link (one token became two, one account); approval issued
a 7-day token and moved the account into the stuck list with only a re-send; whitelisting mailed
nobody; the invitation recorded itself and the row then warned that sending again would write twice.
Test data removed.
Approve an address BEFORE the person registers and their sign-up produces no queue row at all —
`_decide` only takes a still-pending invite — so the Access-requests screen sat empty, the tab badge
read zero, and the only working move was re-typing the address into the whitelist box. Nobody would
guess that, and on 2026-09-03 nobody did.
This is roadmap UX-25, where two earlier attempts were reverted after review. Both moved AUTHORITY:
auto-activating on registration turns a standing whitelist entry into a bearer grant for whoever
registers the address first, and reopening the approved invite to `pending` lets an anonymous sign-up
REVOKE a standing approval. What was actually missing was visibility, so this changes nothing about
what an unauthenticated caller can cause — the admin could already do exactly this by re-entering the
address; they just had no way to know they had to.
The section lists every account that cannot sign in yet and says WHICH gate is shut, because the two
dead ends look the same from the admin's chair: waiting for activation, or waiting on a confirmation
mail. Naming the unconfirmed ones is not decoration — on a no-SMTP instance that is the difference
between the owner and whoever registered the address first (SEC-15), and an admin about to let
someone in should see it. A person the queue above already covers is deliberately NOT listed here;
two buttons for one person is worse than one.
Activate is gated on `is_allowed` — the same function the sign-in paths consult, in core, so the
button that appears and the badge that counts cannot disagree. Without that gate it would
manufacture an inconsistent account: password sign-in only checks `is_active` and would let them in,
while their Google sign-in would be turned away and silently reopened as a fresh access request.
Letting in someone with no approved invite stays the whitelist box's job, which says so out loud.
The badge counts activations SEPARATELY from pending invites rather than widening an existing
field's meaning, and only counts what genuinely waits on the admin: an unconfirmed account waits on
its owner, so it is listed with a re-send but does not nag. The admin-side resend says plainly what
happened, including refusing when the instance has no working SMTP — mail is fail-soft everywhere
else, and that silence is exactly what left the 0.78.5 sign-ups with nothing to fall back on.
`send_verification_link` is now one function with three callers (registration, the public resend,
this one), so the link's shape and TTL cannot drift by who triggered it.
UAT on localdev, both languages: the UX-25 shape reproduced end to end (approve the address, then
register), the badge counting it, Activate letting them in and dropping the badge, the row losing
its Activate but keeping the re-send, a fresh token issued by the re-send, and both actions in the
audit log. Test data removed.
Reported from UAT: delete an account, then open its password-reset link. The card drew the full "set
a new password" form — because a `#reset=` fragment was present, and nothing more — and only after
the user had typed a password did the server answer 400. Measured first: no security hole. A user's
tokens cascade away with the row, so nothing can be set on a deleted or a re-created account. What
was left is a dead end of exactly the kind this epic exists to remove, and it is not rare: a reset
link lives one hour, mail is slow or lands in spam, and whoever got two mails clicks the older one.
password-reset/check answers whether a link is still usable WITHOUT spending it, so asking can never
cost the user their one real attempt — the property the tests pin hardest. It gets its own limiter,
because opening a link must not eat the budget for requesting one, and a throttled caller is told
"ok", never "dead": this check may only ever save someone a wasted form, so when it cannot answer it
degrades to the old behaviour instead of declaring a live link finished. The confirm endpoint stays
the authority, and its failure raises the same named screen, since the token can die between the two
calls or the probe itself may have failed.
A too-short password is deliberately NOT that screen. The length check runs before the token is
spent, so a typo costs the link nothing, and the card tells the two 400s apart by `reason` so only
one of them takes the form down.
Both email-link confirmations (verify, password-reset/confirm) now answer in the {code, reason}
shape S4 introduced. They were the last refusals a user meets by simply clicking an old link while
still being answered in hardcoded English under a bilingual UI. The seven remaining bare strings in
this module (register, request-access, the demo guards, set-password) sit behind client-side
validation the UI does first; they are logged as a follow-up rather than swept in silently here.
One bug found by the UAT itself, in the new code: the check went out FOUR times per opened link —
StrictMode double-invokes effects and the shell remounts the card as the session probe settles.
Harmless per request, but against a per-IP limit it would exhaust the budget in two reloads and then
silently fall back to drawing the form unchecked: nothing errors, the feature just stops working.
Deduped in a module-level map holding the in-flight promise (a ref dies with the remount that causes
it); a failed probe drops itself so a blip is never cached as "dead". The test renders the card
twice, because with one render the guard would rot undetected.
UAT on localdev: the reported scenario end to end (delete the account, open the link) shows the named
screen and no form; a live link still opens the form and completes; the CTA lands on the request-a-
link form; one probe per opened link. Throwaway accounts removed.
"Your account is awaiting admin approval." was true and useless. It never said WHICH mailbox the
account was on, and on 2026-09-03 that silence hid a Gmail alias mismatch for a day: a registration
as np.temu@gmail.com met a Google claim of nptemu@gmail.com, and every screen in the funnel agreed
to say nothing about it.
Every refusal password-login can answer with now uses the codebase's structured {code, reason}
detail, built in one helper. The three that arrive AFTER the password is proven carry the address on
the account plus both gate flags; the two that answer before it (invalid, rate_limited) carry
nothing, so a wrong password and an unknown account stay byte-identical. The frontend renders the
first three as a named screen in place of the form — there is nothing to retype once the password is
correct — with an icon, the two-gate checklist, the address in monospace, and a resend CTA where one
helps. Reporting each gate from its own flag is the payoff of S1: approved-but-unverified is a real
state now, and a screen that assumed a sequence would send that user off to wait for an admin who
has already decided.
The Google access-request notice names the address too, and says so when the request was already on
file under ANOTHER spelling of the same mailbox — the honest version of "we didn't open a second
one", stated only when it applies. It moved from the query string to the fragment: the notice now
carries an address, and a query string lands in every reverse-proxy access log. Same reasoning that
put the verify/reset tokens there, and the same owner already clears it. urlencode, not an f-string,
because URLSearchParams reads a bare + in a fragment as a space and would mangle exactly the
+tag addresses this epic exists to protect.
upsert_pending_invite returns the row it settled on beside the is-new flag, since the row's stored
address is what the notice has to name and what mail will go to.
Deviation from the PendingScreen mockup, deliberately: no "Sign out" button and no separate "Not
your address?" link. The mockup drew the panel in the app shell; the accepted decision (a) keeps the
pending user session-less, so there is no session to end, and the address is named directly above a
"Back to sign in" that answers the same question.
UAT on localdev, both languages: all three refusals with the typed spelling deliberately differing
from the stored one (the panel named the stored one), the resend CTA carrying the typed address into
the resend form, and both access-request banners with a +tag address surviving the fragment round
trip. Throwaway accounts and the language switch reverted.
An account can now hold two true addresses: the one its owner typed, which a Google
sign-in no longer overwrites, and the one Google answers with. Only the first was
stored, so Settings could say 'Connected' without saying connected AS WHOM -- exactly
what someone who registered with a +tag needs to know.
users.google_email (migration 0080) records Google's answer at sign-in and at an
in-app link. /api/me exposes it ONLY when it differs from the account address, so the
comparison lives in one place and the majority, whose two addresses are the same
string, see no change at all. Rendered in the account menu as a muted third line under
the identity, and in the Settings sign-in row under the existing hint.
A short 'Google' label rather than a brand glyph: the app draws one icon set (lucide),
and a generic mail icon next to an email address says nothing. It also reads to a
screen reader, which an unlabelled logo would not.
No backfill is possible -- Google's answer is only known at sign-in -- so accounts
linked earlier stay NULL until their owner next signs in with Google.
Two halves of one rule: the address someone typed is theirs to keep, and it must be
the one that gets them back in.
A plus-tagged or dotted Gmail is often a deliberate choice -- registering as
me+siftlode@gmail.com is how you find out later which site leaked your address. The
Google callback overwrote it with the account's primary, which can never contain a
'+', so the tag vanished on the first Google sign-in and, because sign-in matched the
stored address, took their chosen address with it. The rewrite line is older than
this epic, but S3 made it reachable here by matching adoption across spellings. It
now runs only when the mailbox GENUINELY changed, and lives in a named helper rather
than inline, so the rule has one home.
The three lookups that resolve a typed address -- password login, password reset,
verify resend -- go through find_user_by_email: exact spelling first, then the unique
canonical candidate, and None when several accounts share a mailbox rather than
guessing which one a password should open. Reset and resend previously answered a
different spelling with silence (they reply uniformly by design), which was a dead end
with no way out. Their mail now goes to the address on the account, never the one
typed into the form.
Gmail delivers x.y@, xy@ and xy+tag@ to one inbox; the invite gate only lowercased.
On 2026-09-03 that turned an approved user's Google sign-in into a silent SECOND
access request -- OAuth returns the account's stored primary, which need not be the
spelling they typed -- and left prod with two user rows for one mailbox (ids 13, 14).
The +tag case fails every time: a Google account cannot contain +, so OAuth can only
ever answer with the base address.
Mail still goes to the address AS TYPED; only identity matching uses the canonical
form. utils.canonical_email is the single definition, and a model validator derives
users.email_canonical / invites.email_canonical on every write, so no call site can
forget it. Scope is narrow on purpose: dots are significant on most providers and the
Gmail rule does not hold for Workspace custom domains.
Swept every sibling of the same principle, not just the reported one: is_allowed,
upsert_pending_invite, the Google-callback adoption, _register_account (so no new
duplicate pair can be created), _activate_user_by_email, admin _accounts_for (an
invite backing a live user under another spelling used to look deletable) and
add_invite, plus purge_user, which now erases every canonically-equal invite instead
of leaving one behind that still grants access.
Migration 0079 backfills both columns and indexes them NON-uniquely: prod already
carries canonical duplicates (approved invite pairs since 2026-06-16), and merging
accounts is a judgement call about people's data, not something a migration does on
its own. A test pins the backfill SQL against the Python helper.
Found in UAT. Navigating a tab that already runs the app to a URL differing only in
the fragment is a same-document navigation: nothing reloads, so the import-time entry
snapshot never sees the token. It sat unconsumed in the address bar and the previous
outcome stayed on screen -- the same silence this sprint set out to remove, wearing a
different hat. Pasting the emailed link into a running tab does exactly this, and our
verification mails were plain text until 0.78.8, which makes pasting the normal way to
use one.
A hashchange listener now picks up the new token, the guard tracks consumed tokens
rather than a single fired flag (still exactly one POST per token), and a fresh link
clears the standing outcome instead of leaving the old banner up.
The token was only ever consumed inside Welcome.tsx, which App renders ONLY when
there is no session. Opening the emailed link in a browser that already held a
Siftlode session posted nothing, the fragment was then stripped, and the user got
no signal at all -- the link simply did nothing. Incognito worked, which is why it
survived. Confirmation now happens in App, above that branch: signed out the outcome
still drives the auth card's banners, signed in it drives a top-of-app bar.
The entry URL is snapshotted once at import time (lib/entryUrl) instead of per
component, so no future URL writer can strip a token before whoever needs it has
read it -- that ordering race is what broke this in the first place. POST /auth/verify
now names the address it confirmed, which matters when the browser holds a session
for a different account; the same reasoning as password_reset_required already
covers the disclosure.
Also here, from the 2026-09-02 review tail: the dead GET /auth/verify goes (both
senders emit the fragment form, and the last query-token link expired 24h after the
0.78.7 deploy), a spent #verify=/#reset= fragment is cleared while signed in, and
the auth-card banners get role=status. The verifyOkReset copy no longer blames
admin approval, which E9.5 S1 made false.
SEC-10 revoked any password set while an account's email was unverified, on both
the Google-adoption and the admin-approval path. On the approval path that was
redundant: password_login refuses an unverified account before it ever reads
is_active, so approval hands a pre-emptor's password nothing it could use. All the
revoke achieved there was destroying the password a legitimate registrant had just
chosen -- and since the admin normally decides before the user reads their mail,
'your password was cleared for security' was the DEFAULT experience, not an edge
case. The guard stays on the Google-adoption path, where the real owner does attest
ownership.
password_reset_required and the ok_reset banner stay: accounts stripped on prod
before this change have no other way back in.
Two existing tests pinned the old rule, so they are rewritten rather than deleted,
and the SEC-10 recovery pack now builds the revoked state through the surviving
producer.
E9 Playlist module: total playtime in both playlist headers, the header
compacted to two rows, and the sticky column header sitting flush. Plus the
six defects the epic-end deep review confirmed.
Round 2 mutation-checked round 1's own fix and found the one line it left
uncovered: every table row was an integer, so `Math.floor(sec)` in the new
seconds tier could be deleted or swapped for round/ceil with the suite still
green — exactly the "later cleanup removes it as redundant" failure the fix's
comment claims to prevent. Two fractional rows kill all three mutants.
Epic-end deep review, round 1. Six findings survived verification; five of them
were one defect wearing different hats.
formatRuntime floored 1-59s to "0m". Harmless while it only ever formatted a
Plex movie's length, but 12D repurposed it for an aggregate, and a total is the
only input that can land under a minute: a playlist of Shorts reported "0m"
while the rail beside it read "0:45". Fixed in the function, not behind a
`>= 60` guard at each of the seven call sites — the invariant belongs where it
cannot be forgotten. Its contract comment claimed "Plex movie/episode lengths"
and was two epics stale.
The rail rendered the same playlist total as formatDuration (1:23:45) while the
new header rendered formatRuntime (1h 23m) — two shapes of one number, side by
side on one screen. The rail now uses formatRuntime too; formatTotalHours had
already recorded that H:MM:SS is wrong for an aggregate.
The rename input kept min-w-0 without flex-1 in the compacted row, so it was
the only shrinkable child beside a shrink-0 action cluster. The Plex half of
this same epic already had the fix; this restores the symmetry.
plex.browse.items had no plural forms, so a one-item Plex playlist read
"1 items". Pre-existing, but the new header is the call site where count=1 is
ordinary. Both languages get both forms, no key drift.
formatRuntime had no unit tests at all across seven call sites. Added a tier
table plus a sweep asserting no non-zero second ever renders as "0m".
Dropped after 3/3 adversarial refutations: the meta-line segment ordering
(the filtered count is labelled, and count-then-runtime is the convention) and
the null-duration lower bound (the server's own SUM has skipped NULLs since
long before this branch; changing only the client would split them).
Parity with the YouTube playlist header (E9 12D): the Plex playlist detail
now carries a meta line under the title — "N items · Xh Ym", summed from the
items' duration_seconds. No backend change: leaf_card already serializes
duration_seconds for both movies and episodes, and no new i18n key either —
the count reuses plex.browse.items, the same key the collection header uses.
The title/rename input moved into a min-w-0 flex-1 column so the meta line
sits under both; the line is hidden on an empty playlist.
The header portals into the shell's FIXED toolbar band, so its height is
list height the user never gets back — and it was spending four stacked
blocks on it: title, meta line, an action row, then a separate view-control
row under the cover. Option B collapses that into two tight rows beside a
smaller cover: title + rename/YouTube-link on the first, with Play-all,
push/revert/delete and the unsynced badge pushed right; the meta line
(count · playtime · filtered) and the view controls (group-by, reset view,
save order, view status, undo/redo) sharing the second.
Every control and its gating is unchanged — the view-control group still
appears only for `items.length > 1 || viewActive`, so a one-item playlist
shows just the meta line.
Measured on a 32-item playlist: the band goes 170px -> 88px at rest
(122px while a view is active, where the status sentence wraps), which is
~80px more visible list.
The playlist content wrapper carried `pt-3`, so its sticky column header sat
12px below the scroller top at rest and jumped up to `top:0` on the first
scroll. Drop the top padding so the header is flush from rest and never
shifts — the fixed toolbar band above supplies the breathing room, matching
the channel table's already-correct pattern.
Surface the playlist's total playtime beside the item count in the detail
header (12D) — sums every item's duration (formatRuntime "h m", matching the
approved mockup), hidden when nothing sums. Data is already client-side; no
backend change.
Pass fadeTop:false for the playlists page (12B) so the shell's top edge-fade
no longer sits over the already-sticky column header (tableHeader.tsx:257),
matching the channels/audit pages.
The bodies are text/plain only, so whether the URL in a verification or
password-reset mail is a link at all was the client's decision. Gmail, Outlook and
Apple Mail linkify plain text; lighter web clients render the body verbatim and leave
the link as dead text — in a message whose entire purpose is that one link.
_send now attaches an HTML part generated from the SAME plain body, rather than
per-template HTML that would have to be kept in sync with it: one converter, all
eleven templates, and text/plain stays the primary part and the source of truth.
Escaping runs before linkification — bodies interpolate untrusted values (a
requester's address, the operator contact) — so no caller can inject markup, and a
query string's `&` is already `&` when it reaches the href, which is what an HTML
attribute wants. Sentence-final punctuation is trimmed out of the href while
url-safe token characters (`-`, `_`) are left alone. Inline styles only, no images,
no external CSS, no tracking.
Review found the backfill's fourth predicate `AND email_verified = false` strands
exactly the subset that needs it most. The revoke does not invalidate the account's
outstanding verification token, so clicking the emailed link is the natural next
thing such a user does — and it sets email_verified=True while password_hash stays
NULL. That row then has no route back at all: reset ineligible, resend refuses an
already-verified address, set-password needs a session the account cannot obtain,
and nothing re-stamps it because the revoke itself is guarded on being unverified.
The remaining three predicates already pin the victim set exactly: registration and
the setup wizard always set a hash, every Google row carries a google_sub (adoption
assigns it before revoking), and nothing in the codebase ever clears google_sub — so
a local, non-demo, passwordless row can only have come from the revoke. Widening is
therefore safe, and it is the argument the docstring was already making while the
SQL contradicted it.
Also corrects that docstring: set-password is unreachable because the account cannot
authenticate at all, not because the epoch bump killed a session — an unverified
account is refused at login before a session is ever minted, so it never had one.
Two independent bugs made an email+password registration unrecoverable in prod.
The verification link did nothing. urlWithParams/clearParams rebuilt the URL as
pathname+query and dropped the fragment — but the one-time auth tokens ride the
fragment (#verify=/#reset=, SB3), and the providers that mirror nav params
(useUrlMirror in FeedFilters/Playlists/Plex/Channels) sit ABOVE the lazily-loaded
<App> in main.tsx. Their mount effect replaceState'd the token away before the auth
card's chunk had loaded, so the card read an empty hash: the link opened a bare
sign-in page, no request, no banner. Both helpers now carry the fragment over.
The account was then locked out for good. SEC-10 nulls password_hash when an
unverified account is attested (here: admin approval landing before the user could
verify — which the bug above made impossible). That is indistinguishable from a
Google-only account, and reset eligibility keyed off password_hash alone, so no
reset link was ever issued: no password to sign in with, and no way to get one.
users.password_revoked_at records the revocation, reset eligibility honours it, and
setting a password again (reset-confirm or set-password) clears it. An account that
never had a password stays ineligible on purpose — its owner adds one from Settings.
/auth/verify now also answers password_reset_required so the card can say why the
registered password stopped working, instead of leaving the user at the uniform
"Invalid email or password". Telling the holder of a valid verify token leaks
nothing: they have already proved they own the mailbox.
The 0078 migration backfills the stamp for rows carrying the exact lockout signature
(local, non-demo, no password, unverified), so existing victims recover unaided.
Round 4's only finding: the file header and one comment still said Deny stops a
refused person re-asking. It does not — upsert_pending_invite reopens a denied
row as pending — and the delete_invite docstring in the same branch already says
so. The real difference is that Deny records an auditable decision and mails the
requester; Delete forgets the address.
Round 3's correctness/security pass came back clean; what was left said more than
the code does.
- emailOff promised the access-request list is where sign-ups show up. A sign-up
on an address that is already approved or already pending adds nothing to it
(roadmap UX-25), and "only" was falsified by this release's own inbox notice.
- deleteConfirm claimed the admin was dropping "an approval you already gave" —
it is the confirmation for pending and denied rows too.
- sendFailed blamed the mail server for rejecting a message; the reason covers
connect, DNS, TLS and login failures alike, so it now describes the symptom and
leaves the diagnosis to the log.
- ADMIN_EMAILS re-promotion happens in the Google callback only, not on any
sign-in.
Tests: the decided-list copy of the delete affordance was never rendered (both
new cases fail if that branch is dropped), and the throttle release — a
deliberate behaviour the notes advertise — was asserted by nothing; a drop with
no admin yet, or with a failing write, must not silence the following hour.
Round 2 on the feature diff (15 → 12):
- still_allowed_by_env now rides the invite LIST as well as the delete response,
so the confirmation warns while the admin can still change their mind. It
mattered most for a DENIED row on an env-allow-listed address: deleting it
silently converted a refusal into a grant, and the only warning arrived after
the irreversible click.
- Both inbox bodies claimed to name "the most recent" dropped message. The
throttle records the FIRST of each hour and never updates it, so they now say
"one of them", and the not-configured body finally names where to fix it.
- settings.invites.emailOff told the admin they "get no notice when someone
signs up" — rendered directly above the queue that shows exactly that. Only
the email is lost, and it now says so.
- The disabled delete buttons put their reason out of reach: `disabled` drops a
control from the tab order and suppresses its title. Switched to
aria-disabled + preventDefault, the pattern Playlists.tsx already established
for the same reason.
- deleteInvite is quiet, so the 409 the UI handles with a translated toast no
longer also raises the server's English detail as a modal.
- After a completed password reset, Back landed on the reset form again with a
consumed token — the history change made that state reachable. The token is
cleared on success so the reset view stops being the card's root.
- The hu deleteHasAccount named a tab that renders under a different label.
Tests: a dom test for the Access-requests panel covering both has_account
variants and all three delete outcomes, /api/me flag cases, an ADMIN_EMAILS
case, and the env-allowlist test now patches the raw setting so the parsing
stays under test.
Round 1 on the feature diff found 15; the substantive ones:
- The delete control VANISHED on any invite whose address had registered, which
is the ordinary sign-up shape (register writes the User and the pending invite
together). The guard stays broad — that row is the account's approval gate, and
orphaning it strands someone who can then neither sign in nor be approved — but
the control is now a disabled button carrying the reason, on both lists, so an
admin never sees a row silently missing a button its neighbours have.
- Deleting an entry is not always a revoke: is_allowed falls back to the env
ALLOWED_EMAILS / ADMIN_EMAILS lists once no row exists, and an ADMIN_EMAILS
address is force-promoted on its next Google sign-in. The route now reports
still_allowed_by_env and the UI says so instead of claiming access was removed.
- "Deny keeps the row so they can't immediately re-ask" was simply false —
upsert_pending_invite reopens a denied row. Corrected everywhere it was stated.
- The Configuration warning reads me.email_configured, which save() did not
invalidate, so it kept warning about fields the admin had just filled in.
- The read-only variant was an aria-hidden span explained only by a Tooltip that
the hints toggle can switch off; now a disabled button with title + aria-label.
- The delete error path threw away the 409's reason (the stale-list case) and
left the stale row on screen.
- The throttle window was spent even when no notice was written, silencing the
next hour on the path whose job is to stop silent drops.
- email_enabled() opened a second session and decrypted the SMTP password on
every /api/me poll; it now takes the caller's session.
- welcomeMode.dom.test.ts never rendered the card, so reverting the history
wiring to useState left it green. A render test now drives the real links and
fails on that revert (verified).
Plus /api/me flag tests, the registration-shaped pending case, the env-allowlist
case, and a release note that matches what the code does.
Three operator gaps, all found the same way — a person could not sign up and the
app showed nobody anything.
- DELETE /api/admin/invites/{id} with a delete control on both the pending
requests and the previously-decided list. Deny keeps the row so the person
cannot immediately re-ask; delete forgets the address. Refused with 409 while
a User row exists for it, because is_allowed reads that row and removing it
would revoke a live user's access with no other sign — the list ships
has_account so the UI withholds the button instead of offering one that fails.
- Email is fail-soft by design, which had come to mean invisible. _send() now
records an email_undelivered notification for every admin when a message is
skipped or rejected, naming the message that was lost, throttled to one an
hour. /api/me carries email_configured, driving a standing warning on
Configuration → Email / SMTP and above the access-request list.
- The sign-in card's Create account / Forgot password / Didn't get the email
views are history entries (useHistorySubview), so Back returns to the previous
view instead of only moving the address bar.
Also: the per-test rate-limiter reset sweeps every loaded app.* module rather
than a hand-kept list of three — this change added a limiter outside it.
Round 6's two remaining nits. The operator note said "Configuration → Email"
but the group renders as "Email / SMTP" in both locales, and
test_an_admin_can_activate_a_pre_approved_registrant requested make_user without
using it — a leftover from the reverted rounds that made the test look like it
seeded an admin when it did not.
Round 5's correctness/security pass came back clean; what was left was text that
said more than the code does.
- The register-path notify comment (and its test) claimed the admin "is still
told". The call fires, but on a no-SMTP instance _send drops it — which is the
open half of the problem, not something this branch fixed. Both now say so.
- The release notes tell admins what to actually do: an account whose address was
whitelisted in advance has no pending request to approve, so the whitelist box
is what activates it. That instruction only existed in the roadmap and in
chores, which the dialog does not render.
- "That one line is the whole production change" now accounts for the test-only
RateLimiter.reset() that also ships.
- The rate-limiter test claimed to pin that blocked attempts are not counted, on
an assertion that held either way. Replaced with one that drives the clock
across the window boundary and fails if hammering extends it.
Round 4 showed the `inv is not None` gate added in b5a1002 removed a signal that
matters: for an address the admin had already approved, that email is their only
notice that someone has now created an account on it — the invite is not pending
so nothing lands in the Access-requests queue either. Suppressing it left the
registrant at "awaiting admin approval" with the admin unaware they existed. The
duplicate it avoided (Request access, then sign up) is mild noise by comparison.
The production change is now one db.commit(), which is all the reported bug ever
needed. Release notes corrected again: a previously-denied address was also
unaffected, the pending-request case never lost its queue entry (only the
account behind it), and the test inventory was missing a guard.
Round 3 of the review killed the reopen from b5a1002: flipping an approved
invite back to pending let an unauthenticated POST /auth/register REVOKE a
standing approval for any whitelisted-but-unclaimed address, which then fails
is_allowed() and locks the real owner out of Google sign-in — the primary way
people get in here. It also re-armed the pre-emption window it was meant to
close, because with no SMTP email_verified is set True and the SEC-10 guard the
design leaned on never fires.
Two attempts at fixing the stranded-account UX in the same change both came back
worse than the wart. What remains is the narrow, real bug: the account was
silently rolled back and is now committed. An admin still binds the approval to
the account, exactly as before this branch. UX-25 in the roadmap records the
follow-up and both dead ends so they are not retried.
Also from round 3: the upsert docstring no longer claims the admin UI cannot
reach these accounts (re-entering the address in the same panel's whitelist box
does, and a test now pins it), the release notes are scoped to what the diff
actually changes with the operator note moved where the dialog renders it, and
RateLimiter has unit tests so reset() is not production code only a fixture
calls.
Replaces the activation added in ea931dc, which round 2 of the review showed was
a real weakening: on a no-SMTP instance it made a standing whitelist entry a
bearer grant — the first anonymous party to register an approved-but-unclaimed
address got an immediately usable account under that identity, with no
attestation, no admin action and no signal anywhere. The verify-time activation
had the same shape one step removed, and neither site ran the SEC-10 preset-
password guard or left an audit trail.
Keep a human in the loop instead. An approval granted before any account existed
was a decision about the ADDRESS, so registration reopens the invite
(upsert_pending_invite gains revive_approved, used only by the register worker).
The request lands in the Access-requests queue, approving it runs
_activate_user_by_email — and with it revoke_preset_password_if_unverified — and
the account activates. One mechanism, already audited, no new grant path, and
the "awaiting admin approval" copy is honest again.
The explicit commit from the previous commit still matters: with an invite
already pending, upsert returns without committing and _issue_token is gated on
SMTP, so a no-SMTP registration was still being rolled back.
Also from round 2: gate the admin notice on a fresh decision (matching the two
other upsert callers) so a retry no longer re-pings every admin, and reset the
module-level rate limiters between tests — the suite pooled every login into one
300s window and was two logins short of failing an unrelated test with a 429.
Follow-up to the commit fix, from the deep-review. Persisting the account was
only half the story: when the admin had approved the address BEFORE the person
registered, the account landed is_active=False against an invite that is no
longer pending — absent from the Access-requests queue, and approve_invite 409s
on an already-decided invite. The sign-up worked and still could not sign in.
Derive activation from is_allowed() at the moment ownership is attested:
- verify_email_confirm activates a pre-approved account when the emailed link is
confirmed (only the mailbox owner can follow it), so SEC-10 stays intact;
- _register_account activates only when there is no SMTP to verify with, where
the admin whitelist is by design the sole gate — the same one add_invite
already activates on.
Tests: the pre-approved no-SMTP case and the verify-activates case are real
regression tests (both fail without this change); guards cover the unknown-email
and denied->pending branches, verification of an unapproved account, and the
first end-to-end coverage of the /auth/register route and its
allow_registration gate. Release notes rewritten — the previous entry miscounted
its regression tests and advertised an email fix this release has no code for.
_register_account() flushed the new User but relied on someone else to commit:
upsert_pending_invite() commits only when it actually writes an invite row, and
_issue_token() runs only when SMTP is enabled. For an email an admin had ALREADY
approved, on an instance where email is unavailable, neither fired — the session
closed and the account was rolled back. The would-be user got the uniform "ok"
and no account, could not sign in, and re-registering was a silent no-op.
Commit explicitly after the invite upsert (a no-op when upsert already committed
the same transaction). Three DB-lane regression tests cover the pre-approved
no-SMTP path (fails without the fix), the unknown-email path that always worked,
and the pre-approved path with SMTP on.
2026-09-01 16:25:05 +02:00
1400 changed files with 172806 additions and 28510 deletions
@@ -5,19 +5,23 @@ be developed that way. This file is the rules of the road: read it before you to
loaded into every agent session, so keep it **short and true**. A rule here that has gone stale is
worse than no rule — if you change how the code works, change this file in the same commit.
Siftlode is a self-hosted YouTube reader (+ a local Plex browser/player and a download center). It is a
Siftlode is a self-hosted YouTube reader (+ a local Plex browser/player and a files library with downloads). It is a
**modular monolith**: one Postgres, one FastAPI process (uvicorn + APScheduler), one worker process
(yt-dlp/ffmpeg), one PO-token sidecar. React 18 + react-query frontend with a router-free navigation
provider.
(yt-dlp/ffmpeg), one PO-token sidecar. React 19 (with the React Compiler) + react-query frontend, with a
router-free navigation provider.
## The gate — never merge or ship red
The local gate is **ruff (`check` + `format --check`) + typecheck (3 tsc projects) + eslint + prettier
--check + knip + vitest + pytest**. It runs on every `publish`, which **refuses to build on failure — there is deliberately no
`--skip` flag.** Run it and get it green **before** any merge, release, or handing work to review.
The local gate is **ruff (`check` + `format --check`) + shellcheck + basedpyright (backend) + the lock check + typecheck (3 tsc projects) +
eslint + prettier --check + knip + vitest + vite build + pytest + pip-audit + npm audit**. It runs
before every `release` merges and on every `publish`, which **refuses to build on failure — there is deliberately no `--skip`
flag**; a release also requires the Playwright suite, and the image must pass a Trivy scan before it is pushed. Run it and get it
green **before** any merge, release, or handing work to review.
- Full gate (agents with the private ops dispatcher):`siftlode check`.
-Directly, without the dispatcher: `cd frontend && npm run typecheck && npm run lint && npm run knip && npm run test`, plus backend `ruff check backend` + `ruff format --check backend` and `pytest` (the DB-lane needs Postgres).
- Full gate: `bash scripts/check.sh` (`lint` for the static lanes only); the private ops dispatcher's`siftlode check` runs the same script. Single lanes: [`docs/development.md`](docs/development.md) § The gate.
-**There is no CI** (owner, 2026-10-04): this gate, run here, is the only one — green before every hand-over, and `release` refuses to merge without it.
- **A gate with any real hit is never switched off or narrowed** — not a lint rule, a compiler flag, a lane or a release step. Fix the sites, or suppress each one inline naming its invariant (`-- why`, lint-enforced). What stays narrowed is in [`docs/gates.md`](docs/gates.md), and a contract test fails when a narrowing is missing from it. The effective ESLint config of every scope and the gate/lane lines are pinned in `frontend/src/lib/gates.snapshot.json`: any gate change fails until you regenerate it (`GATE_SNAPSHOT=update`, see gates.md) — then read that diff as a gate change. A comment saying "X is enforced by Y" is checked by running Y (E32: one such comment hid 95 functions the React Compiler skipped).
- **The backend type lane runs in the test image, not on the host** — `requirements.lock` cannot be installed on Windows (uvloop), and a type checker without the real packages reports nonsense. It fails on NEW errors only; the pre-existing ones sit in `backend/.basedpyright/baseline.json` (no burn-down mandate, but none was ever triaged: a `reportOptional*` entry in code you touch may be a real bug). A run that fixed some rewrites it — commit that.
- New DB-backed test? It must actually run, not silently skip — assume `SIFTLODE_TEST_REQUIRE_DB=1`.
## Workflow
@@ -26,8 +30,8 @@ The local gate is **ruff (`check` + `format --check`) + typecheck (3 tsc project
keep commits logical and Conventional-Commit-styled.
- **No AI attribution in commits.** No `Co-Authored-By` trailer, no "generated by" lines. (This file
being AI-written is the intended, visible exception; commits are not.)
- **Release: user-owned in timing, agent-run in execution.** The user decides *when* to ship; on their
go-ahead *you* run `siftlode release` from the feature branch (merge to `main` → publish → deploy →
- **Release: user-owned in timing, agent-run in execution.** The user decides _when_ to ship; on their
go-ahead _you_ run `siftlode release` from the feature branch (merge to `main` → publish → deploy →
verify `/api/version`). Never self-ship — bring work to a green, committed state and stop for the
go-ahead first, then run it yourself.
- **Public repo.** Keep ops/topology/secrets out — those live in a separate private repo. Nothing here
@@ -41,49 +45,79 @@ how it gets built. Concretely:
- **Plan before you build.** For anything non-trivial, present a short plan (sprint-level for larger
work) and get a go-ahead before writing code.
- **Offer choices, mark the optimal — and explain the trade-off.** When several solutions are viable,
list them and mark the recommended one. This is the *one* place to spend extra words: give the *why*
list them and mark the recommended one. This is the _one_ place to spend extra words: give the _why_
behind each option and the weight of the decision. These decision points are where the product owner
engages with the build and learns from it, so make them understandable — a reasoned menu, not a bare one.
- **Mockups for design/UX decisions.** For any visual or interaction choice, show mockup options to
choose from before building.
choose from before building. How the UI looks and behaves is decided in
[`docs/DESIGN.md`](docs/DESIGN.md) (its variants render in the dev-only `/design-lab`): build to it,
and change it only through its decision log.
- **Challenge unsound requests.** If a request is technically ill-advised and you can back it with real
arguments, push back or propose an alternative — don't comply blindly.
- **Verify UI in the browser before calling it done.** tsc/unit/DOM tests miss visual and interaction
bugs; do a real visual check of any UI change.
bugs; do a real visual check of any UI change, and run DESIGN.md's Conformance check (a copy-only
change runs its step 4 alone: every state the new text renders in, in both languages).
- **Be terse everywhere else.** Outside those decision points, lead with the answer or decision. During
implementation and debugging especially: no restating the same point reworded, no filler.
| Request body | a Pydantic model | `payload: dict` |
| Request SIZE | nothing — `bodylimit` caps every route (SEC-20); need more? add a ceiling to `ROUTE_MAX_BODY_BYTES` | `capped_body` on a route that does not read raw bytes itself |
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
| User-facing error | structured `detail` (`{code, reason}`) | a bare English string under a bilingual UI |
| Mutation response | `{"ok": true, …}` (`{"deleted": id}`), test-held | a bare `{"unsubscribed"}`/`{"cleared": n}`, no `ok` |
| Transaction commit | the handler commits; helpers `flush` (test-held list) | helpers that commit (only `quota` and `jobruns` own their session) |
| Admin knob storage | `sysconfig` (values) | env / `app_state` / a table of its own (the job intervals had one) |
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
| FE keyboard shortcut | a row in `lib/keymap.ts`+`dispatch()` | a private `keydown` listener with its own key literals |
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
| FE static label / button-like link | `Badge` (flat, no edge) · `LinkButton` / `IconLink` | a glass pill that reads as a chip; an `<a>` hand-painted like a button |
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)`; a YouTube write action (subscribe, sync, …): `notifyYouTubeActionError` — a 403 offers Connect | a bare `catch {}` or `notify()` with no error attached |
| FE floating or full-screen layer | `ui/Overlay` (portals to `<body>`) or the popover engine (`AnchoredPopover` portals itself); stacking by the z-index scale (`z-menu` … `z-popover`, docs/DESIGN.md "Layers") | one rendered in place inside a virtualized row or a masked/transformed scroller — clipped or painted over; a magic `z-[n]` |
| FE player on a native media element | `lib/mediaSession` (`useMediaSession`) + `lib/mediaKeys` + `lib/resume` (`useResume`), the element held as STATE | a private `navigator.mediaSession` / `keydown` / position-saving effect per player |
| FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`); a filter change → `lib/filterLog.ts`, a player → `lib/mediaLog.ts` | `console.*` (lint-enforced); a value only a higher level may carry, logged unguarded |
| FE state that MIRRORS a prop/query | derive it, or hold a draft (`draft ?? server`) | a copy an effect re-syncs — that paints the stale value first |
| FE state reset on a key change | adjust it DURING render against a remembered key | `useEffect(() => setX(init), [key])` |
| FE effect that must not re-subscribe | `useEffectEvent` (React 19.2+); a handler or timer: `useLatest` / `useStableCallback` | a render-phase `ref.current = x` — the React Compiler skips the component |
| FE hook suppression | an inline `eslint-disable-next-line` NAMING the invariant | `eslint-suppressions.json` (deliberately absent — E27 S2) |
| FE popover / scroll-fade ref | destructure it: `const { setReference } = pop.refs`, then `ref={setReference}` | `ref={pop.refs.setReference}` / `ref={fade.ref}` — the compiler then treats the whole object as a ref and skips |
| FE async handler with cleanup | `try { … } catch { … }`, then the cleanup after it (steps that `return` early go in their own function); a conditional, loop or `?.` the `try` needs goes before it or into a module-level helper | `try/finally`, or a `?:` / `??` / loop / `?.` inside `try` — the React Compiler skips the whole component (and `react-hooks/todo` misses most of these) |
Not repeated here, because a lint rule refuses the wrong one and its message names the right one:
buttons (`ui/Button`, `IconButton`, `Pressable`, `Chip`, `DragHandle`), text fields and native
(`SegmentedToggle` / `FilterSelect`), a picture (`ui/RemoteImage`, never a bare `<img>`), a box or font size (the spacing scale or rem,
never a px-frozen `w-[Npx]` / `text-[Npx]`: the text-size slider scales the root font), and network calls (`req` / `uploadReq` / `beacon`, `lib/http.ts`).
## House rules that bite (learned from real bugs)
@@ -96,10 +130,11 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
"no results". Reads degrade honestly; writes are conditional (`UPDATE … WHERE status='running'`).
- **i18n parity.** HU/EN, zero key drift. Every user-facing string is translated.
- **Comment the "why", keep it current.** This codebase uses comments as its architecture doc; a stale
"why" is a liability, so update it when the behavior changes.
"why" is a liability, so update it when the behavior changes. Say the why itself, never the plan
step that produced it: no epic, sprint or review-round ids in the code (`planCodes.contract.test.ts`).
---
*Run-it-locally and the full gate commands live in [`docs/development.md`](docs/development.md); backend
_Run-it-locally and the full gate commands live in [`docs/development.md`](docs/development.md); backend
code conventions in [`backend/app/README.md`](backend/app/README.md). This file stays the source of
truth for the canonical-patterns table and the gate/workflow/collaboration rules above.*
truth for the canonical-patterns table and the gate/workflow/collaboration rules above._
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.