Commit Graph
95 Commits
Author SHA1 Message Date
peter 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.
2026-09-05 16:58:29 +02:00
peter 7104767ff9 fix(e8): deep-review hardening + cut 0.73.0
Fixes from several multi-agent deep-review passes over the E8 feed-filtering &
channel-manager epic (S1-S4):
- feed_years buckets by UTC year to match the UTC-pinned year-filter bounds
- tag suggestions: examples ∩ subscribed (drop tagged-then-unsubscribed channels
  from the profile denominator); exclude hidden channels as suggestion targets
- channel manager: restore ghost chips + surface an error toast on a failed tag accept
- year filter: debounce the search term so the per-year facet doesn't refetch per keystroke
- channel tags: aria-label the "+N" overflow pills
- extract useTagSuggestions hook; add backend + hook tests (profile boundaries, cap,
  language exclusion, unsubscribed/hidden handling, UTC bucketing, bulk-accept)

Bump VERSION to 0.73.0 + release notes.
2026-08-26 05:25:47 +02:00
peter 17f8885fb1 fix(channels): collapse overflowing tag suggestions into a "+N" pill
With 2+ suggestions the narrow tags cell clipped the second ghost chip
mid-word right next to the edit "+", reading as the icon covering it. Route
the ghost chips through the same fit machinery the attached chips use: a
dedicated SuggestedChipsLane fits what the cell holds and collapses the rest
into a "+N" pill whose hover popover still one-tap accepts / dismisses each.

The lane is attached-XOR-ghost (suggestions are untagged-only, and accepting
one now drops the whole channel from the suggestion cache), so each lane owns
the full cell width instead of clipping the other.
2026-08-26 02:39:00 +02:00
peter 549aae67cc feat(channels): suggest your own tags for untagged channels from your tagging pattern (E8·S4)
Learn, per user tag, which auto TOPIC tags are characteristic of the channels
you've tagged it, then suggest that tag for still-untagged subscribed channels
whose topics cover enough of the profile. Language auto-tags are excluded from
the signal — near-universal within a language, they flooded every channel.

New GET /api/tags/suggestions (read-only, deterministic) backed by
app/sync/tagsuggest.py. Surfaced in the Channel Manager as dashed "ghost" chips
in the tags cell — one tap accepts (reuses the attach-tag endpoint), × dismisses
for the session — plus a header badge and a bulk "Accept N confident" (the
full-profile matches) behind a confirm. No silent writes (D4 = suggest-and-confirm).

Also reflow a pre-existing prettier drift in dateFilter.ts from E8·S2.
2026-08-26 02:18:34 +02:00
peter 2c843e549b fix(channels): tag-filter row folds overflow into "+N", keeps "…" pinned
The channel manager's tag-filter row wrapped the trailing "…" catalog-actions
menu onto a second line once the chips + Clear outgrew the width. Rework it as
one non-wrapping row: the TAGS editor trigger (left), a single-line chip lane
that collapses overflow into a "+N" pill whose popover holds the rest (still
toggleable), then Clear and the "…" menu pinned right — so "…" always stays on
the first line.

New TagFilterChips reuses the shared measure core (useChipOverflowFit +
fitChipsWithPill), the same compression the feed's applied-filter row uses.
2026-08-25 02:30:28 +02:00
peter 4ee0dab68f feat(feed,channels): "No tag" filter for untagged channels (E8·S1)
Filter to channels you haven't personally tagged in the channel manager
(no user-owned ChannelTag; auto language/topic tags don't count):

- Feed: a "No tag" sentinel in the "Your tags" sidebar combobox (exclusive
  with real tag picks) + a removable "Untagged" chip; new `untagged` bool
  feed param / `notag` URL key, persisted + coerced on every filter path.
- Channel manager: a virtual "No tag" chip in the tag-chip row filters the
  table to untagged channels (client-side, shared NO_TAG_SENTINEL).

Backend `untagged` is a plain channel-level narrowing, so feed_summary keeps
it applied in the base pass — the count/facet lockstep holds with no mirror.

Note: item E (per-tag counts) and item D (Any/All toggle) were already
shipped; S1 delivers only item B.
2026-08-25 01:44:34 +02:00
peter 21a3aae700 perf(feed): collapse count + facets into one /feed/summary pass (E2 S4, PERF-13)
The FE fired /feed/count (1 pass) + /facets (3 passes) on every filter change =
4 full scans of the subscribed set. New /api/feed/summary does ONE grouped query
(the base filter with tags stripped, GROUP BY channel_id -> per-channel video
counts) and derives the total count + every tag's distinct-channel facet count in
Python on the ~few-hundred-row channel map, faithfully mirroring filtered_query's
channel-based tag logic + the /facets drill-down (own category dropped, unless a
conjunctive topic-AND). Returns {count, facets} — the same numbers the two old
endpoints returned.

FE: a single shared query (Feed reads .count, Sidebar reads .facets); same key +
filters, so React Query dedupes to ONE request. Invalidations repointed to
qk.feedSummary. The old /feed/count + /facets endpoints (and their now-unused FE
api/qk) are kept as the equivalence-test oracle + safety net; a later cleanup can
drop them.

New equivalence test (test_feed_views, 8 filter combos incl. cross-category,
disjunctive/conjunctive topic modes, search): /feed/summary count == /feed/count
and facets == /facets, byte-identical. Structural win: 2 requests / 4-6 queries
-> 1 request / 2-3 queries (the review measured 530 -> 144 ms). Gate green:
344 pytest, frontend tsc/eslint/knip/prettier/222 vitest.
2026-08-18 05:01:27 +02:00
peter 2cc77757d9 fix(channels): address R14 code-review findings
- list_channels no longer computes the Normal/Shorts/Live aggregation the
  frontend stopped reading when R14 dropped the N/S/L table column; the dead
  ManagedChannel.count_* fields are gone too.
- Immediate "get full history" backfill now coordinates with the paced
  scheduler via a small in-flight set: capped at 2 concurrent + per-channel
  dedup, and run_deep_backfill skips a channel an immediate backfill owns —
  killing the cursor race and bounding the quota-drain burst, without losing
  the speed win.
- Add test_channel_backfill.py: the channelDetail N/S/L counts + the guard's
  no-op / cap / scheduler-skip behaviour.
- Inline the syncedChannels useMemo (its dep is a fresh array each render, so
  it never cached); fix the stale "~2-min ticks" comment (it's 10 minutes).
2026-08-15 06:38:19 +02:00
peter 7ee0e9cd02 feat(channels): R14 zoom-fit table — fluid columns + column fogyókúra
- DataTable: opt-in `fixedLayout` (colgroup + table-layout:fixed) so the
  table fits its container at any zoom/text-size; only the channel manager
  opts in — playlists/discovery/Plex keep auto-sizing.
- Remove the N/S/L and Sync columns from the manager table. Fully-synced
  status becomes a ✓ charm on the Stored count; the sync filter moves to an
  `applySyncFilter` pre-filter still driven by the top chips.
- Channel column is now fluid: ChannelLink truncates with the full name on
  hover (title). Data columns get fixed widths so only Channel/About flex.
- N/S/L (normal/short/live) breakdown moves to the channel page: add the
  three counts to the channelDetail endpoint (one grouped query, same
  filters as the list) + the ChannelDetail type + a meta-line entry.
- Drop the now-dead sync/types i18n keys (en+hu).
2026-08-15 05:28:59 +02:00
peter b52c87b336 polish(ui): tighter header gap, stronger row hover, readable tag chips (R13 S3)
- Channel-manager tab row: pt-4 -> pt-2, closing the too-wide gap under the
  fixed header.
- Row hover was barely visible (--card sits close to --bg in dark schemes). New
  shared --hover token (a 9% foreground mix, clear in every scheme + theme,
  auto-adapting via --fg); the .selectable recipe and the DataTable row both use
  it, so every hoverable row app-wide (tables, nav rail, menu rows) reads.
- Channel tag chips: text-3xs -> text-2xs + font-medium, so the tiny labels stay
  legible on the accent fill across all colour schemes.

Self-E2E'd: tighter gap, a table-row hover now clearly visible. gate green
(tsc/eslint-0/vitest 147/knip/prettier/build).
2026-08-15 03:40:35 +02:00
peter 6de6dece03 refactor(channels): collapse the stats row into a Stats hover chip (R13 S3)
The six inline catalog stats crammed into the tab row become one compact
"Stats" chip that reveals the numbers on hover — a new ChannelStatsPopover on
the AnchoredPopover hover mode, one icon-per-row (channels, recent synced, full
history, [ETA], my videos, quota left). Kept in the manager's own toolbar (the
header SyncStatus is global; these are channel-catalog figures). Retires the
local Stat helper + the now-unused formatEta import. i18n channels.stats.label
(HU+EN). Self-E2E'd: hover reveals the list. gate green (tsc/eslint-0/prettier).
2026-08-15 03:33:34 +02:00
peter ca7e4b16a0 refactor(channels): ⋯ overflow ActionMenu + TAGS-as-trigger (R13 S3)
Declutter the channel-manager tags row:
- New reusable ActionMenu (⋯) on the AnchoredPopover platform — a compact
  overflow menu of one-shot actions with optional pending/disabled + spinner.
  The two big glass buttons (Read subscriptions, Backfill everything) move into
  it, freeing the row.
- The "YOUR TAGS" caption becomes a "✎ TAGS" trigger button that opens the tag
  editor directly, retiring the separate "Manage tags" button.

Adds i18n (channels.actionsLabel, channels.tags.label; HU+EN). ActionMenu is
built to be reused for other overflow menus later. Self-E2E'd: ⋯ opens the two
actions, ✎ TAGS opens the manager. gate green (tsc/eslint-0/prettier).
2026-08-15 03:27:15 +02:00
peter 42d0173095 refactor(ui): unify Channels + Stats filter chips (R13 S2c step 4)
- channel-manager sync-status chips (F2) -> FilterChipGroup (rounded-lg -> the
  canonical rounded-full), controls-row alignment preserved via className
- channel-manager user-tag chips (multi-select) restyled to filterChipClass
  (rounded-lg -> rounded-full) so the whole manager chip set is one look
- stats system time-range chips (F4) -> FilterChipGroup (text-sm -> canonical)

Adds aria-label i18n (channels.filters.syncLabel, stats.rangeLabel; HU+EN).
Self-E2E'd: sync chips + tag chips rounded-full, view TABS stay rounded-lg
(their own canonical), stats 7/30/90d toggles. gate green (tsc/eslint-0/
vitest 136/knip/prettier). Branch not merged.
2026-08-15 00:53:12 +02:00
peter ed1a2b7256 test(channels): cover the tab-history logic; memoize viewTabs (S2b review)
Addresses the 2 applied findings from /code-review high on 735d08c:
- Add ChannelsProvider.dom.test.tsx (4 tests): setView pushes a _chTab history entry +
  persists the tab, re-selecting the current tab pushes nothing, a genuine popstate carrying
  _chTab restores + re-persists it, and a popstate without _chTab is ignored. Real storage
  (jsdom) with the account pinned to 1; only NavigationProvider is mocked. vitest 120->124.
- Memoize viewTabs (keyed on the translation fn) so it isn't rebuilt on every Channels render.

The third finding (consolidate the hand-rolled single-select sync chips with <Tabs>) is
deferred by design into the S2c plan, where MatchToggle will be a shared segmented-control
primitive that can also absorb the sync chips.

gate-green: tsc + vitest 124 + eslint-0 + knip + prettier.
2026-08-14 04:01:38 +02:00
peter 735d08c91d refactor(channels): adopt shared Tabs + usePersistedTab for the manager tab row (R13 S2b)
Retire the hand-rolled channel-manager view pills for the shared <Tabs> component (given an
optional className so it embeds in the justify-between header without the default mb-4), and
back view + layout on usePersistedTab instead of manual getAccountRaw/setAccountRaw useState.
That subsumes the account-pin re-hydrate (usePersistedTab now re-reads on the pin), so
ChannelsProvider's bespoke markManagerDirty/useAccountRehydrate is gone; setView keeps the
history-tab logic (_chTab pushState + popstate) layered on the stable raw setter, and
useAccountPersistedState's setter is now memoized so that stable action holds.

Per the user's call, unify every channel-manager pill on the rounded-lg idiom: the view tabs
via <Tabs>, the sync chips and tag chips + the "manage tags" affordance from rounded-full to
rounded-lg — no more odd-one-out rounded-full.

gate-green: tsc + vitest 120 + eslint-0 + knip + prettier. Self-E2E'd: tab switch, browser
Back across tabs (Blocked->Hidden->Subscriptions with localStorage in sync), and a fresh tab
restoring view=hidden on the FIRST load; zero console errors.
2026-08-14 03:50:43 +02:00
peter ca9dd54923 fix(channels,datatable): address R13 S2a review + unify tag chips with the Tags column filter
Review fixes (user /code-review high):
- DataTable: hoist a stable EMPTY_FILTERS fallback so a controlled caller that omits
  `filters` doesn't churn a fresh {} each render.
- DataTable: the persistKey blob now stores only the slices this table OWNS — a
  controlled sort/filter is written as null/{} instead of a stale never-read duplicate.
- useFilterState: optional `migrate` seed (used only when storage is empty);
  ChannelsProvider carries the retired LS.channelFilter sync chip forward via migrateSyncChip.
- Channels: guard the sync-chip read with typeof instead of an `as string` cast.

Also unify the tag filter, same as the sync one: the top "YOUR TAGS" chip row is now a
controlled view over the Tags COLUMN filter (`filters.tags`), replacing its own tagFilter
state — so the chips and the Tags funnel share one state bidirectionally, the card layout
applies it via filterRows, and it persists with the rest of the map.
2026-08-14 02:19:44 +02:00
peter 837fdcd408 feat(channels,datatable): unify sync-status into one controlled DataTable filter (R13 S2a)
The top sync chips and the Sync column's own select filter were two independent
states with different vocab (all/needs_full/fully_synced vs fully/needsFull) and
subtly different semantics. DataTable gains a controlled filters/onFiltersChange
pair (symmetric to the existing controlled sort); ChannelsProvider now owns the
filter map via a new useFilterState hook, so the top chips, the Sync column
funnel, the card layout, and the header "full history" deep-link all drive ONE
filters.sync. The card layout reuses the exported filterRows predicate instead of
its own status pre-filter. Vocab unified on the column's fully = recent_synced &&
backfill_done, which now matches the Sync cell's own "fully synced" badge.
2026-08-14 02:00:17 +02:00
peter 665bc6f7b5 fix(plex,channels): address R13 S2 review findings
- Shared usePlexClearFilters hook replaces the duplicated clearAll (was copied in
  PlexBrowse and PlexSidebar) — one "reset every Plex narrowing" both call.
- The Plex duration chip's minutes unit is localized (plex.filter.durationUnit:
  "min"/"perc") instead of a hardcoded "m".
- The Plex collection chip falls back to the "Collection" label, not the raw
  rating_key id, when collectionTitle is missing.
- Dropped the dead rowClassName that dimmed hidden rows in the subscribed table
  (hidden channels are excluded there now — they live in the Hidden tab).
- Renamed the misleading `set` null-check predicate to `has`.

Gate green: tsc/eslint-0/prettier/knip clean, vitest 106.
2026-08-14 01:34:48 +02:00
peter 002ef0ab84 feat(channels): proper channel cards for Hidden + Blocked tabs (R13 S2)
The Hidden and Blocked tabs were a bare row of name chips ("two withered
chips"). Both now render a responsive grid of real channel cards via a shared
ExcludedChannelList: avatar + name + open-on-YouTube (reusing ChannelLink) and
one labelled reversing action (Unhide / Unblock). One presentation, parameterised
by icon/label/action/copy — the two "excluded channel" tabs now match.

Gate green: tsc/eslint-0/prettier/knip clean. Self-E2E'd: Hidden tab renders both
channels as cards with avatars, YT links, and Unhide buttons.
2026-08-14 00:49:09 +02:00
peter 6062007947 feat(channels): add a Hidden tab; move hidden channels out of Subscriptions (R13 S2)
Hidden subscriptions get their own tab (mirroring Blocked): a flat list with an
Unhide action, instead of dimmed rows scattered through the Subscriptions table.
- ChannelsView += "hidden"; the subscribed table now EXCLUDES hidden channels,
  so hiding a channel (the eye button) moves it to the Hidden tab and unhiding
  (Eye) returns it — both via the existing patch mutation.
- The "Hidden" top-level status chip is dropped (ChannelStatusFilter loses
  "hidden"); the status row is now purely sync-status (all/needs_full/fully_synced).
- Bilingual i18n (en/hu): channels.tabs.hidden + a channels.hidden block; the now
  unused channels.filters.hidden removed.

Hidden = out of the personal (Mine) feed only, still in the shared Library — a
softer exclusion than Blocked (hidden everywhere). Gate green: tsc/eslint-0/
prettier/vitest 106. Self-E2E'd: hide → appears in Hidden tab → unhide → back,
all /api 200.
2026-08-14 00:38:47 +02:00
peter 69d34f7fa3 refactor(channels): move cells + column defs into channelColumns.tsx
R12 S4c. Channels.tsx shed its ~150-line column block and 3 cell components
into the co-located channelColumns.tsx:

- SyncBadge / SyncCell / ActionsCell move verbatim (prop-driven, self-contained).
- the column array becomes a buildChannelColumns(deps) factory next to
  subsColumn — the manager owns the state/mutations and passes them in; the
  inline patch.mutate wrappers become onHide/onDeep callbacks so the factory
  doesn't couple to the mutation type.

Channels.tsx 905 -> 628 lines; no behaviour change. Gate green (tsc / eslint 0
errors / prettier / knip / vitest 79) + self-E2E: the manager renders every
column and cell (priority stepper, actions, sync badges, tags) with no console
errors.
2026-08-11 23:27:16 +02:00
peter 02754f479f refactor(ux): address /code-review high findings on the fixpack
- Extract the optimistic + debounced-flush feed-priority flow into a shared
  useChannelPriority hook (mirrors useChannelTagActions); the manager and the
  channel-detail page now share it instead of two drifting copies.
- NavSidebar: drop the always-true `slim` const and its dead expanded-rail
  branches; refresh the now-stale pinned/collapsible comments.
- Remove the orphaned `navCollapsed` localStorage key (no readers/writers left
  after the always-slim rail).
- Channel page: drop the wrapper native `title` that duplicated PriorityStepper's
  own Tooltip.

The keyboard-focus nav-label finding needed no change: the popover platform
already wires useFocus (visibleOnly), so a real Tab press shows the flyout label
(verified in-browser).
2026-08-11 03:29:08 +02:00
peter 9d7a2a75d8 feat(ux): card unification, slim icon nav, filter/plex fixes (7-item batch)
1A ChannelPage "Full history" button no longer wraps (whitespace-nowrap).
1B Extract the manager's press-and-hold priority stepper into a shared
   PriorityStepper and add it to the channel detail header (subscribed, non-demo);
   backend channel_detail now returns the per-subscription `priority`.
2  Unify YouTube + Plex media cards onto one look/behaviour: drop the whole-card
   hover LIFT (VideoCard) — which also fixes the clipped-top bug — and instead the
   inner thumbnail/poster ZOOMS on hover (both); give the flat Plex posters the same
   glass-card 3D depth + accent hover border as the feed cards (shadow-md is a
   transparent no-op here, so it's glass-card that provides the depth); clamp Plex
   card titles to a fixed 2-line height so 1- vs 2-line titles align.
3  Nav rail is now always a slim icon-only column, flush to the left edge; each icon
   reveals a floating name label on hover (Tooltip gains `always` + `openDelay`, and
   a right side). Removed the pin/unpin toggle, the expand-on-hover mode and the
   navCollapsed pref plumbing.
4  Scope the side-panel peek-open HOVER to the tab handle + the card (not the whole
   full-height slot next to the rail), so approaching the nav no longer pops the
   filter open; focus stays container-wide. useHoverFocusWithin gains a hover-out
   grace period + split hover/focus handler groups.
5  Plex art backdrop fills the whole viewport (a fixed inset-0 layer behind the
   transparent app) instead of only the inset <main> box.
6  Plex season binge continues across seasons: the season view's play queue now
   spans the whole show (allKeys), so a season's last episode rolls into the next
   season's first (seasonKeys stays for "add season to playlist").
7  When a Plex video/episode ends with nothing queued next, the player returns to
   the previous page (onEnded → onClose) instead of stranding a black screen.
2026-08-11 02:20:52 +02:00
peter 90b33fc325 refactor(ui): R10 S4 — complete the selection-paint sweep (all pill idioms)
The first S4 pass migrated a subset, which left same-idiom selection pills
hand-rolled and made adjacent controls inconsistent (a /code-review finding).
This completes the sweep so every selected-among-options control shares the
recipe:

- plain pills → .pill (+ aria-pressed): Channels status filters + view tabs,
  Sidebar filter-tag chips + date presets + custom-dates, Stats tabs + ranges.
- glass-card-resting pills → .pill with the resting `glass-card glass-hover` kept
  in the non-pressed branch (solid accent when pressed): PlexSidebar library
  scope + the facet Chip (retired the round-1 "deferred" note — the recipe
  already expresses this via the resting branch, no new variant needed).
- custom-dates aria-pressed is coerced with !! so a date string can't leak into
  the attribute and miss the [aria-pressed="true"] paint.
- drop the dead `border` on ChannelTags' chip-solid (accent border on accent bg).

Out of scope (a separate concern, NOT selection-among-options): primary/action
buttons that share the accent fill — Banner action, ConfirmProvider primary,
PlexBrowse primary, PlayerModal watched-toggle — these want a future .btn-primary
recipe. Left by-design: tableHeader filter options (PopoverItem + keepSelectedPaint,
owned by the popover platform).
2026-08-11 00:27:04 +02:00
peter dcb91066f5 refactor(ui): R10 S4 — unify selected-paint into .pill/.is-current/.chip recipes
The ~14 hand-rolled "this is the selected/current/active one" idioms each
repeated the same `sel ? "bg-accent text-accent-fg border-accent" : "…"` inline
ternary, which had drifted. Three attribute-driven recipes in @layer components
now own that paint, keyed on the ARIA the control already carries: .pill
(aria-pressed segments/toggles), .is-current (aria-current nav/list items), and
.chip / .chip-solid (tag & filter tokens). The JSX just toggles the ARIA; the CSS
paints, so one control's selected look can't diverge from the next's. No
focus-visible in the recipes — the @layer base rule already rings every button/a.

Migrated 15 sites (Feed show/content, Channels tag filter, Sidebar or-and +
scope, PlexSidebar match, Tabs, SettingsPanel tab-rail, PlexPlaylistView layout
[soft], NavSidebar nav + settings, ActiveFilterChips, PlexSidebar RemovableChip,
ChannelTags, ChannelPicker). Deliberately left: PlexSidebar facet Chip and
PlexPlaylistView already covered; SavedViews row (paint split across two
elements), the scheme swatch (border-only on a coloured swatch), and
ChannelPicker's option rows (text-only selection) — noted for a follow-up.
2026-08-09 21:06:58 +02:00
peter 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.
2026-08-09 03:18:04 +02:00
peter 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.
2026-08-09 01:03:53 +02:00
peter 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.
2026-08-08 00:50:05 +02:00
peter 2984aa3240 fix(popover): resolve the R8 platform code-review findings
Correctness:
- Escape is now strictly topmost-only for EVERY mode. Hover popovers register in
  the shared layerStack too (and floating-ui's own Escape is off for all modes), so
  an open tooltip/hover-card no longer either swallows the Escape from a Modal/player
  underneath (its document-level stopPropagation vs their window listeners) or
  double-closes both. A player's own overlay hover-card now takes Escape before the
  player, keeping it alive.
- An invisible (referenceHidden, scrolled-out) surface is no longer a dismissable
  layer, so it can't silently eat an Escape aimed at what's actually on screen, nor
  make a player defer.
- PopoverSurface splits into a wrapper that returns null before any hook and an inner
  that holds them, so a mounted-but-closed popover (≈10 per table row) subscribes no
  fullscreen listeners — was ~1800 document listeners on a 100-row table.
- The fullscreen portal no longer remounts on a key: one persistent container is
  re-parented body↔fullscreen-element with appendChild (atomic move), so the open
  panel's focus/caret/scroll/animation survive a transition and an element SWAP is
  handled by the element-identity dependency.
- AddToPlaylist drops `disabled={busy}` on its items (it blurred the focused item to
  <body> and permanently killed roving focus); re-entrancy is already guarded in
  toggle(), and aria-busy conveys the pending state.
- New `hostsInput` option turns off BOTH typeahead and focus-item-on-hover for the two
  menus with an inline text field, so a pointer passing over a list item no longer
  steals the field's caret and mis-routes the next Enter. Replaces their `typeahead:false`.
- ChannelTags' single-pass fit loop gains a whole-set fast path, so it stops collapsing
  chips into "+N" when they would all have fitted.
- `initialFocus={-1}` is applied only when a `selectedIndex` is set (the sync-focus race
  it works around); a plain nav menu now lets the focus manager focus normally, so an
  item-less menu (first-run tag editor) still has a keyboard entry point.

a11y:
- `ariaLabel` now wins on menu-role panels — the surface strips useRole's competing
  `aria-labelledby` when an explicit label is given (verified: the filter/View menus
  are named by their column/label, not the trigger).
- PopoverItem routes caller props THROUGH getItemProps and applies its own
  tabIndex/disabled/data-active/className last, so a caller handler or tabIndex can no
  longer be silently dropped or clobber the roving model.

Regressions / consistency:
- Tooltip shows/hides on contact again (explicit 0/0 hover delay) instead of inheriting
  the platform's 120/140 ms intent delay.
- ChannelTags' editor list regains its useScrollFade overflow mask.
- ChannelTagsControl takes a `bounded` prop; the channel detail page passes false so
  tags show inline in the wide header instead of collapsing to "+N".
- ChannelPage's tag-catalog query is gated on subscribed && !demo (matches the only
  consumer's render condition); the manager's tag toggle now also invalidates
  qk.channel(id) so the detail page's cached tags stay in step.
- DetailCustomizeMenu + PrefToggle revert off the platform to the pre-R8 inline
  `absolute` menu: in the player's overlay it renders inside a transform:scale(1.25)
  subtree, and portalling out to <body> painted it at 1×. Same reason the player's own
  menus stay hand-rolled; this also restores PrefToggle to plain buttons (the rows are
  independent toggles, not a radio group). The stale PlayerModal fullscreen comment is
  corrected too.

Gate green (tsc/eslint/76 vitest). Self-UAT in-browser: hover-over-item no longer
steals the tag input's caret; text filter keeps caret while narrowing; menu aria-label
is the label not the trigger; selectedIndex lands focus on the checked item; roving +
Escape-close+refocus intact; add-to-playlist items never disabled. NOT verified here
(needs a device / playing media): the fullscreen portal reparent and the player-overlay
paths — owed device UAT, as they already were.
2026-07-31 02:10:14 +02:00
peter f091247624 refactor(channels): shared tag-mutation hook + a channel_detail tag_ids test (R8 review)
Address the two remaining review findings on the tag work:
- extract useChannelTagActions — the optimistic attach/detach + create-and-attach flow that
  the channel manager and the channel detail page had copy-pasted; the cache-shape-specific
  optimistic update is injected (manager maps a ManagedChannel[]; detail patches one
  ChannelDetail), so the two stay in sync;
- add a DB-lane test asserting GET /api/channels/{id} returns exactly this user's personal
  tag links — not another user's, not the auto/system (user_id NULL) links.
2026-07-30 23:33:50 +02:00
peter 8ae06ea1fe fix(channels): center table, kill popover top-left flash, exact "+N" count (R8 S1 UAT)
Second UAT pass on the tag cell + popover platform:

- Popover top-left flash (real root cause): the popIn keyframe animates `transform`,
  which overrode Floating UI's positioning `transform: translate(x,y)` for the animation's
  duration, parking the popover at (0,0). Move the animation to an INNER wrapper so the
  positioned element's transform stays clean (verified: menu is at the correct spot from
  frame 0). The isPositioned visibility gate is kept as a belt-and-suspenders.
- Table shifted right / not centered: my previous fixed-15rem tags column + nowrap Sync
  pushed the table past the max-w-[96rem] wrapper so it overflowed. Trim to 12rem tags and
  shorten the long "full history queued/coming" sync labels to "queued"/"coming" so the
  table fits the wrapper (1594 <= 1628) and centers again.
- "+N" count wrong/missing while adding tags: replace the iterative shrink (which lagged a
  render and could clip the pill's digits) with a deterministic measure — a hidden measurer
  lays out all chips + a sample pill, and we compute the exact fit in one pass. The count is
  always correct now (verified across varied rows), and a bare "+" with no number is
  structurally impossible.
2026-07-30 23:33:50 +02:00
peter 0fd95c15a1 fix(channels): stable tag column width, no popover flash, no sync wrap (R8 S1 UAT)
Three UAT refinements on the tag cell + popover platform:

- Fixed tag-column width (w-[15rem], was max-w): under table-layout:auto a content-
  driven cell re-solved every column's width on each chip added, so the table visibly
  shifted (and the "+N" count flickered) until the cap was hit. A fixed footprint makes
  adding/removing tags move nothing (verified: column x unchanged, "+N" count stable).
- No popover flash: gate the floating surface's visibility on Floating UI's isPositioned
  so it never paints at (0,0) top-left for a frame before the transform lands.
- Sync cell no longer wraps: flex-nowrap so the recent/full badges stay on one line
  (the column grows to fit them instead of breaking to two rows).
2026-07-30 23:33:50 +02:00
peter cfa8be950d feat(channels): fixed-width tag cell with "+N" overflow + inline create (R8 S1)
Redesign the channel tag cell (the popover pilot's first real consumer) to fix two
UAT-reported bugs and two UX gaps:

- Jumping editor (bug 1): the "+" edit trigger now sits in a STABLE slot at the end,
  so toggling a tag (which grows the chip row) no longer moves the anchor — the editor
  popover stays put (verified dx/dy = 0).
- Whole-table reflow (bug 2): the cell is width-bounded (max-w inside the cell, since
  table-layout:auto ignores the column width — the documented channel/About trap) and
  single-line, collapsing overflow into a hoverable "+N" pill. Rows no longer wrap and
  reflow (verified uniform row heights).
- The "+" is ALWAYS present (even with zero tags), and its menu can CREATE a tag inline
  (type + Enter → create + attach) — tagging no longer requires the top-of-page dialog.

New shared ChannelTagsControl (used by table + cards; the detail page is Part 2) over the
R8 popover platform: a `menu` editor popover + a `hover` reveal-all popover. Greedy rem-safe
fit via ResizeObserver. `api.createTag` now returns Promise<Tag>.
2026-07-30 23:33:50 +02:00
peter e644b4f7d7 feat(popover): add the Floating UI anchored-popover platform + migrate TagsCell pilot (R8 S1)
Introduce useAnchoredPopover + PopoverSurface over @floating-ui/react — one primitive
replacing the hand-rolled measured engines: flip() (two-way flip), shift() (width clamp),
hide() (hide-and-follow when the trigger leaves its scroller), autoUpdate (scroll follow),
FloatingPortal (escape ancestor stacking contexts). Supports menu + hover modes; focus
management lands in S2.

Wrap the app and the public watch page in <FloatingTree> so Escape / outside-press dispatch
is topmost-only across nested layers (S3 folds in the Modal + players).

Migrate the richest engine, TagsCell, as the pilot — ~140 lines of bespoke positioning
(coords state, flip layout-effect, scroll rAF loop, useDismiss, Overlay) collapse into one
hook call. Verified in-app: click opens, portals to <body>, positions below the trigger,
role=menu + aria-expanded/haspopup, Escape and outside-click close, no console errors.
2026-07-30 23:33:50 +02:00
peter 7765d19813 fix: code-review round 1 — 4 findings
- og: guard safe_abs_path's None (missing/gc'd poster) before _jpeg_size, else the public /watch OG
  render crashed with an uncaught AttributeError
- channels: hoist the priority step button to module scope so it no longer remounts on every
  hold-repeat tick (which dropped the pointer capture and could let a hold run away on pointer drift)
- watch: only route audio through Web Audio on iOS (where a backgrounded <video> is suspended);
  other platforms keep their reliable native audio, avoiding a foreground-silence risk if the
  AudioContext can't resume
- share: drop the unstable `create` mutation object from the auto-create effect deps (it changes
  every render); the links.data trigger + autoTried ref already fire it exactly once

The clipboard-on-focus finding is intended (the user explicitly asked for auto-paste on focus; the
read is best-effort and browsers reject it without a gesture, so no real prompt fires).
2026-07-28 02:49:34 +02:00
peter 9d3d69f0e9 feat(channels): press-and-hold priority stepper + inline-editable value
The priority arrows changed one step per click, so setting a large value meant many clicks. Now a
press-and-hold auto-repeats at an accelerating rate (a quick tap still steps once; keyboard path
kept), and the PATCH is debounced per channel so a hold sends one request with the final value, not
one per notch (flushed on unmount). The value also sits LEFT of the arrows (cursor never covers it)
and is click-to-edit — type any integer directly, negatives allowed — via an absolutely-positioned
input that overlays without disturbing the table row layout.
2026-07-28 02:22:36 +02:00
peter 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.
2026-07-27 21:48:35 +02:00
peter b5573b62d1 feat(ui): shared LoadingState — a centered accent spinner instead of plain loading text
Replaces the plain "Loading…" text in every R3 loading branch with a single shared LoadingState
(a spinning lucide Loader2 in the accent colour + label, centered, role=status/aria-live) — one
component so all loads read the same and can be swapped for skeletons later in one place. Applied
across Feed, Channels, ChannelDiscovery, AuditLog, NotificationsPanel, Messages, Scheduler, Stats,
PlexBrowse (grid + subviews), PlexPlaylistView, ConfigPanel, DownloadCenter, AdminUsers, ChatThread.
Left the small "load more" footer and lazy Suspense fallbacks as-is.
2026-07-22 04:35:02 +02:00
peter a393fd2fb1 fix(ui): R3 S1 review fixes — don't blank data on transient refetch errors
- The honest-state branch now guards on `isError && !data`, so a background/window-focus refetch
  failure keeps already-visible content instead of replacing the list with a full error screen
  (regression the bare `isError` introduced). Applied to AuditLog, Channels, ChannelDiscovery,
  PlexBrowse, PlexPlaylistView, Messages(people), Feed — matching the guard already used in
  Messages(conversations)/NotificationsPanel/Scheduler.
- Drop the unused DataTable loading/error/onRetry props (every table page uses the outer
  StateMessage branch, not the props) — reverts DataTable to its simple empty block.
- StateMessage announces errors with role=alert/aria-live=assertive (was polite for all tones).
- Remove the now-unused common.empty string (en+hu), left over from the removed QueryState render-prop.
2026-07-22 04:14:46 +02:00
peter d9a08892a5 feat(ui): R3 S1 — honest error states across the pages
The error state didn't exist as a concept: a failed main query rendered as an "empty" list
("No channels" while the request actually died), so a backend restart read as "everything is
empty". Add the convention that an empty state may NEVER show while a query is erroring.

- New components/QueryState.tsx: shared StateMessage (loading / error+Retry, house style, with
  role=status/aria-live — the app had zero live regions).
- DataTable gains loading/error/onRetry props (suppresses empty-text on error).
- isError branch (before the empty branch) added across the false-empty pages: Feed (retry),
  Channels, ChannelDiscovery, AuditLog, NotificationsPanel, Messages (conversations + people),
  Scheduler, Stats, PlexBrowse grid, PlexPlaylistView. ChannelPage's main content is Feed, now
  covered. common.loadError/empty strings (en+hu).
2026-07-22 03:58:51 +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 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 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 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