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.
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.
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.
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.
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 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.
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.
`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.
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.
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.
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.
- 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
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.
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.
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.
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.
- 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.
- 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)
- 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 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).
- 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.
- 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,…)
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.
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.
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 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 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.
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.
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 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.
- 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.
- 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".
- 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 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.