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).
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.
- 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.
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).
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 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.
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).
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.
Ships the E5 sprint stack (S1–S7): field-scoped Plex search + typeahead, cast &
crew pages, the HU-overlay Hungarian metadata + language-aware serving, the
IMDb/audience rating split, the toolbar relocation, and the narrow/mobile header
fix — plus the underlying Plex-module refactors (feedquery/plex.cards/plex.query
extraction, db.query→2.0 select).
- VERSION 0.65.2 → 0.66.0
- releaseNotes.ts: 0.66.0 user-facing entry
- prettier-format the E5-touched frontend files (release hygiene)
Merged main (0.65.2 download-gc hotfix) in first; no code conflicts (E5 is
Plex-only, the hotfix was notifications/downloads).
- Add PlexScopeToggle.dom.test.tsx (renders the three scopes, current one
pressed, click writes scope back to the provider) and
usePlexActiveFilters.dom.test.tsx (emits filter + watch-state chips but never
a sort chip, even with a non-default sort active) — the S3 relocation was
untested.
- Narrow-width header: make the header row flex-wrap so the scope toggle drops
to a second line instead of crowding the flex-1 search box to zero width, and
let the field-scope pill shrink/truncate. Desktop stays a single line
(verified: 1280px header unchanged, search 237px, no wrap).
The removed one-click sort-reset chip is intended (sort is an always-visible
control now, like the Feed) — no change. FE suite 174 green; typecheck + eslint
clean.
- Reset `hi` on Escape and gate `activeOptionId` on `open`, so a dismissed
panel never exposes an aria-activedescendant pointing at an unmounted option,
and no stale highlight flashes on reopen (live-verified: aria-activedescendant
is null when nothing is highlighted / the panel is closed).
- Strengthen the reopen test: setQ now writes back to the mock provider's q, so
the test asserts a FRESH suggest query fires on retype (q="matrixx"), not just
that a cached listbox re-appears.
- Add a combobox-ARIA test: role=combobox, aria-expanded toggles with the panel,
and aria-controls associates the input with the listbox it opens.
Suite 171 green; typecheck + eslint clean.
- Decouple the typeahead's dismiss from real focus: a separate `dismissed`
flag (set on Escape/pick, cleared on typing/refocus) replaces manually
clearing `focused`, so closing the panel no longer wedges it shut while the
input keeps DOM focus — typing again re-opens it (verified in-browser).
- Scroll the highlighted option into view during arrow-key nav (listRef +
data-idx + scrollIntoView), instead of leaving listRef unused.
- Wire the input as a combobox for screen readers: role=combobox,
aria-autocomplete/expanded/controls/activedescendant + per-option ids.
- Drop the redundant client-side .slice (the request already caps at limit=10).
- SearchBar gains an `inputProps` passthrough for the combobox ARIA.
- Add PlexSearchBox.dom.test.tsx (4 tests): per-scope pill options, suggestion
render + keyboard pick→jumpTo, and the Escape→retype reopen regression.
Reviewer's scrollbar-blur concern was a false positive — the listbox container
is not focusable, so a mousedown on it never blurs the input.
Add a search scope-pill to the Plex header search box (GitHub-style, one
field-scope at a time) and an instant typeahead of the top matches below it.
Backend (unified_library):
- New `field` param: all | title | people | summary | episode. `all` is
byte-identical to the pre-scope behaviour (untouched weighted search_vector);
a specific field keeps the GIN `search_vector @@ tsq` prefilter AND ANDs a
per-column tsvector recheck, so scoped hits are a strict subset of Everywhere.
Per-field expressions mirror the search_vector Computed def (same TS config,
same left(summary,1000) truncation).
- `episode` scope empties the movie/show grid and title-only-matches the grouped
Episodes section; the title/people/summary scopes suppress that section.
- Unknown field falls back to `all`. New DB-lane characterization tests
(test_plex_field_scoped_search.py): each scope narrows correctly, field=all is
identical to today, episode empties the grid and needs shows in scope.
Frontend:
- PlexSearchBox wraps the shared SearchBar with the scope-pill (reusing
ActionMenu) and the typeahead; SearchBar gains generic leading/below/focus/
keydown slots so other modules' search boxes are untouched.
- Episode scope offered only when shows are in scope (movie-only hides it),
healed at the point of use like the sort state.
- Typeahead reuses /api/plex/library (limit=10), honours scope+field (not the
sidebar filters), shows poster+title+year, keyboard nav (arrows/Enter/Esc),
and jumps straight to a movie's info page or a show's drill-down via a new
provider jumpTo hand-off (mirrors the playlistOpen pattern).
- `field` persisted per-account; en/hu labels added.