- 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.
- 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,…)
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 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.
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.
- 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.
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.
- 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.
- 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.
- 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).
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.
- 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 `;`.
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.
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.
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.
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.
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.
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.
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.
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.
Extract the show->ordered-episode-keys flatten into one pure, unit-tested
helper (lib/plexQueue.ts) and use it at all three sites — the collection
Play-all (S4) and the show/season play-through pages in plexViews — so the
season-then-episode queue-order invariant lives in one place instead of three
hand-rolled copies. Behaviour-identical (the helper's ?? [] is a safe superset
of the collection site's previously non-null read).
Also: a show-collection whose shows expand to zero episodes had an enabled
Play-all that silently no-op'd — the same 'does nothing' state S4 removed for
the empty-queue case. Surface an honest info notice (plex.collection.playEmpty,
HU+EN) instead.
A collection's Play-all/Shuffle queue held only playable leaves
(movies/episodes), filtering out shows — so on a show-collection the queue
was empty and Play-all was hidden/dead. Build the queue by expanding each
show member into its episodes (season then episode order), concatenated in
member order; leaves still queue as themselves. Shows are fetched lazily on
click (parallel plexShow fetches), not on page load. Gate Play-all/Shuffle
and the poster on "has any playable member" (leaves OR shows), and disable
them while the queue builds. Scope: option A — no member manual-order
migration. Adds plex.collection.playFailed (HU/EN).
The notifyCollectionEditError helper localizes the backend's duplicate-name
409 to plex.collection.nameTaken; it was applied to the New-collection panel
but two sibling collection-write sites still routed 409 through
notifyActionError, surfacing the server's raw English detail under the HU UI:
- PlexCollectionView.doRename (rename collision)
- PlexCollectionEditor.createNew (create-in-editor duplicate name)
Both now use notifyCollectionEditError, matching the create path. Also adds a
direct dom test for the extracted PlexLibraryPicker covering the enabled gate,
emptyHint, results, honest error, and no-results branches.
AddItemPanel (Collection page) and NewCollectionPanel (browse) had near-
duplicate debounced library-search + result-list rendering. Extract it into
one PlexLibraryPicker that owns the query, the loading/error/empty states and
the row markup; callers keep their own chrome and pass term + renderAction.
No behaviour change.
Round of USER-triggered deep-review fixes (11 CONFIRMED + 1 PLAUSIBLE, all
verified) on the 22-commit branch:
- collections: split the mutate-then-resync error surface. A resync READ that
fails after a successful Plex mutation no longer reports a false 422 "Plex
write failed" + rollback; it returns 502 "sync pending" (the next full sync
reconciles the mirror). New _write_or_pending_error helper; delete is exempt
(no Plex read). Covered by new happy-path / 422-rollback / 502 tests.
- New-collection dup-name guard is now advisory only: existingNames is the
search-scoped, all-library union, but the backend enforces uniqueness PER
library — so it no longer blocks a valid same-name collection in another
library, and the backend 409 is localized to nameTaken.
- member-remove now confirms before the write-through (matches delete-collection).
- add-title / new-collection library search render an error state on request
failure instead of a misleading "No matches".
- history: seed navCounter past the current entry's persisted sfScroll on load
so post-reload ids can't collide with back-stack entries and mis-restore scroll.
- PageScroller: LRU-cap the navId-keyed savedScroll map (was unbounded).
- share isIntrinsicReadOnly between the browse tile and the info page so the
lock/edit affordance can't drift.
Clicking a collection tile now opens a dedicated Collection info page (like the Plex-web
one) instead of just applying it as a filter. New GET /collections/{rk} returns the hero
aggregates (year range, most-restrictive member cert, total runtime) + the members ordered
by release year then title; a new PlexCollectionView renders the hero (art/title/meta/
summary+More) and the member grid, with a Play-all and Shuffle over the playable leaves
(reusing the player's existing queue + auto-advance).
For a non-external, admin-enabled collection the page offers rename / delete / add-title
(a search picker) / remove-item, all through the pre-existing admin endpoints that write
THROUGH to Plex (each edit awaits and re-fetches — no optimism, since a Plex write can 422).
An eligible collection shows "Enable editing" first (the DB editable flag). Read-only ones
(smart / imdb / tmdb / … auto-lists) are marked with a lock on both the info page AND the
browse tile, up front.
New `{ kind: "collection" }` sub-view wired through plexSubUrl (bookmarkable ?v=collection:rk),
PlexBrowse (render branch, Backspace back-nav, tile handler), with a round-trip test.
Verified live: tile→page, hero + chronological members (James Bond 1962→2021, PG-13, 55h),
Play/Shuffle, Enable editing → edit controls + remove-X, the add search (marks already-in
titles "Added"), and the read-only lock on external collections. Real Plex mutations wired
but not executed against the live account.
Gate: tsc/lint/knip/prettier clean, 424 vitest; backend ruff clean, 6 collections tests
(+ a detail-hero/ordering assertion).