8e1004fe6fbf49cae05eec6689eca2db9cbbea98
7
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7d682c90d5 |
build(frontend): React 18 -> 19 with the React Compiler (E27 S1)
The compiler auto-memoises, which is the point: it infers what the app's 112 `useCallback` and 48 `useMemo` call sites were hand-written for and applies the equivalent itself. It is on repo-wide with no opt-out list — it bails on anything it cannot prove safe, and says so through the lint rules. Verified in the built bundle rather than assumed: 369 `react.memo_cache_sentinel` markers across 35 of the 76 chunks. ONE transform, shared by vite.config.ts and vitest.config.ts through reactPlugin.ts. Not two `react()` calls: if the compiler ran only in the production build, every vitest run would exercise code the user never receives and a compiler-induced change would ship green — the same gate-vs-image split 0.93.2 was released to fix. The plugin is imported rather than named by the usual string, because a string is invisible to knip and, worse, a typo in it is a silent no-op that ships an uncompiled bundle, while a bad import is a tsc error. eslint-plugin-react-hooks 5 -> 7 brings the whole compiler rule set: 283 findings, triaged rather than bulk-suppressed, because the two halves needed different answers. react-hooks/refs (217) is OFF. 153 of those 217 are this app's popover architecture, not its history: 79 are @floating-ui/react's documented `pop.refs.setReference` / `getReferenceProps` callback-ref setters, and 74 more are passing the popover handle down as a prop, which is what components/ui's popover engine is for. Suppressing them would not help the next popover anyone writes. The remaining ~64 are genuine render-phase `ref.current = x` writes and are real, but the rule cannot tell them apart here; they are E27 S2's work. The other 66 go into eslint-suppressions.json as a ratchet — new violations fail the gate, and `--prune-suppressions` makes a fixed one fail too until it is removed. 45 of them are `set-state-in-effect`, which IS E27 S2's subject, so that sprint now has a measured worklist. react-i18next deliberately stays on 14. It never declared a React-19 peer — every version from 14 to 17 asks only for `react >= 16.8.0` — and what the newer lines actually require is i18next 25/26 against our 23. That is an i18n runtime migration wearing a React hat, and it buys this sprint nothing. Two React 19 type changes: `useRef<T>(null)` is now `RefObject<T | null>` (the honest shape — the element is null until it mounts), so PlexPlayerSettings' prop follows; and reactPlugin.ts joins tsconfig.node.json's include. The compiler skips memoising VirtualGrid and reports it: TanStack Virtual's `useVirtualizer()` returns functions that cannot be memoised without going stale. That is the right trade for a component whose cost is its row window, and it is the one such site in the app, so it carries a targeted disable with the reason — a second incompatible library anywhere else must still stop the gate. Nine lanes green, vitest 1558. Browser UAT on the dev server against the still-running React 18 build as a control: boot, feed (virtualized grid, popover menu, a filter change that moved the list 195,187 -> 64 items with the URL mirrored, so no stale memoised UI), command palette, player modal — no console errors on any of it. |
||
|
|
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.
|
||
|
|
3cd0c525e2 |
fix(keys): epic-end review round 2 — focus comes back to the card, the right rail and the reason
Whole-epic deep-review of E11 (18 raised, 12 kept; B and G refuted, four latent test-hardening notes dropped), plus the admin refusal copy gap round 1 surfaced. - A context menu opened from a card the keyboard is on gives the focus back to the card. floating-ui returns focus through the opener's first tabbable descendant even when handed a ref, so for a non-tabbable opener the surface restores it itself (measured in Chrome: j, Shift+F10, Escape left it on the thumbnail link). - Back finds a named list (a rail) by its name: a rail that appeared above it after the cache expired sent the focus into the wrong rail. An unnamed list is matched only when the page has the same number of lists. - The slot kept for a removed item is forgotten once focus moves on to page content (search, a chip), so j after new results starts at the first visible item; a menu or dialog opened from the item keeps it. - The list keys reach the cheat sheet only while a list has an item. - A j-focused card root is a named group while it holds the focus. - The channel page's Block and Hide toggles keep the focus while their request runs. - A split export that fails part-way says how many clips were added and deselects them, so a retry adds only the rest. - Config save, the one-click share link and "log out other sessions" report failures with their reason. - VirtualGrid uses the one scroll-parent walk listNav measures against. - Admin refusals (activate, resend, invite, set-password link) say why in HU and EN instead of "try again"; the email-off special cases collapse into that one reason. The contract reads the codes from localizeDetail. HU copy points to Konfiguráció → E-mail / SMTP; HU "unreachable" matches the EN change of round 1. |
||
|
|
8fb276aa90 |
feat(keys): j/k walk the feed, s finds the search box, g jumps to a module
A feed card cost ~10 Tab stops, so the tenth card sat ~135 Tabs from the top of the page, and the only keyboard focus ring (a 60% accent outline on the thumbnail) all but vanished on the dark glass. First slice of the E11 keyboard layer (S4a); Home, Plex and the tables follow in S4b/S4c. - `lib/listNav`: one window listener over every registered list, walked in document order. `j`/`k` step item to item (on into the next list — owner decision D4), the arrows move around a grid, Enter clicks the item's own `data-nav-open` element, so it opens exactly like a click. An item root is never a Tab stop: it gets `tabindex=-1` only while the keyboard is on it, and keeps it until the next item takes it, so the player's focus restore lands back on the card. The first `j` takes the first card you can see, not the top of the list. - `VirtualGrid` takes part with `keyboardNav` (column count + scrolling an unmounted row in); `VirtualFeed` turns it on, so the feed, the library and the channel page all have it. - Keymap: two-step sequences (`g h`) whose pending step belongs to the scope that started it; the page-level scopes (`page`, `search`, `list`) are quiet while a layer that owns the keyboard is open, enforced in `dispatch`. A test pins the owner's rule that none of them needs a modifier on the Hungarian layout. - `g h/f/c/p/x/n/m/d/s/,` jump to a module this account can reach; `s` focuses the header search box and selects the old query; Escape in it hands the keyboard back to the page (Plex's typeahead spends its first Escape on its panel). - Hover popovers are passive layers: an open tooltip still takes Escape first but no longer silences the page keys. - Focus ring (owner decision D2 = B): solid 2px accent around the whole item; the app-wide button/ link/row ring goes from 60% to solid too. The item keeps a transparent resting outline because the video card transitions `all` — without it every `j` flashed a 3px white ring (measured in Chrome). - Help sheet: the new keys, in a fixed order after the surface's own; a `g` chord only for a reachable module; opened over a player or dialog it leaves out the page-level keys and the context-menu key, which nothing can reach there (the latter pre-existing since E10). - CLAUDE.md: a canonical-patterns row for list/grid keys (`useListNav` + `navItemAttrs` + `data-nav-open`, not a private arrow-key handler). |
||
|
|
80dea4f307 |
fix(e10): review round 1, part 2 — the docs the epic invalidated, and three contracts made real
Finishes round 1. Part 1 took the runtime bugs; this takes the prose that had gone false and the three "the comment says so" gaps behind it. CLAUDE.md is auto-loaded into every agent session and ui/README.md is not, so an architecture paragraph that still described a flat components bag was the most expensive stale sentence in the repo: the next agent adding a popover reads it, sees no platform layer, and writes the generic component into components/ — the invisible-layer failure this epic exists to end. It now names components/ui/ and says the boundary is lint-enforced. The canonical-patterns table gained the three rows the epic established: ui/Button over a hand-rolled <button>, a keymap row over a private keydown listener, and disabled-when-busy vs inert-when-inapplicable. ui/README.md's inventory was written in this branch's first commit and never learned about CommandPalette, ContextMenu/useContextMenu, KeyboardHelp or gridColumns — in a section whose own closing rule is that a second component painting the same thing is how the last inconsistency happened. It also claimed "everything is rem-derived" while VirtualGrid's minCol/GRID_GAP are deliberately px; that is now stated as the exception it is, and a new section records which promises here are tests rather than sentences. Three contracts stopped being prose: - A text-less Button must carry a name. "Required for iconOnly" was a doc comment, which is why the migration shipped two nameless icon buttons through eight green lanes. It is a union now, covering both spellings (iconOnly, and size="icon"/"icon-sm"), satisfied by label, aria-label or title. It caught one more on the way in: Banner's dismiss took an optional dismissTitle, so it could render unnamed — it falls back to common.close now. - gridColumns' gutter arithmetic. Every existing assertion survived replacing floor((w + GAP) / (minCol + GAP)) with the naive floor(w / (minCol + GAP)): the fixed widths agree, the capped cases are cap-bound, and the loops compare columnsFor against itself. Pinned at the exact-fit boundaries where the two first disagree (536 and 828). - ShareDialog's link row. Only Copy was migrated in S1, leaving one 2rem button between two 1.75rem ones — and on a coarse pointer .touch-target grew only the migrated one to 2.75rem while the destructive Revoke stayed small. Also: the maxCols docstring said 8 columns of 384px where the test it shipped with asserts 369. |
||
|
|
7d302f71b7 |
feat(e10): let an ultrawide buy bigger cards instead of more of them
The viewport contract has two ends, and only the floor was enforced. An auto-fill grid answers "more room" with more columns, never bigger ones, so it settles just above its minimum column at any width: measured at 3440px, the feed laid out 11 columns of 264px. A feed diluted across an ultrawide fails the contract as surely as a broken one at the floor. Multi-column feed views now carry a `maxCols`; past it the leftover width flows into the items. At 3440: cards 11x264 -> 8x370, cardsSmall 15 -> 11x264 (still visibly the dense card, and never as wide as cards gets), tiles 7 -> 6x498. Each cap is set so the item tops out near 1.5x its own minCol, which is what keeps a view recognisably itself. A cap can only bind ABOVE the width at which that many columns already fit, so it is a pure ultrawide affordance — 1024, 1920 and 2560 lay out exactly as before. Verified in the browser at all three widths and across all five views. The Channels grid needs no cap: the page's own 96rem --content-mw already bounds it. Also renames VirtualGrid's `maxCol` to `colMaxWidth`. It was never a column count — it is the single-column reading-width cap — and `maxCol`/`maxCols` one letter apart is the kind of drift that has bitten this repo before. The column maths moves to ui/gridColumns.ts as a pure function so both ends of the contract are unit-testable without a layout engine; the tests assert the cap's numbers, that it never binds at the floor, and that the dense card cannot grow into the standard one. |
||
|
|
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. |