Commit Graph
9 Commits
Author SHA1 Message Date
peter 97381e65d0 feat(design): a page's background menu on the five list pages (E28 S3e4c)
A right-click on the EMPTY area of a page opens a menu of that page's own
toolbar: the feed (sort, reshuffle, view, watch state, content, source, clear
filters), the Plex library grid (sort, watch state, clear), the channel manager
(layout, the cards' sort, sync status, edit tags, clear the tag filter, read
subscriptions), a playlist (play all, group by, reset view, save order,
undo/redo, the list's own menu) and the notification center (mark all read).
No destructive row and no backfill.

PageShell listens on the page area; lib/backgroundTarget decides empty (not
text, a control, a link, media, or a row/card with its own menu). Shift+right-
click, a touch long-press and a right-click on selected words keep the
browser's menu. ui/PageMenu registers while mounted, so a loading or error
state keeps the browser's menu too. The rows mirror each control through
ui/pageMenuItems (sortSubmenu via pickSort, choiceSubmenu, multiSubmenu).
pageMenuScope.contract.test.ts pins the five pages.
2026-09-26 20:48:53 +02:00
peter bd0914b7ed fix(design): a right-click on selected words opens the browser's own menu (E28 S3e4b)
The row menu took every right-click, so selected text could only be copied
with Ctrl+C. On the selection (its laid-out line boxes) the event is left to
the browser; beside it the object's menu opens as before.
2026-09-26 19:03:34 +02:00
peter c4917619ee fix(design): a row's "…" closes its own menu when its row object is rebuilt (E28 S3e2)
The "…" told its own menu from another row's by payload identity. The Plex playlist page builds
each row's menu target afresh on every render, so a second press on the same "…" reopened the
menu instead of closing it. useContextMenu takes an optional `keyOf`, and the page keys its
targets by the row key.
2026-09-26 07:10:02 +02:00
peter 75ae810866 feat(design): a video's one action list (C1) — the card's "…", the menu holds the rest (E28 S3e1)
- useContextMenu gains buttonProps (a row's visible "…" opens the same menu under the button; a
  second press closes it) and anchor (the element the menu came from, for a panel a row opens).
- ui/MoreButton; ActionMenu rows can run inGesture (a clipboard write inside the click, Safari).
- useVideoActions: one builder for a video's menu (play / restart, channel, watched, save, add to
  playlist, download, details, copy title / link, hide, reset, pin), composed by the feed and by
  Home's rails; the playlists and details panels open anchored beside the card (VideoFollowUp).
- The card keeps watched / save / add to playlist and the "…": on the picture on hover (the
  duration steps aside), in the rows' action column; on touch only a 44px "…" beside the title.
  Download, hide, reset and the hover info card leave the card.
- The title is selectable: a click ending a selection neither plays nor follows the link.
- DESIGN.md: how C1 is built + the log row.
2026-09-26 01:23:44 +02:00
peter 29875b7367 fix(ui): stable shelf-card menu triggers, a named Button by type, true layout comments
- Home's rail handed each card a fresh `() => menu.triggerProps(c)` per
  render, defeating memo(VideoCard) on every tile whenever the menu's own
  state changed. `useItemTriggers` (ui/useContextMenu) keeps one trigger per
  item, index-aligned; tested for identity across renders.
- The Button accessible-name union had one hole: `<Button icon={…} />` with
  no children and no size typechecked into a nameless button. A textful
  button now needs children or a name; pinned with @ts-expect-error.
- comfortPin's header said the pin belongs "on every card and row", which
  the code never meant (an episode, a season, a person are not pin kinds).
  It now says what is pinnable — and the show page's Related strip, whose
  shows ARE pinnable, gets the pin menu it was missing (and a rem width).
- gridColumns / feedView / VirtualGrid claimed layouts 1024–2560 are
  "byte-identical" under the column cap; the small cards are capped at 2560
  with the filter rail collapsed. The comments say so.
2026-09-19 07:40:48 +02:00
peter 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.
2026-09-17 02:59:09 +02:00
peter 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.
2026-09-06 01:57:32 +02:00
peter 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.
2026-09-05 23:52:29 +02:00
peter 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.
2026-09-05 19:59:56 +02:00