1147 Commits
Author SHA1 Message Date
peter 9445e4d71a release: 0.49.0 — R2 guardrails & test net
Internal-only epic (gates, tests, compiler strictness); no user-facing change, so
the release note is chores-only. Bundles: the siftlode check/publish gate
(tsc/eslint/prettier/knip/vitest/pytest) with a hard pre-publish gate + e2e
freshness guard, ESLint flat config + one-time Prettier pass, the backend pytest
harness (isolated test image), frontend vitest growth, the e2e scroll-restore fix,
and noUncheckedIndexedAccess (+ the zero-fallout compiler flags).
2026-07-21 04:30:51 +02:00
peter d717dfec74 Merge: R2 S3 — tighten the compiler (noUncheckedIndexedAccess + zero-fallout flags) 2026-07-21 04:28:02 +02:00
peter e006cc879f refactor(frontend): satisfy noUncheckedIndexedAccess (S3b)
Enable noUncheckedIndexedAccess and fix all 98 call sites it surfaced across 18
files. Every fix is behaviour-preserving — the flag flagged reads TypeScript
couldn't prove in-bounds, all of which were already guarded by a length/index check,
a findIndex result, or a non-empty invariant:

- read-once so a ternary's narrowing sticks (Feed overrides, linkify mention);
- non-null assertion AFTER an existing guard (PlayerModal/VideoEditor queue+cut-list
  indexing, modules step, useUndoable stacks) with a comment stating the invariant;
- nullish fallback where undefined is a real possibility (GlassTuner slider ?? def,
  ConfigPanel active group ?? [], descriptionLinks id ?? null);
- optional chaining where the entry genuinely can be absent (PlexBrowse IO entry);
- a non-empty tuple type for the RELATIVE_UNITS constant (encodes "always has [0]").

The single highest-leverage fix: PlayerModal's `active` became `queue?.[index] ??
video` (always Video), clearing 34 of the 45 errors in that file at once. Verified:
tsc/eslint/prettier clean, 56 vitest + 34 pytest + 17/17 e2e green (the e2e suite
exercises the feed/player/channel components touched here).
2026-07-21 04:11:19 +02:00
peter 3fe0897fd3 build(frontend): enable the zero-fallout compiler flags (S3a)
Three strictness flags that the codebase already satisfies (0 errors each):
- noFallthroughCasesInSwitch on src + e2e — a missing `break` is now a type error.
- noUnusedLocals + noUnusedParameters on the e2e project, so it matches src and node
  and dead spec scaffolding can't accumulate (it was the one project without them).
- allowJs + checkJs on the node project, pulling tailwind.config.js and
  postcss.config.js under the type checker (the last hole in C-4.5) — a real type
  error in either is now caught (verified: `content: 123` fails; Config stays
  permissive on plugin keys by design).

noUncheckedIndexedAccess is NOT here — it has 98 call-site fixes and lands as its own
reviewable sub-sprint (S3b).
2026-07-21 03:47:55 +02:00
peter 62cab204ea Merge: R2 S2b — backend pytest harness (pure-logic tests + isolated test image) 2026-07-21 03:43:38 +02:00
peter 4bae9113e4 test(backend): pin the traversal guard against a string-prefix bypass
Review round 1: the two existing safe_abs_path escape tests both pass even against
the classic buggy `str(path).startswith(str(root))` containment check, so a
regression to that bug would have shipped green. Add the one input that
distinguishes the correct `root not in path.parents` guard: a sibling dir whose name
prefixes the root (/root vs /rootx). Mutation-verified — swapping the guard to
startswith now fails this test. Also corrected the requirements-dev header (installed
by Dockerfile.test, not a nonexistent `dev` stage).
2026-07-21 03:36:42 +02:00
peter 33dbf70313 test(backend): pure-logic pytest harness, run in an isolated test image
The backend had zero tests. This adds 33, all pure (no DB, no network), plus the
harness to run them in the gate:

- backend/Dockerfile.test: the app's deps + pinned pytest/ruff, code bind-mounted at
  run time so it always tests the working tree. Isolated from the prod Dockerfile, so
  the published image never carries test tooling.
- requirements-dev.txt: pytest==8.4.2, ruff==0.15.21 (the version the gate already
  runs) — the backend lane is now pinned, not just host-global.
- tests: normalize_title (de-shout, hashtag strip, emoji drop, trilingual letters
  survive); storage sanitize/rel_path/download_filename and the safe_abs_path
  traversal guard (../ and absolute-path escapes rejected — verified by mutation);
  links HMAC grants (round-trip, wrong-token, tampered sig/exp, expiry) + is_expired;
  a DB-free app-assembly smoke (every router wires up, OpenAPI generates).

DB-backed router smoke (auth/feed/downloads) needs a test Postgres + migrations —
deferred. useCardPager needs hook-test infra (jsdom/renderHook) — deferred.
2026-07-21 03:29:24 +02:00
peter eea1a22572 Merge: R2 S2a — e2e scroll-restore fix + columnSort/linkify unit tests 2026-07-21 03:17:16 +02:00
peter bdb0185db8 test(frontend): unit-test columnSort and linkify
22 vitest cases for two pure helpers that had none:
- columnSort: numeric-vs-locale comparison, asc/desc, no-mutation, the null/junk
  coercion in parseSortState, and the unsorted→asc→desc→unsorted click cycle.
- linkify: http/www/bare-domain URLs, the version-number non-match, the
  "<platform>: @handle" mention (handle linked, prefix kept as text), alias→canonical
  base URL, bare @handle/#hashtag left plain, and multi-link ordering. Nodes are
  inspected structurally, so the existing node-env vitest needs no DOM.

useCardPager (a hook) is deferred to when component/hook test infra lands with the
backend pytest sprint.
2026-07-21 03:13:26 +02:00
peter 3c119b66c7 test(e2e): click a visible channel card in the scroll-restore test
The "Back restores the feed scroll position" spec failed against the production
build while the app was in fact correct. Root cause, found by instrumenting
PageScroller and comparing the baked :8080 build to live :5173:

`getByTestId("video-card-channel").first()` grabs the first card in the DOM, which
after scrolling 3000px is in the virtualizer's overscan ABOVE the viewport.
Playwright scrolls it into view to click it, moving the feed from 2879 to ~1017–1253
BEFORE navigating; PageScroller's save-on-scroll records that moved position, so Back
faithfully restores the moved position — under the test's own tolerance. A real user
clicks a card they can see, which does not move the feed.

New helper clickVisibleChannelLink() clicks a card whose bounding box is inside the
viewport. Restore now lands at ~2804 (was ~1017). 17/17 pass; stable over 3 reruns.
No app change — the scroll-restore logic was already correct.
2026-07-21 03:10:19 +02:00
peter f95d889a92 Merge: R2 S1b — ESLint flat config + Prettier gate lanes 2026-07-21 02:56:45 +02:00
peter d222751fe2 chore: add .git-blame-ignore-revs for the Prettier format pass
Lists the repo-wide format commit so `git blame` skips it. One-time local setup:
`git config blame.ignoreRevsFile .git-blame-ignore-revs` (Forgejo honours it too).
2026-07-21 02:26:53 +02:00
peter 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.
2026-07-21 02:26:40 +02:00
peter 04ecd8b3a7 build(frontend): add ESLint flat config as a gate lane
ESLint 9 flat config, pinned. Narrow by design: the load-bearing rule is
react-hooks (rules-of-hooks + exhaustive-deps) — 33 disable comments existed for a
rule nothing ran. tsc already owns unused vars / undefined / types, so those are
turned off here to report each finding once. no-explicit-any is off: all 19 live in
the api.ts god-module that R7 rewrites, and it flips the rule back on then.

reportUnusedDisableDirectives is an error, which immediately caught two inert
`eslint-disable` comments (App.tsx, AddToPlaylist.tsx) whose effects have stable
deps — removed. no-unused-expressions allows the side-effect ternary idiom the
codebase already uses (`v.paused ? v.play() : v.pause()`).

Errors: 0. Remaining 38 are warnings (28 react-refresh DX, 10 exhaustive-deps) —
pre-existing, visible in the lint output, tracked to their epics.
2026-07-21 02:25:12 +02:00
peter dc991111a9 Merge: R2 S1 — quality gate wiring (build typecheck, knip pin, publish gate) 2026-07-21 02:17:25 +02:00
peter 33abf2a4d8 fix(build): stop the host tree leaking into the image, split the tsc lanes
Review round 1 fallout, all of it verified against real builds:

- .dockerignore: `node_modules/` is anchored at the context root and has no
  implicit `**/` (unlike gitignore), so it never matched frontend/node_modules.
  `COPY frontend/ ./` was overlaying the Windows host's node_modules on top of
  what `npm ci` had installed — the image carried @esbuild/win32-x64 and 31 .cmd
  shims, and the new typecheck ran a tsc resolved from that merged tree. Build
  context transfer drops 3.05 GB -> 19 kB. Also excludes e2e/.auth (a live
  Playwright session), the report/result dirs and logs.
- build no longer type-checks the e2e project: a type error in a spec must not
  make the app unbuildable for a self-hoster. The gate still checks it via
  `npm run typecheck` (now three named lanes).
- tsconfig.json references tsconfig.node.json, so editors check vite.config.ts /
  vitest.config.ts under the same config the gate uses instead of an inferred one.
- vitest include accepts .test.tsx: an uncollected test file does not fail, it
  silently never runs.
2026-07-21 01:37:53 +02:00
peter 172ab8008f build(frontend): type-check the production build, pin knip
`npm run build` now runs `typecheck` first, so the Docker image is built through the
same gate as local dev — until now the image was a bare `vite build`, and esbuild
strips types without checking them.

typecheck covers three projects: src, the Node-side build configs (new
tsconfig.node.json — vite.config.ts/vitest.config.ts were type-checked by nothing),
and e2e. knip moves from `npx knip@latest` to a pinned devDependency so the gate
stops depending on the network and on whichever version shipped that morning.
2026-07-21 01:07:54 +02:00
peter 25f0d71dfc chore(frontend): unexport two ConfirmProvider-internal types
knip flags them: both are only referenced inside the file. Clearing them is what
makes the lint lane green enough to become a hard publish gate.
2026-07-21 01:07:54 +02:00
peter 486a5cd1b0 Merge: promote dev to prod 2026-07-21 00:47:08 +02:00
peter 855ae0c995 release: 0.48.0 — playlist column headers, and sorting stops rewriting the list 2026-07-21 00:46:41 +02:00
peter a94da079ba Merge: E4 S4 — playlist hybrid table (view-sort + explicit save)
Closes E4. The playlist detail gains real sortable/filterable column headers
over the drag-reorderable list, and the crucial distinction: the sort and the
filters are a VIEW, while the stored order changes only on drag or an explicit
save. Picking a sort used to write straight through to the server.

Also extracts the column header out of DataTable (shared by both tables now,
which fixed the filter popover remounting on every keystroke), and closes eight
findings from the 2026-07-20 reviews that lived in these files.
2026-07-21 00:43:36 +02:00
peter 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.
2026-07-21 00:40:49 +02:00
peter 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.
2026-07-21 00:03:07 +02:00
peter 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.
2026-07-20 23:48:50 +02:00
peter 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.
2026-07-20 23:45:31 +02:00
peter 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.
2026-07-20 23:30:49 +02:00
peter 89000538d4 refactor(table): extract the column header out of DataTable
The sort cell, filter funnel/popover and filter predicate move to
components/tableHeader.tsx so a non-DataTable body can render under the same
headers — the playlist manager's drag-reorderable list needs the headers
without inheriting DataTable's internal ordering state.

FilterPopover reaches module scope in the process. It was declared inside
DataTable's body, so it was a new component type on every render and React
remounted it on each keystroke; verified against the pre-change build, where
the input's DOM node is replaced on all four keystrokes of a test word.

Also picks up two things that live in this markup: aria-sort on the sortable
headers, and a real hit target on the filter funnel (it was 14x14).
2026-07-20 23:18:46 +02:00
peter ca9fd2a5ce Merge: promote dev to prod 2026-07-20 22:33:37 +02:00
peter 742cf2df73 release: 0.47.0 — relative timestamps fixed, and localized by the platform 2026-07-20 22:31:30 +02:00
peter 72bd1d9e9d Merge: relative time localized by Intl.RelativeTimeFormat
Hands the wording to the platform instead of 12 hand-written strings.
Hungarian is unchanged; English becomes more compact.
2026-07-20 22:30:40 +02:00
peter 93b793dea2 Merge: relative-time tier shift fix + the first unit tests
Fixes a bug live since 0.2.0 (ea317c0): every relative timestamp named the
tier below its own range, so minutes read "Ns ago" and hours "N min ago".
Brings the repo its first unit-test runner along with it.
2026-07-20 22:30:40 +02:00
peter 7cd5adc78f refactor(format): localize relative time with Intl.RelativeTimeFormat
The tier table kept a divisor and a translation key as two independently
maintained halves, and a mispairing there rendered minutes as "Ns ago" for 59
releases. The table now pairs a divisor with the Intl unit it produces and the
platform supplies the words, so the wrong half is at least self-evident on
sight — "minute" next to 3600 reads wrong in a way "time.minutesAgo" never
did. It also removes 12 hand-written strings and hands plural rules to CLDR
for any language added later.

Hungarian output is byte-identical to the strings it replaces. English gets
shorter: "59 min ago" to "59m ago", "1 wk ago" to "1w ago", "1 mo ago" to
"1mo ago", "1 yr ago" to "1y ago"; "2h ago" and "1d ago" are unchanged.

style narrow matches the app's compact timestamps and numeric always keeps it
counting, rather than "yesterday"/"last week". time.justNow stays because Intl
has no wording for the sub-minute case. Formatters are memoized per language,
since this runs once per rendered timestamp across a virtualized feed.
2026-07-20 22:28:09 +02:00
peter dea63d8fd2 refactor(format): hoist the relative-time tiers and lock their invariants
Review follow-ups on the tier-shift fix. The table moves to a module-level
RELATIVE_UNITS: it is constant, so rebuilding it per call was pure garbage on
a path that runs once per rendered timestamp across a virtualized feed.

Its ascending order is load-bearing and was unstated — sorting it largest-first
would make every timestamp under a year read "just now", silently, the same
shape as the bug this branch fixes. It is now documented and asserted, and the
suite derives its tier coverage and translation checks from the table itself
rather than from its own case list, so a new tier cannot slip in uncovered.

The audit log read the empty string for an unreadable date as a value and
rendered a blank cell; it now uses the `|| "—"` idiom the channel table
already uses, so missing and unreadable dates look alike.
2026-07-20 22:17:51 +02:00
peter da728f0c5e test(format): cover the relative-time tier boundaries
The repo had no unit-test runner, so this adds the minimum: vitest (3.x, as
4.x needs Vite 6), a node-env config deliberately separate from vite.config.ts
so the production build never loads it, npm test / test:watch, and a knip
entry so the config and specs are not reported unused. Browser flows stay in
e2e/ under Playwright.

The suite samples every tier at its exact opening second as well as inside it,
and stubs i18n so each assertion lands on the key rather than the copy. An
interior-only suite still passes when the range comparison flips to <=, which
is how the tier shift went unnoticed for so long.
2026-07-20 03:06:50 +02:00
peter c61bc4ff74 fix(format): relative time labelled every tier one unit too small
The units table paired each divisor with the NEXT row's label, so the count
came out right and the word wrong, all the way up the scale: minutes rendered
as "Ns ago", hours as "N min ago", days as "N hours ago", months as "N weeks
ago". Most visible on the audit log, where scheduler rows three minutes apart
read "1s / 4s / 7s ago".

Each row now carries its own label and is read whole, so a divisor can no
longer drift from its word. Also guard a non-finite age, which rendered
"NaN mo ago", and drop time.secondsAgo now that nothing emits it.

Present since ea317c0 swapped the hardcoded English strings for i18n keys;
shipped in every release from 0.2.0 on.
2026-07-20 03:06:42 +02:00
peter 8501509acb Merge: promote dev to prod 2026-07-20 01:32:45 +02:00
peter 758e655642 release: 0.46.0 — channel manager overhaul (table/cards, About, shared sort, paging) 2026-07-20 01:31:15 +02:00
peter 5210e9c6fe Merge: E4 managers overhaul — bottom pager, About column, table/cards layouts (S1–S3) 2026-07-20 01:29:53 +02:00
peter 307ab96d84 fix(managers): two-way menu flip + extract useCardPager
- The tag menu's flip was one-way: once anchored above it stayed there, so a row
  scrolling toward the top dragged the menu off the screen. It now re-decides both
  ways each pass — prefer the side it is on (no flapping), leave it as soon as that
  side stops fitting and the other does. Verified: trigger at y=229 (no room above)
  flips back below, menuTop 253 = btnBottom 249 + 4, nothing off-screen.
- Extract the card pagination into lib/useCardPager: size (persisted, validated),
  page, the out-of-range clamp-commit, the slice and the Pager props lived twice,
  and every review round had to fix both copies in lockstep. The two tabs now keep
  only what differs — which rows, which storage key, and when to reset.
  The clamp is gated on the card layout too, so it no longer rewrites card state
  while the table is the one on screen.
2026-07-20 01:24:31 +02:00
peter ab5f3c9fbb fix(managers): address the E4 S3 code-review round 3 findings
- The tag menu's "trigger is gone" test now measures against the SCROLLER, not the
  window. The page scroller starts ~230px down (measured), so a row that slid under
  the fixed toolbar was still "on screen" by window coords and the menu floated over
  the chrome, anchored to something invisible.
- Scroll-repositioning keeps whichever side the menu is anchored to instead of
  resetting it to "below" every frame — that reset made the layout effect flip it
  back, costing two renders and two forced layouts per frame near the bottom edge.
- One placement rule (`coordsFrom`) now serves the initial open, the layout
  correction and every reposition, so those can no longer drift apart.
- Card page: reset on the filter/sort INPUTS only, and separately COMMIT the clamp
  when the page falls out of range. Keying the reset on the row count (round 2) was
  too blunt — a background refetch that merely added a channel threw the reader off
  page 7 for something they didn't do.
- Pager derives `pageCount()` once and `hasPagerContent` reuses it, instead of the
  same expression twice in one file.
- Re-indent the pagerNode JSX left dangling by round 2's ternary edit.
2026-07-20 01:10:13 +02:00
peter c630d29ff6 fix(managers): address the E4 S3 code-review round 2 findings
- The portalled tag picker now FOLLOWS its trigger on scroll (rAF-coalesced)
  instead of closing. Closing on every wheel tick was a regression for the table,
  where the menu used to travel with its row; it still closes once the trigger
  leaves the viewport (nothing left to anchor to) or on resize.
- Place the menu by MEASUREMENT, not a px estimate: it opens below, then the
  layout effect flips it above only if the real box overflows. The old
  `rows * 30px` guess read short at font-scale 1.3 (the rows are rem), which
  could leave a fixed menu hanging off the bottom with no way to reach it.
- `toggle` only opens once a position exists, so the `open && coords` gate can
  never show a stale anchor.
- One owner for "is there anything to page": `hasPagerContent()` lives in Pager
  and DataTable calls it, instead of both re-deriving the rule — that drift is
  what left empty control bands behind in the first place.
- Reset the card page on the row COUNT too, not just the filter inputs, so an
  unsubscribe or a shrinking refetch can't leave a stale page to spring back.
- `parseCardSize` clamps to the sizes the pager actually offers (like clampView /
  clampStatus), so the select can't display one value while the grid pages by another.
- VirtualGrid's docblock now carries the stacking-context constraint: its rows are
  transformed, so anything floating an item renders must portal out.
2026-07-20 00:56:47 +02:00
peter 260de48f41 fix(managers): the tag picker drew under the next card
In the card layout every VirtualGrid row carries a `transform`, which makes it a
stacking context — so the tag menu's z-index was scoped to its own row and the
NEXT card (a later DOM sibling) painted over it. Same hazard that buried the
players in E3, so the same blessed fix: render the menu through <Overlay> (i.e.
at <body>) with fixed coords instead of `absolute` inside the cell.

The flip-up case anchors the menu's BOTTOM to just above the button so it lands
exactly whatever its real height is, and the horizontal clamp measures the
rendered width rather than assuming px — `w-44` grows with the text-size slider.
Closes on outside click (dismiss now watches the cell AND the portalled menu),
and on page scroll/resize, where a fixed menu would otherwise strand at a stale
anchor. Verified in both layouts by hit-testing the menu's own pixels.
2026-07-20 00:42:33 +02:00
peter be9d17ebb1 fix(managers): address the E4 S3 code-review findings
- DataTable: keep `pagerNode` NULLABLE. Extracting Pager made it an always-truthy
  element, so the `controls` / `bottomControls` guards always fired and a table with
  nothing to page rendered empty `my-3` bands (plus an empty in-band PageToolbar).
- ChannelsProvider: `setView` bails when the tab is unchanged (re-selecting the
  active tab, or goToFullHistory forcing "subscribed", stacked dead Back entries),
  and pushes a FRESH state like setPage does instead of spreading `_chan`/`_yt`
  markers into the tab entry.
- Lift the layout into its own narrow context (`useChannelLayout`): App read the
  whole ChannelsState for the fade, so every keystroke in the manager's search box
  re-rendered the app root.
- Reset the card page when the row set or sort changes (both tabs) — the clamp only
  hid a stale page, which came back when the filter was cleared.
- Sort + slice the card rows only in cards mode; table mode was copy-sorting ~300
  rows per render for a result nothing read.
- Validate persisted `cardSize` / `sort` (`parseCardSize`, `parseSortState`), matching
  the clamp-on-read convention the module's other persisted values already follow;
  a corrupt size made the page count NaN and emptied the grid.
- Pager: page-size changes scroll to the top too (they re-slice from a new first item).
- ColumnSortControl: a stored sort naming a column this table no longer offers is
  treated as no sort, instead of showing a direction arrow for a sort that isn't applied.
- Drop the stale "rows" layout comments (that variant was retired this sprint).
2026-07-20 00:34:38 +02:00
peter a50608d026 feat(managers): actions-first discovery table, tabs in the back chain, pager scrolls to top
UAT round 4:
- Discovery table: move Actions to the first column, mirroring the Subscriptions
  table (where it sits right after Prio) so both tabs' actions line up.
- The manager's tabs now ride in history.state (`_chTab`), the same way the channel
  page (`_chan`) and the live search (`_yt`) already do — so Back steps back through
  the tabs you visited before it leaves the module. Arriving from elsewhere stamps
  the entry setPage pushed (one navigation never costs two Back presses).
- Turning a page now scrolls the list back to its top (both the tables and the card
  grid, since they share Pager) — landing mid-list in an unseen page is disorienting.
2026-07-20 00:14:23 +02:00
peter dff4152522 feat(managers): paginate the card layout + its top scroll-fade
UAT round 3 feedback on the card layout:
- Add a pager + "Cards per page" to the card view (it infinitely-scrolled before).
  Extracted the pager cluster out of DataTable into a reusable Pager component
  (the table now renders it too — no behaviour change); the card view slices the
  sorted rows and drives its own per-account page size (subscribed + discovery).
- Restore the top edge-fade in the card layout. The channels page registers
  fadeTop:false for its sticky-header table; the card layout has no sticky header,
  so App now re-enables the top fade when the channel layout is cards.
2026-07-19 23:28:43 +02:00
peter 9c4dd0163a feat(managers): shared sort across the table and card layouts
Cards have no column headers to sort by, so lift the sort out of DataTable into
one shared state that BOTH views drive and reflect. New lib/columnSort.ts holds
the comparator + click-cycle (one source of truth); DataTable gains an optional
controlled sort (sort + onSortChange) and falls back to its internal persistKey
sort when they're omitted, so every other table is untouched. The subscribed
sort lives in ChannelsProvider, discovery keeps its own (its columns differ);
a new ColumnSortControl dropdown (options derived from the columns' sortable
metadata, feed-style + asc/desc toggle) drives it in cards mode. Per-account,
persisted, per-tab.
2026-07-19 23:09:54 +02:00
peter 875c7ad741 feat(managers): richer channel cards + drop the rows layout, cap the channel column
UAT round 1 feedback:
- Retire the `rows` layout — it barely differed from the table; keep Table + Cards.
- The card now shows the About cell (+ its hover overlay) and the priority stepper
  in every layout, not just the table: ChannelLayoutGrid gains named slots
  (lead / about / actions) and stops hiding the `hideInCard` columns, since the
  roomy desktop card has space the cramped mobile fallback did not.
- Fix the table losing its centering with "Rows per page: All": a long-titled
  channel stretched the uncapped Channel column (~289→429px) and overflowed the
  centered table. Cap it with a max-w element inside the cell (table-layout:auto
  ignores a cell max-width), the same trick AboutCell already uses. Gaps now 163/162.
2026-07-19 22:42:09 +02:00
peter b659ad800c fix(managers): key the row-layout primary cell + measured rowEst
Self-review follow-ups: the `rows` layout mapped the primary column without a
React key (single-element today, but a warning waiting to happen); wrap it in a
keyed Fragment. Also replace the guessed card/row rowEst with browser-measured
values (176 / 62) so the scrollbar doesn't jump on first scroll.
2026-07-19 21:59:28 +02:00
peter 1703d4ab5e feat(managers): table / cards / rows layouts for the channel manager (E4 S3)
A generic ViewSwitcher (reused from the feed) drives a per-account layout
shared by the Subscriptions + Discovery tabs. `table` keeps the DataTable
(numbered pager); `cards` and `rows` render a virtualized grid over the same
client-side list via the new ChannelLayoutGrid, which composes cells straight
from each table's Column<T> render closures (cardPrimary/hideInCard/cardLabel),
so both tabs — with their different columns — get the views for free. Status
chips surface in the fixed band when the table's own controls row is gone.
2026-07-19 21:52:16 +02:00
peter 5ea82095a1 refactor(feed): extract virtualization engine into a generic VirtualGrid<T>
VirtualFeed becomes a thin wrapper that maps a FeedView to VirtualGrid's
layout spec and renders a VideoCard per item; all measure/chunk/place/
infinite-scroll mechanics move to VirtualGrid so the channel manager (E4 S3)
can reuse them. Behaviour-preserving: e2e 17/17, all render families smoked.
2026-07-19 21:36:54 +02:00