0521149cf17fdfaaa197ce6056da9a12591c0f3b
82
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
0521149cf1 |
fix(design): claim audit part 1 — a closing key no longer re-presses its trigger, the Switch box and name, named z-levels (E28)
- 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. |
||
|
|
6354454cc9 | feat(design): skeleton first loads by shape on the shared reveal clock (E28 S3d2, B8) | ||
|
|
dac9d89f91 |
feat(design): the field family, the dropdown trigger, native controls, the no-raw-input lint rule (E28 S3c)
- 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 |
||
|
|
81936e9f56 |
feat(design): every button on its family, the chip primitive, the lint rule (E28 S3b2)
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).
|
||
|
|
86f467bd65 |
feat(design): the button families on the decided ladder, first half of the sweep (E28 S3b1)
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). |
||
|
|
c5e022f056 |
refactor(frontend): the long tail — and eslint-suppressions.json is gone (E27 S2)
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. |
||
|
|
17a26894b0 |
fix(a11y): every sortable list speaks the user's language to a screen reader
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`.
|
||
|
|
7a47223a58 |
fix(ui): no button drops the keyboard focus when its own action disables 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. |
||
|
|
bc138e3b37 |
fix(ui): a disabled menu row says why it is off, under its label
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. |
||
|
|
c3d88d9b20 |
refactor: component files export only components; lint fails on a warning
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. |
||
|
|
f130a1c05a |
fix(keys): epic-end review round 1 — Back, menus and dialogs keep the focus where it belongs
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. |
||
|
|
b5d37ca142 |
feat(keys): j/k and the arrows walk the channel, playlist and download lists
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. |
||
|
|
78a9324381 |
fix(ui): the palette ranks what you typed, and lib stops leaning on components
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`. |
||
|
|
8a50538275 |
fix(ui): a write that fails says what failed and why, wherever it fails
`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. |
||
|
|
12121255dd |
fix(playlists): a rejected reorder goes back to the order the server accepted
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. |
||
|
|
267fd7f70f |
fix(playlists): a refused edit is taken back on screen, and a gone list or tag is said, not swallowed
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. |
||
|
|
76b5f7c93e |
fix(playlists): a failed background refetch keeps the list on screen, and the player outlives every branch
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). |
||
|
|
1cfe6a7f59 |
fix(api): a gone row and a refused playlist action are said in the reader's language, and a failure is reported once
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.
|
||
|
|
dcd8a9566a |
fix(playlists): the player's Remove follows the list it was launched from, the player survives a change of selection, and a gone list is said in the reader's language
- 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.
|
||
|
|
060deb89d2 |
fix(playlists): "remove" inside the player targets the list it came from, reports failure, and keeps the cursor and focus honest
Deep-review R5 — five defects in the Remove button
|
||
|
|
4869d6c108 |
fix(player): the resume position reaches the playlist page and live search, not just the feed
Deep-review R4.
|
||
|
|
f76a2afbf1 |
feat(ui): the four small gaps — player delete, repeat-move, a real channel link, YT search sorting
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. |
||
|
|
ccd5f3739a |
feat(e24): pin to the comfort shelf from every card and row
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. |
||
|
|
dac391a890 |
fix(e10): review round 3 — settle the focus race in a browser instead of guessing again
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. |
||
|
|
4b33036e7c |
fix(e10): review round 2 — revert what round 1 got wrong, narrow what it got too broad
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.
|
||
|
|
20e17c0ccb |
fix(e10): review round 1 — the boundary that did not bind, and 14 more
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. |
||
|
|
928eac4c89 |
feat(e10): open the app's menu with a right-click, starting with playlist rows
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. |
||
|
|
29bb914a95 |
feat(e10): re-aim the Button variants at the paint the app actually uses, and migrate the first 15 sites
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. |
||
|
|
5bfcf5d83a |
refactor(e10): move the platform components into components/ui/ and enforce the boundary
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. |
||
|
|
ada95d04a4 |
fix(playlists): one rendering for a playlist total, honest below a minute (E9 R1)
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). |
||
|
|
95c33e0b0f |
refactor(playlists): compact the playlist header to two rows (E9 12A)
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. |
||
|
|
4e6bcbc078 |
fix(playlists): stop the table header shifting up on first scroll (E9 12B)
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. |
||
|
|
e17fb5bbf5 |
feat(playlists): total playtime in header + sticky-header fade fix (E9 S1)
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. |
||
|
|
9af4e8f4ff |
feat(playlists): sentence-style two-way sync preview (only changed sides)
The confirm dialog spelled out both sides even when one was "+0 / −0", which read as engineer shorthand. Build the message from one sentence per side that actually changes and add a short "stays unchanged" line for the untouched side; the zero side is no longer shown as a bare +0/−0 to decode. Assembled in the component from granular i18n clauses (add/remove per side, reorder, rename) with count plurals; HU+EN. Replaces the single pushPlanMerge template. |
||
|
|
a0b725d779 |
feat(playlists): two-way (3-way) merge YouTube sync + mandatory preview (S7)
"Sync to YouTube" was a one-directional destructive push (local wins, deletes from YouTube every item not in the local list), so items added on YouTube were lost and a dirty pull was skipped entirely. Replace it with a real 3-way merge: each item is classified against the last-synced baseline as a local-add, a YouTube-add, or a genuine delete, so an add or delete on either side survives. - Persist the ordered baseline id set (new playlists.synced_video_ids, migration 0075); the fingerprint hash alone couldn't drive a merge. NULL baseline (fresh export / pre-S7 link) unions losslessly on the first sync and records one. - Pure merge_plan(baseline, local, youtube) -> add/remove per side + merged order (local order, YouTube-only additions appended). merge_playlist applies the local side directly and the YouTube side through the failure breaker; the baseline only advances on full success so a partial failure re-converges. - plan_merge/push-plan now report what each side gains and loses; the confirm dialog shows the two-way plan before applying (pushPlanMerge, HU+EN). - Tests: 10 new (pure classification incl. delete-vs-stale-copy; DB apply proving a YouTube-add and a Siftlode-add both survive on both sides, a genuine delete propagates without collateral loss, no-baseline never deletes, create path). |
||
|
|
1801a58f6b |
feat(layout): centre the header on each page's content width, centre the playlist table
Every page's floating header (search bar + sync chip) and the toolbar band now centre on the SAME max-width the content column uses, instead of spanning the whole column while a max-w page centres its grid. So the search bar lines up with the left edge of the cards/table on every page, and sits centred with the content — not against the full page width. - PageShell takes a `maxWidth`, published as --content-mw; the header adds max-w-[--content-mw] mx-auto inside its left-4 / right-(1rem+scrollbar) span, so it centres on that width (`none` = full width). - moduleRegistry PAGE_CONTENT carries each page's content max width (plex 1600px, channels 96rem, playlists 64rem, downloads 56rem, notifications/users 48rem; feed stays full width) — one source. - Playlists: its content (header, filter row, table) was left-aligned max-w-5xl → now mx-auto, so the table is centred and the header lines up with it. Verified at 1920px: header left edge == content left edge — feed 340, channels 213, playlists 487. |
||
|
|
ec3bbc78dc |
style(layout): tighten the oversized vertical gaps around the page toolbars
The floating-header → toolbar → filter-chips → content stack carried ~2× the whitespace it needed. Halve it, app-wide where the chrome is shared: - PageShell: the --hdr-h gap below the floating header 8px → 4px (every page's header). - Feed: toolbar wrapper pt-4 → pt-2, the toolbar's own pb-3 → pb-1.5, and the grid's top pt-2 → pt-1. Measured on the feed: header→toolbar 24→12px, toolbar→chips 12→6px, chips→cards 12→8px. - Plex / Playlists / ChannelDiscovery / DownloadCenter / Notifications / AdminUsers: toolbar wrapper pt-3 → pt-2, so every page's top band matches the tighter feed. PageShell.dom.test updated for the 4px gap. |
||
|
|
76f8de258c |
refactor(ui): R10 — .btn-primary recipe for the primary/action buttons
Add a paint-only .btn-primary recipe (solid accent bg + accent-fg text + hover:opacity dim) in index.css @layer components, alongside the selection-paint recipes (.pill/.chip/.is-current). Migrate ~38 primary/action buttons across 24 files off the hand-rolled 'bg-accent text-accent-fg hover:opacity-90', keeping each site's geometry (padding/radius/font/gap/disabled/shadow). Deliberately NOT migrated: accent BADGES/labels (spans), the chat message bubble, selection toggles (tableHeader — R8 popover territory), and the round play FAB (hover:scale, not the opacity-dim primary idiom). Three previously hover-less primary buttons (SettingsPanel/SetupWizard/DraftSaveBar) gain the standard hover dim for consistency. Gate green (tsc/eslint-0/prettier/knip/vitest 79); self-E2E confirmed a migrated button computes to accent/accent-fg and both recipe rules load. |
||
|
|
0559e1a503 |
refactor(input): R10 S3b — one input recipe, ring focus everywhere
Unify text inputs onto the form.tsx inputCls look (surface bg, rounded-lg, 2px accent RING focus), replacing the settings-family border-focus style app-wide: - deleted 3 duplicate local `const inputCls` (KeyGate/SettingsPanel/SetupWizard) → import the shared one; a 4th (Welcome, py-2.5) kept local but re-styled to match. - ~30 inline settings inputs: default full-size fields → inputCls; size variants (pager/ table-filter/chat textarea/plex/glass-card) keep their width/radius/padding but swap bg-card→bg-surface and border-focus→ring-focus. glass-card/bg-transparent bgs left as-is. - refreshed the form.tsx doc-comment (the old two-style split is gone). The ring is the more visible / a11y-correct focus cue, matching R9's focus foundation. |
||
|
|
ab873bbcaa |
feat(theme): R10 S2 — re-close the rem system (px-frozen labels/chrome -> rem)
The ~124 px-frozen text-[Npx] labels didn't track the Settings font-scale slider while their rem-sized neighbours did. Add text-2xs (0.6875rem) / text-3xs (0.625rem) font-size-ONLY tokens (no paired line-height -> matches the arbitrary they replace, zero vertical-rhythm shift) and codemod every text-[Npx] to a rem class (rare 8/9/12/13px -> rem arbitraries). Also: --hdr-h 68px -> 4.25rem so header content clears the rem-sized pills at scale 1.3 instead of sliding under; NavSidebar/SidePanel slot widths + their icon/badge px sizes go rem so the rails keep pace with their (now-scaling) labels; a w-24/h-[54px] thumb -> h-13.5 to preserve 16:9 at scale. Media thumbnails, page-width caps and fixed container widths deliberately stay px. Verified via getComputedStyle at scale 1.06/1.3: text-2xs 11.66->14.3px, header clearance 72->88px, nav slot 246->302px — all scale proportionally. |
||
|
|
d794524adf |
feat(theme): R10 S1 — semantic status colors (danger/warn/success)
Add theme-scoped --danger/--warn/--success (+ -fg) tokens wired into the v4 @theme inline, replacing ~110 raw red/amber/emerald/green palette refs across 34 files. Fixes light-theme contrast failures (fixed Tailwind shades fell to ~1.7-3:1 on white). Status colors are scheme-independent — recognised by hue, shifting only by theme to hold contrast (bright on dark bg, darkened on light). Solid fills use the paired -fg text; @source inline guarantees the full -fg set exists. IMDb rating gold kept as a deliberate decorative non-status exception. |
||
|
|
70bda98868 |
build(deps): migrate Tailwind CSS v3.4 → v4
Root-fixes the opacity-modifier seed bug behind R10 S0: the theme colors were bare `var(--x)` hex with no <alpha-value>, so `bg-accent/40`, `ring-accent/40`, `border-border/60`, `text-muted/70`, … (~99 sites across 45 files) emitted no CSS rule at all — latent no-ops. v4 implements the opacity modifier via `color-mix`, so the modifier now works with the existing hex vars, no per-scheme token duplication needed. - `npx @tailwindcss/upgrade`: `@tailwind` directives → `@import "tailwindcss"`, JS config → CSS `@theme` (colors, --font-sans, the z-index tokens preserved), classname renames (rounded/shadow/blur scale, outline-none→outline-hidden, ring→ring-2) across 56 files; PostCSS plugin → @tailwindcss/postcss, autoprefixer removed (v4 prefixes for you). - `@theme` → `@theme inline` (load-bearing): the color vars are redefined at runtime under html[data-scheme]/[data-theme]; inline resolves them at the use site so per-scheme opacity tints track the active scheme (the shadcn v4 pattern). - Revert one codemod false-positive: it rewrote the English word "rounded" → "rounded-sm" inside a releaseNotes.ts prose sentence. Gate green: tsc(app+node) / eslint 0-err / prettier / vitest 79 / knip. Verified in-browser: bg-accent/15 now computes a scheme-tracking color at 0.15 alpha across all 6 schemes × 2 themes; the hand-rolled glass border/shadow/ radius/backdrop-filter render unchanged under v4. |
||
|
|
ad86d6d126 |
refactor(fe): query-key factory + migrate feed/channels/playlists (R7 S2a)
Introduce lib/queryKeys.ts as the single source of truth for React Query keys — the R7 driver was ~200 hand-written key literals across 51 files (`["feed"]` alone in 15+), where one typo silently orphans a key so an invalidate never matches. S2a migrates the three highest-traffic domains (feed/video, channels, playlists) plus the shared me/my-status/tags roots: ~90 literal sites in 18 files now call qk.*(). Pure substitution — each factory fn returns the byte-identical array the call sites used before, so TanStack's prefix matching is unchanged (qk.feed() still invalidates qk.feed(filters)). Co-invalidation clusters are deliberately NOT bundled into helpers (they differ per site), so no query's refetch scope changes. Nullable keys (playlist(selectedId), ytSearch(q)) accept null so a null segment is preserved (["playlist", null]) while a no-arg call is the bare prefix key — guard is `!== undefined`. Gate green: typecheck, eslint (0 err), prettier, 68 vitest. Smoke: app boots, feed/facets/tags/my-status all 200, no console errors. Factory extended to plex/messaging/admin/downloads/notifications in S2b. |
||
|
|
1c6b7a9a5a |
feat(ui): R3 S3 — surface swallowed errors
- notifyYouTubeActionError: on any non-403 error, show the server's own reason (err.detail, e.g. "Not enough quota left today") instead of the caller's generic fallback, which was discarded (U-3.2.8). 403 still offers the Connect affordance. - App boot error now offers a Retry (StateMessage) instead of a dead "Something went wrong" (U-4.2). - deletePlaylist prefers the server's reason over a blanket "couldn't delete on YouTube" that misleads when the failure wasn't the YouTube side. - Already covered in earlier sprints (verified): the live-search "Load more" already surfaces the quota error inline via err.detail, and ChannelPage subscribe/unsubscribe already route through notifyYouTubeActionError. |
||
|
|
e257e2aca0 |
style(frontend): Prettier config + one-time repo-wide format pass
Prettier 3 (pinned), printWidth 100, double quotes — chosen to match the existing hand-formatting and minimise reflow. This commit is ONLY the mechanical format pass (`prettier --write`) plus the config/ignore/scripts, isolated so it never pollutes a feature diff. Verified behaviour-neutral: typecheck, eslint (0 errors), and vitest all green before and after. See .git-blame-ignore-revs — `git blame` skips this sha. |
||
|
|
f87d58d8d7 |
fix(playlists): name the view state in one sentence
Sorting a playlist whose stored order already matches the sort produced a warning, no Save button and no visible change — which reads as a malfunction, not as "there is nothing to save". And when the order DID change, three separate texts piled up in the toolbar (a reset link, a save hint, a "reordering is off" notice), none of which answered the question actually being asked: did I just change my playlist? One line now names which of the three situations you are in — the view matches the saved order, the view differs and can be saved, or a filter is on and saving is impossible — and says in each case that the saved order is untouched. The disabled grip still explains itself on hover and focus, so that notice no longer needs to stand in the toolbar. |
||
|
|
cdec80547e |
fix(playlists): UAT round 1 — drag lag, an invisible default sort, dialog layout
- The dragged row trailed the cursor by 150ms. The row carried Tailwind's bare `transition` utility (copied from DataTable's <tr>), which animates TRANSFORM — exactly the property dnd-kit drives from the pointer. transition-colors now, and the row being dragged drops dnd-kit's own transition too. - The list looked sorted by Channel when it was really in its stored order that happens to be channel-grouped, and nothing said so. Sorting by "#" ascending IS the stored order, so it is now the neutral state: the header shows a real arrow for it, cycling off any column lands back on it, and it stays the state where drag is enabled and no save is offered. - A choice dialog with two or more actions stacks vertically. Side by side they wrapped raggedly, and the wrapped one read as a lesser second group when the two are peers. Cancel goes last, actions run least to most destructive. |
||
|
|
6dc7b9268a |
fix(playlists): the table overflowed the page scroller
A truncate cell is white-space:nowrap, so its min-content width is the whole title — under table-layout:auto the Title column grew until Length and the remove button hung off the scroller (measured: table 1059px inside an 855px wrapper, main.scrollWidth 1074 vs clientWidth 887). Fixed layout with a declared width on every column but Title, which absorbs the rest. |
||
|
|
efd46a01c8 |
fix(playlists): address the review round — 11 findings
Header (shared, so every table gets these):
- The filter funnel could not close itself: useDismiss only knew the panel, so
the trigger's mousedown closed it and the click reopened it. It now knows the
trigger, captured at open time so it survives the close.
- Escape stranded focus on <body>; it goes back to the funnel, which also gained
aria-expanded/aria-haspopup.
- A non-sortable header rendered a focusable no-op button — two unlabeled dead
stops per playlist table, whose grip and actions headers are empty. Plain span
unless the column sorts.
Playlist:
- The disabled drag grip explained itself to nobody: `disabled` drops a button
from the tab order and suppresses its tooltip. It is aria-disabled now, stays
focusable, and the reason is its accessible name.
- That reason also lied on a built-in list, where the block has nothing to do
with sorting or filtering. Third branch, own string.
- The open player followed a position in a derived list, so a refetch or a view
change could slide a different video under it or unmount it mid-playback. It
follows the video id, like PlayerModal's own queue already does.
- A failed reorder could not resync: the id set is unchanged by a reorder, so
the arriving detail was judged "same list" and the rejected order stayed on
screen as the base for the next drag.
- The same guard is now keyed by playlist. Two lists holding the same videos
looked identical to it, so the second rendered in the first's order and
inherited its undo stack — where Undo would write it back to the wrong list.
- A one-item list filtered to nothing had no Reset view: the whole control strip
was gated on the item count while the funnels rendered regardless.
- Both delete outcomes now read as actions ("Delete here only" / "Delete here
and on YouTube"); "Here only" scanned as the safe choice while also deleting.
- Keyboard dragging shipped with dnd-kit's hardcoded English screen-reader
instructions and announcements. Localized, with live positions.
|
||
|
|
83e673718f |
feat(playlists): hybrid table over the drag list, and guard its destructive paths
The column sort and the filters are a VIEW; the playlist's stored order only changes when the user presses "Save this order", which still goes through the undoable path. Picking a sort used to write straight to the server, so merely looking at the list by title rewrote the playlist and marked a YouTube-linked mirror dirty. The table: - playlistColumns.tsx holds the column defs; the body renders them as table rows and fills the grip cell itself, since the handle needs the row's sortable listeners that a render closure can't reach. - The # column shows the STORED position, so a view-sort never renumbers the playlist under the reader. - Saving is withheld while a filter is active (it would drop the hidden items), and drag is disabled under any view, with the reason spelled out rather than silently doing nothing. - applyView/sameOrder live in lib/playlistView.ts with tests: the sort and the channel grouping compose, and getting that wrong produces a plausible order that the save button would then persist. - dnd-kit KeyboardSensor: the grip was focusable and called itself "reorder", but only a pointer could move it. The guards: - Delete is ONE dialog with every outcome. It was two chained confirms where the second one's Cancel/Escape/backdrop meant "delete here only" — dismissing the dialog deleted the playlist. ConfirmProvider gained useChoice() for it. - ConfirmProvider focuses Cancel when an offered action is destructive; the confirm button took autoFocus unconditionally, so a stray Enter on an open dialog ran "Delete user" or "Clear audit log". - Removing an item asks first, and rebases the undo history instead of throwing it away: useUndoable.rebase maps every snapshot, so the reorder history stays valid for the remaining items. - A failed detail query showed "Loading…" forever; it now says so and offers a retry. Rename and reorder failures are surfaced instead of swallowed (reorder was a bare .then(), which also produced an unhandled rejection). - Local reorder/remove no longer require YouTube write scope. - playlistName() replaces the watch-later localizer copied into three files. |