Commit Graph
100 Commits
Author SHA1 Message Date
peter e7091158fd test(e2e): golden-master Playwright net for the Platform refactor
Locks the five flows the refactor must not break (feed, channel, player,
settings, nav) so later sprints can prove they changed nothing. Adds a thin
data-testid layer and a dedicated seeded non-demo account; the suite runs on
the baked :8080 stack (authoritative) or :5173. No product-behaviour change.
2026-07-18 00:35:57 +02:00
peter bc5f95f807 Merge E3: feed view modes + scroll-affordance sweep
Five view modes behind a reusable ViewSwitcher (cards · cardsSmall · rows ·
rowsCompact · tiles), and a subdued edge-fade on the page scroller and every
capped list. UAT-accepted; not shipped to prod (user's call — the Platform
refactor is next). Version bump + releaseNotes deferred to ship time.
2026-07-17 21:58:43 +02:00
peter 65ab33d632 fix(player): portal the YouTube player modal out of the page scroller
Same regression the S3 edge-fade caused for the Plex player, and the one my
"swept the class" pass missed: PlayerModal is `fixed inset-0 z-50` but rendered
inside the page scroller, so once that scroller got the mask it became a
stacking context and scoped the modal's z-50 inside it — the nav rail (z-40) and
header (z-30) painted over the player, and the fade dimmed its top edge (worst
at browser zoom, where the centered card is smaller against the rail/panel).

Portaled to <body>, where every other full-screen overlay already lives.
Verified: modal is body-parented, full-viewport, and hit-tests on top at the
nav / filter / header / corner points with the main mask active.
2026-07-17 21:43:37 +02:00
peter 9b4ee83ec8 fix(plex): portal the full-screen player out of the page scroller
The player is `fixed left-0 top-0 z-50` but rendered from inside the page
scroller, so it only ever covered the viewport because that scroller happened
not to be a stacking context. Giving it the edge-fade made it one, and the mask
scoped the player's z-50 inside it: the rail, the side panel and the header
painted straight over the player, and its edges faded. Measured both ways, then
fixed where every other full-screen overlay here already sits — <body>.

Same for the Suspense fallback that stands in while the player chunk loads, and
for the floating draft save bar on the Configuration page. Swept the rest: every
other `fixed` element is either App-level or already portaled.
2026-07-17 03:30:55 +02:00
peter 0ed58e3cb1 improvement(scroll): fade every capped list's clipped edges
The sweep's own three (the thread, the Plex collections filter, the tag manager)
plus the seven the spec missed — the playlist picker, the row tag menu, the Plex
playlist/collection dialogs, the player's track menu, the video description and
the table's multi-select filter. Same idiom throughout: a height-capped list that
silently clipped its content now says so, and hides its scrollbar like the rail
and the panels already do.

The table's option list is its own module-level component because it owns a hook:
FilterPopover is redeclared per DataTable render, so React remounts it.
2026-07-17 03:14:48 +02:00
peter a032324513 improvement(scroll): fade the page scroller's clipped edges
The subdued edge-fade the rail and side panels use now hints at more content on
the page scroller too, which the feed, the channel page and Plex all scroll
inside. Both <main>s move into one PageScroller so the invariants they share —
the load-bearing scrollbar gutter, and the bar staying visible because hiding it
would zero that gutter — are stated once.

Two fixes the hook needed first: it set fresh state on every scroll event, which
above a page tree would re-render it per frame; and it watched a single child
captured at mount, so a lazy page swapping out its Suspense fallback left the
observer on a detached node and the fade frozen at its pre-load value (measured
on the Plex page: no bottom fade over a 3119px list).
2026-07-17 03:14:37 +02:00
peter 0b56eb9d53 fix(feed): keep the row's columns growing with the text-size setting
My last commit made ROW_COL the literal source of the widths — and froze
them, because inline pixels can't follow the root font that Settings'
text slider drives (0.9-1.3). Measured at 130%: the channel box stuck at
128px while its text wanted 152 and the meta cell, being a rem grid, grew
to 156 — one row in two unit systems.

The classes come back and ROW_COL documents them, saying plainly that the
two are matched by hand and why they have to be. Chasing a comment's
literal truth cost a property I'd already verified.
2026-07-17 02:21:04 +02:00
peter 99c1bd9916 fix(feed): make the row's widths actually come from one table
The comment claimed the boxes were generated from ROW_COL. They weren't —
still w-32/w-16/w-48 by hand — so a comment promised a link that didn't
exist, which is worse than not claiming it. The boxes take the numbers
directly now (inline, since Tailwind can't read a runtime value), and the
thresholds add up in plain arithmetic rather than an IIFE whose result
depended on key order.
2026-07-17 02:16:16 +02:00
peter ba23c0c996 refactor(feed): derive the row's drop-out thresholds from its column widths
They were two hand-written sets of numbers describing the same boxes —
change a w-32 and the thresholds are silently wrong, the symptom being a
column appearing just before it fits. Now computed from one table (which
reproduces the old values exactly).

Width also stops reaching the four views that ignore it: it changes every
frame of a resize, so passing it everywhere pushed all of them through
VideoCard's memo for nothing.
2026-07-17 02:13:45 +02:00
peter be05c80d82 fix(feed): drop the compact row's columns by the ROW's width, not the viewport
The viewport was the wrong signal: the nav rail and filter panel move the
row's width by ~430px between them, so at a 1280 window with both pinned
every column still qualified while the row had 762px for 766px of them —
the title squeezed back to nothing, the exact bug the breakpoints were
meant to fix. My own comment predicted it and I didn't follow it through.

VirtualFeed already measures the container for colCount, so it now passes
the item's real width down. Measured in the failing config: title -4 ->
359 with the date column standing down.
2026-07-17 02:11:06 +02:00
peter 21cc5de034 refactor(feed): build the prose view count where it's used
It sat above the one-line row's early return but only textBlock consumes
it, so every compact row allocated it and threw it away — the same shape
I'd already fixed for textBlock and meta, crept back. Also merges a
rationale comment I'd written twice.
2026-07-17 02:04:56 +02:00
peter f702526cef refactor(feed): let the caller decide where the mode's name fits
The xl breakpoint was a fact about the FEED's toolbar, hard-coded into
the switcher that E4's managers are meant to reuse — their rows are far
emptier and would have hidden the label for no reason. It's a prop now,
defaulting to icon-only.

The two views spans collapse into one builder, which also fixes a
regression I'd just added: splitting them put the exact-count tooltip on
the number alone, so hovering the word gave nothing.
2026-07-17 02:02:35 +02:00
peter eda6ef4766 improvement(feed): give the compact row's title the room back
Three from UAT: the row's cap goes 1280 -> 1472 (+15%), since with the
rail and filters hidden there was a lot of page left over and the fixed
columns mean the extra width can only go to the title; the views column
drops the word and keeps it in the tooltip (a table doesn't repeat its
unit every line), taking that column 144 -> 64; and the view switcher
names the current mode where the toolbar has room, falling back to the
icon alone below xl. Title: 431 -> 511 at 1440, 706 at 1900.
2026-07-17 01:58:14 +02:00
peter ed08aa87f0 fix(feed): stop the compact row eating its own title on a smaller window
Every column but the title is shrink-0, so once the fixed widths outgrow
the row the title gives all of it: measured clientWidth 0 on every row at
a 1000px window — a table of channel, duration, views and date with no
titles. Columns now drop from the right as space runs out, least
important first, in the order the user named: title, then channel, then
the rest. Measured at 1000px: title 0 -> 351.

I'd written this off as "E15's problem, bites below ~640px" — wrong by
600px, because I only ever measured at 1440.
2026-07-17 00:55:46 +02:00
peter 638aa5750e fix(feed): only tooltip the channel name where it's actually cut off
I set the attribute unconditionally because the one-line row truncates
hard, and never looked at the other four views — where the name fits and
the tooltip just repeated what was already on screen. It now shares the
title's clamped check.
2026-07-17 00:50:40 +02:00
peter 30804dc7dd fix(feed): name the row's status markers once, and reliably
Dropping the sr-only twin last round swapped a double announcement for
none: a bare span's title isn't dependably surfaced, so a screen reader
got no hint a row was live, a stream, or saved. role="img" + aria-label
names each marker once and keeps the hover.
2026-07-17 00:47:40 +02:00
peter e52fba8b07 fix(feed): don't tell people to click an indicator that isn't a button
The saved pip borrowed the bookmark button's label, so it read "Saved —
click to remove" on a plain span: hover, be told to click, click, nothing.
It gets its own "Saved" string. The status markers drop their sr-only
twin too — with a title already carrying the meaning, a screen reader
that surfaces title text said it twice.
2026-07-17 00:45:06 +02:00
peter 3de7e0ba0e fix(feed): stop the compact row's meta sliding about
The cell was a flex row, so whatever sat left pushed the rest: a 36:36
and a 3:31:09 put the marker beside them at different x, and a live video
— which has no duration at all — pulled it left again. The column edges
lined up, which is all I had checked; the data inside them did not.

Each datum now has a fixed slot in a grid, the duration right-aligned as
a number should be. The chip becomes an icon, about a letter wide: it is
the least important thing in the row, and three states have to stay apart
by SHAPE — in the youtube scheme --accent is red, so a live dot and a
stream dot would be the same dot.
2026-07-17 00:41:33 +02:00
peter 26c7ed7471 fix(nav): stop back-to-top dying on the first visit to a lazy page
It looked <main> up when it bound, which the lazy pages won. On a first
visit to the channel page or Plex the chunk is still loading, so the
Suspense fallback is on screen, there is no <main> to find, and the effect
gave up — its dep never changes again, so the button stayed dead for that
whole visit and only worked once the chunk was cached.

It now reads the scroller off the scroll event instead, captured on the
document: scroll doesn't bubble but it does capture, so there is nothing
to look up and nothing to race. Also 50% bigger, as asked (44 -> 64).
2026-07-17 00:14:31 +02:00
peter 7dda44c487 fix(feed): size the compact row's columns for Hungarian too
Every width was measured in the English UI, but "101.7K megtekintés" is
139px against "101.7K views" at 79 — so a Hungarian user's view count
ellipsed itself in a column whose job is to show it. The channel column
gives back the room: its names truncate at any width we can afford (12
of 102 at 128 vs 5 at 160), and a clipped name still reads where a
clipped number does not.

The channel name also carries a tooltip now — it truncates hardest in
the fixed column and had no way to be read in full.
2026-07-16 23:59:49 +02:00
peter 5433a60ac9 fix(feed): size the badge column against the library's worst case
A 60-video sample topped out at 2:00:58, so the column was 8px short of
an 11-hour stream that is also saved — and the library holds one. That row
would wrap to two lines in the one layout that promises single ones.

The stacked layouts' textBlock now builds after the one-line row returns,
since only they use it.
2026-07-16 23:54:21 +02:00
peter f5649fc0b9 improvement(feed): make the compact row a real table
Actions lead the row: the title is what you read, so that is the shortest
pointer trip to them. Every column after it is a fixed width, which is
what puts the same datum on the same vertical line in every row — a CSS
grid cannot do it, since VirtualFeed renders each row as its own subtree.
Widths come from measuring the real data, not from taste.

Metadata rises to the channel name's size, and the duration drops its
chip: over a thumbnail that background carries contrast against the
artwork, but in a text column it only made one data column look unlike
its neighbours.
2026-07-16 23:46:34 +02:00
peter a78d0faa69 fix(feed): keep the small card's action row inside the card
Six buttons need 188px but the small card's text column bottoms out near
160 (its 180px column less padding), so the reset button — the only one
that is conditional, hence the only one anyone noticed — hung outside the
card by up to 15px. The buttons go tighter there (154px), and the row can
wrap as a backstop rather than clip. AddToPlaylist and DownloadButton now
take the button style as a prop so all six stay one size.

Tile actions also pin to the tile's bottom edge, so they land in the same
place whether the title above ran to one line or three.
2026-07-16 23:34:46 +02:00
peter 5436a81d10 refactor(feed): read the view spec's column bounds in one place 2026-07-16 04:23:43 +02:00
peter 6f1afbbb13 fix(feed): re-measure the bare row's first-paint estimate
Making it a single line halved its height (82 -> 46), which left the
virtualizer sizing a 60-item page ~2160px too tall.
2026-07-16 04:20:59 +02:00
peter f8a6ce69c7 fix(feed): give the small card the icon-only thumbnail controls too
Its column bottoms out at 180px, so its image is narrower than a row's
(measured 170px) while still carrying the 194px labelled pair — the same
overflow the row fix was for. The rule is the thumbnail's size class, not
the family: only the large card has room for labels.
2026-07-16 04:18:31 +02:00
peter ace4a144e8 fix(feed): stop the layout juddering when content lands on the scroll threshold
The feed takes its column count from main's clientWidth and its
thumbnails are aspect-ratio boxes, so the two feed each other once the
content height sits within a scrollbar's width of the viewport: the bar
appears, 15px goes, thumbs shorten, the content fits, the bar leaves,
and round again — every frame. Measured: scrollbar width alternating
0/15 and clientWidth 987/972 on consecutive frames. Reserving the gutter
holds clientWidth constant and breaks it.
2026-07-16 04:14:27 +02:00
peter f108c93c02 improvement(feed): rework the row views from UAT feedback
The bare row becomes a single line — title truncating into the space
left, then channel, badges, meta and the actions each holding a fixed
column — and it drops the 896px reading cap, since with the actions
right-aligned there is no gap for the extra width to stretch. The
actions get a reserved slot: the reset button only exists for videos
with watch state, so without one the meta sat 32px further right on
some rows than others.

Rows and tiles get a permanent container instead of a hover-only fill.
They were transparent at rest and painted an opaque bg on hover, so
running the pointer down the list kept covering and re-revealing the
ambient backdrop — which reads as the background flickering.

Card actions now sit on the bottom edge rather than wherever the title
ends, and the small thumbnails carry icon-only controls: the labelled
Continue / Restart are sized for a card's image and were clipped at
176px.
2026-07-16 04:14:27 +02:00
peter de1b32ba6f refactor(feed): tidy the inline badge classes and the no-thumb guard 2026-07-16 03:28:56 +02:00
peter 944dedb218 fix(feed): keep the duration, live state and resume bar in the bare row
Dropping Thumb dropped everything it draws ON the image — duration, the
live/stream badges, the saved marker and the resume bar. Those are
metadata, not decoration, so they move into the meta line and the bar
onto the row. Splits the spec's overloaded compact flag into thumb/dense,
which meant one thing per family and nothing on its own.
2026-07-16 03:25:05 +02:00
peter 78f21eb46b feat(feed): add the small-card, compact-row and tile views
Tiles answer the row view's wasted space: the actions move under the meta
into vertical slack the thumbnail already reserves, which frees ~168px of
width, so the block survives in a narrow column and the list can go
multi-column. Rows stay single-column on purpose — reading down one edge
is why that view exists.

The column count now comes from a per-view minimum width, so every
multi-column view reflows on the mechanism the card grid already used.
2026-07-16 03:17:30 +02:00
peter 1a94411a03 fix(feed): clamp the view menu's roving index to the option list
Guards the shared switcher against a caller whose option list shrinks
while the menu is open, which would leave every item un-tabbable.
2026-07-16 02:44:22 +02:00
peter 67f14d145f fix(feed): open the view menu on the active item, group its radio items
Seeding the roving index from an effect only scheduled the update, so the
focus effect still read the previous index and focused the wrong item for
one render — a screen reader announced both. Seed it where the menu opens
instead. The modes are one exclusive choice, so they also need a group to
scope their checked-ness to.
2026-07-16 02:42:06 +02:00
peter 805441ac58 fix(feed): give the view switcher the keyboard model its menu role promises
Arrows/Home/End move a roving focus and Escape hands focus back to the
trigger — the menu unmounts under the focused item, which stranded focus
on <body>. Outside-click deliberately does not refocus: that would steal
focus from whatever was just clicked.
2026-07-16 02:36:56 +02:00
peter eae65456ac feat(feed): pick the feed's view from its own toolbar
The view mode moves out of Settings into a switcher next to sort, on the
feed and channel pages alike. It leaves the Settings prefs draft for its
own eager save: a toolbar click must not mark that page dirty and trip
its leave-page guard.

lib/feedView.ts owns the vocabulary and maps the old grid/list prefs
forward on read, so nobody's saved choice is lost.
2026-07-16 02:29:32 +02:00
peter 303c896d87 chore(release): 0.44.0 — peek-open panels, active-filter chips, channel filter, sync chip 2026-07-16 01:21:55 +02:00
peter c88f0343e1 Merge feature/e2-panel-overlay: E2 side-panel overlay, active-filter chips, header sync chip, reachable + multi channel filter 2026-07-16 01:21:06 +02:00
peter 34fd88faf6 fix(channel): "Show in my feed" adds to the picked channels, not replaces
Sprint 5 made the channel filter multi-select, but this handler still wrote a
single-element array — so arriving from a second channel page silently dropped
the first. Union the id in (deduped, so repeating it for an already-picked
channel is a no-op).
2026-07-16 01:18:23 +02:00
peter 36581f2380 fix(channel): "Show in my feed" lands on the feed, not where you came from
It called closeChannel() before setPage("feed"). closeChannel runs
history.back(), and the popstate that restores the page fires AFTER the
synchronous setPage — so the restore won, dropping you back on whatever page the
channel was opened from. Opening from the feed hid it: the page it restored
happened to be the feed.

setPage already closes an open channel page and pushes a clean history entry, so
the closeChannel() call was both redundant and the bug.
2026-07-16 01:13:08 +02:00
peter 0d10e0f0a5 feat(filters): filter the feed by several channels at once (OR)
Pick any number of channels; they OR together and every other filter applies
across the lot — "the last week's unwatched from these three".

`channelId`/`channelName` are replaced outright by `channelIds: string[]`, no
legacy reader (standing rule: no back-compat unless asked — a saved view written
against the old field simply loses its channel filter). The wire and the share
URL move with it: repeated `channel_ids` params, `?channel=a,b`. Backend ORs via
`Video.channel_id.in_()`; the count endpoint shares the same params object.

Two things the migration exposed:
- SavedViewsWidget fed DB blobs straight into serializers that now index an
  array — the FeedFilters type says nothing about what an older build wrote, so
  the blob is coerced where it enters. It white-screened the app before this.
- Chips carried channelName to label themselves. Without it they resolve names
  from the channel cache, so they now fall back to the id, not a shared
  "This channel" that would render N identical chips while the cache loads.
2026-07-16 00:54:22 +02:00
peter 82e351bc62 improvement(channel): bring the actions to the tabs, list every channel
Channel page: the actions sat in the header's top-right corner, a page-width
mouse trip from anything else you'd click. There are only ever two tabs, so
they ride the tab row now, right next to About.

Channel picker: drop the 50-row cap — 320 plain buttons cost nothing to render
(the feed virtualises thousands), so the whole list scrolls in the same box. The
cap was caution, not a measurement. The box gets the app's usual edge-fade to
hint at more, and a line saying "Priority first, then A-Z" — the backend orders
by `priority DESC, lower(title)`, which reads as random to anyone who hasn't set
a priority.
2026-07-16 00:32:53 +02:00
peter 452fbd43de fix(filters): stop the channel list squashing its own rows flat
The picker's list is a flex column with a max height, so its fifty buttons were
flex children free to shrink: each collapsed to ~12px and clipped its own text
into an unreadable smudge instead of the box scrolling. shrink-0 on the rows.

Colour, contrast (9.03) and opacity all measured fine — the height was the
broken part, and no colour check was ever going to see that.
2026-07-16 00:16:32 +02:00
peter 3caef7f6f9 feat(filters): make the feed's channel filter reachable
`filters.channelId` was settable by exactly one thing: a "?channel=" URL from a
shared view. The panel still rendered a CHANNEL card whose only control was
"remove" — a remove button for a filter the UI could not apply.

Two ways in now. The panel gets a real Channel group (searchable picker,
first-class alongside date/tags/language, so the chip row surfaces it for free);
normalizeLayout appends it to saved layouts, so nobody loses their arrangement.
And a subscribed channel's page gets "Show in my feed", which applies the filter
and keeps the rest of your filters — the point is "this channel, the way I
normally read", which the channel page itself can't answer (it runs show=all,
scope=all over the whole catalog).

The picker owns its channel query so a few-hundred-entry list loads when the
panel is actually opened, not on every feed visit, and says "showing 50 of N"
rather than quietly stopping.
2026-07-16 00:11:30 +02:00
peter 0ab1d0b057 fix(header): show the sync chip only where it means something, trim its copy
The chip is about YouTube subscription syncing, so it only earns its place on
the feed those subscriptions fill and the manager where you curate them. On
Plex — a different source entirely — plus playlists, downloads and stats it was
just noise.

Its tooltips were written for a roomy sidebar block and read like documentation
on a pill: the counts explanation went 120 chars -> 48, the full-history one
142 -> 46, and pause/resume drop "background".
2026-07-15 23:55:37 +02:00
peter e642b356a9 fix(header): sit the sync chip with the pills, shorten its tooltip
Pinning it to the header's right edge read fine on a narrow window and stranded
it on a wide one — it's a status, but it's still part of the pill row. It now
follows the search bar.

Its tooltip was the long explanation of the counts, which is redundant next to a
popover that spells them out and overflowed the viewport from the right edge.
The chip gets the counts themselves; the explanation moves onto the popover's
counts row, where there's room for it.
2026-07-15 23:48:36 +02:00
peter 104183118d feat(header): move the sync status into a chip on the header
The rail spent a two-row block plus a divider on per-user sync status. It's
global, not per-module, so it now rides the right end of the floating header as
a pill: the headline count at a glance, an accent dot when something wants you
(paused / missing history), and the counts, state, the channel-manager link and
pause in a popover.

Frees the rail's whole top block. Also drops SyncStatus's two now-dead layouts:
"bar" was a leftover of the top bar removed in 0.42.0, and "rail" lost its only
caller here — the component is chip-only, ~60 lines lighter, and the popover
reuses useDismiss.
2026-07-15 22:42:31 +02:00
peter c440a043f2 fix(feed): no active-filter chips on channel pages
A channel page renders the Feed from its own baseline — channel-scoped,
whole-catalog, show-all, live included — so measuring it against the feed's
defaults labelled all five as "filters you applied" when they are just the page
being itself. It also has no filter sidebar (App hides it), and revealing what
the collapsed panel hides is the chip row's only job.
2026-07-15 22:30:07 +02:00
peter 644d65a3fd feat(feed): removable active-filter chips above the feed
With the filter panel now collapsed to a tab, there was no way to see what was
narrowing the feed without opening it. Every applied filter is now a chip you
can drop on its own, with a Clear all when more than one is on.

The panel's count badge and the chip row read the SAME list (lib/useActiveFilters),
so they can't disagree about what's applied; that list also owns the reset both
"Clear all"s share. Replaces the hand-summed activeCount in Sidebar.
2026-07-15 22:11:44 +02:00
peter 837b48cbfa refactor(feed): lift the sort vocabulary out of Feed into lib/feedSort
The active-filter chips need to label a sort, and parseSort/SORT_MAP were
module-private in Feed. Pure move, no behaviour change.
2026-07-15 22:03:40 +02:00
peter 3596d83acd fix(panels): stop the tab bleeding through the card's rounded corner
The tab stays mounted under the open card on purpose, but the card's 17px corner
radius cuts away its own pixels and the tab's square docked corner sits right
under that arc — showing a hairline of tab glass. Fade the tab out while the
card is open; opacity-0 rather than invisible/hidden, so it stays hoverable and
focusable (that is the whole reason it stays mounted).
2026-07-15 21:44:22 +02:00
peter 532a1c2842 fix(nav): give the rail and the side panel a dead zone between them
The peek handlers sat on the rail's layout slot, which is 80px wide while the
card it holds is only 56px — so the card's own margin was a live hover zone with
nothing visible in it. Leaving the filter tab or panel to the left put the
cursor straight into it and the rail popped open unprompted; the gap between the
two hover zones was literally 0px. Moving the handlers onto the card leaves a
16px no man's land (x=69..85) where neither opens.
2026-07-15 21:38:22 +02:00
peter 79d9bca290 feat(panels): peek-open side panels with a docked tab
The shared SidePanel now follows the nav rail's model, so Filters, Plex and
Playlists all inherit it: unpinned it sits as a small tab docked flush against
the rail (replacing the full-height vertical strip) and expands into the card on
hover or keyboard focus without pushing content; pinned it stays expanded
in-flow. The collapse toggle became pin/unpin, and panels now default to
unpinned. The tab carries the active-filter count and an accent ring.

The tab stays mounted under the open card on purpose: swapping it out from under
the pointer drops the hover that opened the card, and unmounting it while
focused strands keyboard focus.

Measured: feed content starts at 123px instead of 336px when unpinned.
2026-07-15 20:57:21 +02:00
peter 11451c1af2 refactor(nav): extract the peek-open hook for reuse
The rail's hover/focus-to-peek logic — including the portal-proof focus tracking
(React-tree onFocus/onBlur plus a safety net for portals that unmount while
focused) — is about to be needed verbatim by the side panels. Lift it to
useHoverFocusWithin now that a second consumer exists. No behaviour change.
2026-07-15 20:57:21 +02:00
peter 9d9c6894f4 chore(release): 0.43.1 — sidebar no longer latches open after a portaled menu 2026-07-15 20:29:41 +02:00
peter 8191ded159 Merge fix/nav-focus-stuck: rail no longer latches open after a portaled menu closes 2026-07-15 20:29:03 +02:00
peter bcec462ef6 fix(nav): stop the rail latching open after a portaled menu closes
Opening the language switcher (or the account popover) and then dismissing it
left the unpinned rail stuck expanded. Those menus are portaled and unmount
while focused, and browsers fire no blur when a focused node is removed — so the
wrapper's onBlur never arrived and `focused` stayed true forever.

Keep the React-tree onFocus/onBlur (portals bubble through it, which is what
correctly holds the rail open while its own menu has focus) and add a safety net
for the one case they miss: when focus has landed nowhere, clear it.
2026-07-15 20:24:40 +02:00
peter 6456761861 Merge fix/docker-build-cache: keep the heavy image layers cacheable (70s -> 7s rebuilds) 2026-07-15 19:50:28 +02:00
peter baae29d8b6 perf(docker): keep the heavy layers cacheable (70s -> 7s rebuilds)
GIT_SHA/BUILD_DATE were stamped into an ENV at the top of the runtime stage, and
BUILD_DATE is a per-second timestamp — so that layer differed on every build and
invalidated everything below it. The ffmpeg/deno apt install and the full pip
install silently re-ran on EVERY build, and every layer got a fresh digest, so
docker push re-uploaded the whole image each publish. Moving the version stamps
to the end of each stage lets those layers cache and keep their digests.

Also: npm ci off the (previously uncopied) lockfile, BuildKit cache mounts for
pip/npm so a dep bump only fetches the delta, and COPY --chown instead of a
trailing recursive chown.

Measured on a one-line backend edit: 70s -> 7s, with ffmpeg/deno/pip/npm all
CACHED. Image contents verified identical (appuser uid 1000, /app + /downloads
ownership, ffmpeg + deno on PATH).
2026-07-15 19:40:48 +02:00
peter b51f2e5f84 Merge feature/e1-header-chrome: E1 header/account/appearance chrome (nav overlay, role tooltip, logout, opt-in glass tuner) into dev for v0.43.0 2026-07-14 21:41:59 +02:00
peter 44926f1c23 chore(release): 0.43.0 — nav overlay sidebar, account chrome, opt-in glass tuner 2026-07-14 21:41:44 +02:00
peter 6a9bf948f9 feat(appearance): opt-in glass tuner toggle in Settings
Graduate the floating glass tuner from a localhost-only dev tool to a
user-facing opt-in: it's now gated on a per-user `theme.showTuner` pref (off by
default) with a "Show the glass tuner" switch in Settings → Appearance, riding
the existing theme draft-save. Softened the dev-only framing. Behaviour and look
are unchanged; the full preset system is a separate epic.
2026-07-14 21:39:53 +02:00
peter ad1cc4586a feat(nav): slim hover-expand sidebar, role tooltip, logout button
The rail now sits slim by default and expands to a labelled overlay on hover
or keyboard focus without pushing content; the manual toggle became a pin
(pinned = the old in-flow expanded rail). The header no longer reserves a fixed
504px left offset — it's absolutely positioned inside the content column, so it
tracks the actual left zone and reclaims the dead gap. The role badge is gone;
role now shows via a title tooltip on the username, and a logout quick-button
joins the language/about cluster.
2026-07-14 21:39:30 +02:00
peter f96c6a74dd Merge feature/glass-phase2: glass-over-image UI epic (header, floating side panels, i18n, notifications) into dev for v0.42.0 2026-07-13 05:13:52 +02:00
peter 27b9e83af6 docs(release): expand v0.42.0 notes (header, side panels, i18n, notifications) 2026-07-13 05:13:52 +02:00
peter facb3ac9cb feat(ui): unified floating side panels for filters and playlists
Replace the full-height filter rails with one shared floating glass SidePanel
used by Feed, Plex and Playlists. Each collapses to a slim tab beside the nav;
its sections are reorderable "islands" (drag / hide / per-group collapse) with a
per-panel saved layout; the body hides its scrollbar with a soft top/bottom edge
fade. The nav rail becomes a matching floating rounded card.

- New shared SidePanel / PanelGroup / PanelGroups + useScrollFade hook.
- Generalise the feed-only sidebarLayout into a per-panel panelLayout (feed keeps
  its storage key; plex/playlists get their own; persisted per account).
- Lift the Playlists rail to App level (selected playlist is now App state) so it
  floats and collapses like the filter rails; remove the old CollapsedFilterRail.
- The floating top header's fixed offset accounts for the panels' margins.
2026-07-13 05:13:40 +02:00
peter c2d914bacb feat(notifications): coalesce identical notifications with a repeat count
Fold a burst of identical notifications (same level+title+message within a
short window) into one entry with a ×N count instead of stacking copies — tames
a crash-retry loop or a repeatedly-failing poll. Shown in the toast and centre.
2026-07-13 05:13:18 +02:00
peter 07024de6c2 feat(i18n): retire the German interface language
Only English and Hungarian remain. Drops the de locale files, the LANGUAGES
entry, the Google-locale mapping in auth, and the German welcome-message
template. A legacy "de" preference falls back to English gracefully. Content-
language detection and subtitle languages are untouched (media, not UI).
2026-07-13 05:13:08 +02:00
peter 076a2fbe6f fix(header): restore inner scroll broken by the floating-header wrapper
The wrapper that pads content below the fixed header lacked min-h-0, so as a
flex child with min-height:auto it grew to its content's height instead of
staying bounded to the viewport. That let the whole document scroll (the fixed
header/nav stayed put while content ran off-screen). Add min-h-0 so the wrapper
stays viewport-height and the inner <main> keeps its own overflow scroll.
2026-07-13 02:39:43 +02:00
peter d159f35f31 feat(header): floating module header with cyclic nav and shared search
Replace the full-width top bar with a fixed floating header of glass pills: a
fixed-position ModuleName (accent dot + label + prev/next cyclic module stepper)
and a shared SearchBar. The header's left edge is anchored as if the nav rail
and filter sidebar were both open, so it never shifts on collapse or module
change; the right edge leaves a 10% margin.

- ModuleName label width is measured live from the reachable modules' titles in
  the current language, recomputing on a language or account change.
- Module names come from a shared moduleLabelKey (the same short labels as the
  nav rail); NavSidebar and the header now read from one source (lib/modules.ts).
- The arrows step cyclically through moduleOrder(me) — dynamic, no hard-coded
  targets, wrapping at both ends.
- Search is hoisted into the SearchBar for feed (YouTube search), plex, channels
  (including the Discovery tab) and playlists (new); Go button, Enter triggers it.
- The Playlists left rail now reaches the top; header clearance via --hdr-h.
2026-07-13 02:28:06 +02:00
peter a7f9032e0e feat(glass): light-theme background images + enable the toggle in light
Add a light set of the 6 per-scheme backdrops (public/backdrops/light/*.svg) —
soft, pastel-mid, non-figurative washes tuned to each accent so they read through
the light translucent glass. Wire them up centrally:
- App: the backdrop is on whenever bgImage && !perf (was dark-only), so light
  gets it too.
- index.css: per-scheme rules are now [data-theme]-qualified — dark uses the dark
  set, light the /light/ set; shared cover/fixed size rule for both.
- Settings: the Background image toggle + Image-fade slider work in light now
  (removed the dark-only gates); hint no longer says 'dark only'. Trilingual.
The light glass (62% translucent tier) refracts the light backdrop just like dark
refracts its own; one fade slider controls both.
2026-07-13 00:14:20 +02:00
peter 8019b97ca8 feat(glass): make the dev tuner theme-aware (Sync to current theme)
The tuner writes inline vars (global), so its sliders always showed the dark/
backdrop-on baseline even when viewing light. Add a 'Sync to current theme'
button that clears the inline overrides, reads the live theme-driven values into
the sliders, and headers the Copy-CSS export for the active scope (:root for
dark, html[data-theme="light"] for light) — so light-theme glass can be dialled
in and exported straight into the right block.
2026-07-13 00:02:04 +02:00
peter 89b6d8a7bb fix(glass): give light theme its own translucent tier + unify the Plex filter rail
Light theme has no background image, so the adaptive :root glass (94%) rendered
as flat snow-white islands, while the Plex filter rail (still on bg-surface/40)
showed the ambient tint — an inconsistency. Fix centrally, not per-component:
- index.css: one html[data-theme="light"] token override (surface 62 / card 74 /
  menu 86) so every .glass* surface is translucent in light and picks up the
  ambient tint, like it refracts the backdrop in dark.
- PlexSidebar: bg-surface/40 -> glass, so all filter rails bind to the same token.
One central lever now controls the light-theme glass translucency for the whole app.
2026-07-12 23:53:56 +02:00
peter ec23dafffe chore(glass): scrub dev-process/hostname refs from tracked comments
Code review (public-repo rule): the GlassTuner + index.css/App.tsx comments named
the prod hostname and dev-process jargon (UAT/HMR/dev ports/epic), which ship in
the public bundle. Genericised the comments — no behaviour change.
2026-07-12 23:30:50 +02:00
peter 06b4411aa4 feat(glass): re-theme the public WatchPage onto the token system (Phase 3)
The standalone /watch share page was on a hard-coded slate/teal palette (it
renders outside <App> with no theme). Add a baseline theme (dark/midnight) in
main.tsx so the token CSS vars resolve on every route incl. the public pages,
then swap all of WatchPage's slate/teal/hex to theme tokens (bg-bg/text-fg/
text-muted/accent, surface/border), with the password card as glass. Now
consistent with the app's default look; the app still overrides with the user's
saved theme when it mounts.
2026-07-12 23:00:36 +02:00
peter cb2ad684bd feat(glass): glassify modal inner panels, dropdowns & list rows (Phase 2)
Medium container surfaces -> the glass system so they refract the backdrop
consistently: Playlists row (bg-card->glass-card), DownloadCenter user-picker +
ShareDialog share-picker dropdowns (bg-card->glass-menu), DownloadCenter stat
cards / download rows / usage block, ShareDialog share-link block, VideoEditor
selection bar, ProfileEditor profile rows (bg-card/40|bg-surface/60->glass-card).
Tight admin rows and tiny inputs left solid on purpose.
2026-07-12 22:54:13 +02:00
peter a625974471 feat(glass): glassify the side rails + back-to-top (Phase 1, feed chrome)
Convert the always-visible flat rails to the frosted glass system so they sit
consistently over the new background image (like the nav/header already do):
- Feed filter Sidebar + CollapsedFilterRail: bg-surface/40 -> glass
- Playlists left rail: bg-surface/40 -> glass
- BackToTop FAB: bg-card+border -> glass-card glass-hover
Small controls inside (filter pills/inputs) stay solid — a solid-on-glass
hierarchy, not everything translucent.
2026-07-12 22:44:57 +02:00
peter 8069cbd4b7 Merge feature/glass-consistency: variable-driven glass, per-scheme backgrounds, Starship/Matrix schemes (0.42.0) 2026-07-12 22:29:07 +02:00
peter 88e6b6a2a0 chore(release): 0.42.0 — glass-over-image look + Starship/Matrix schemes 2026-07-12 22:28:50 +02:00
peter 3f91453254 feat(glass): user-facing Image-fade slider under Settings > Background image
Replaces the dev-only backdrop-fade knob with a real control: when Background
image is on (dark theme), a 0-100% 'Image fade' slider appears beneath it and
drives --bg-fade live (higher = fainter). Persists to DB + localStorage via the
existing theme prefs path (ThemePrefs.bgFade, applyTheme sets the CSS var).
Removed --bg-fade from the GlassTuner so the two don't fight over the var.
2026-07-12 22:24:09 +02:00
peter a8b1b26901 feat(glass): per-scheme background images + glass-over-image mode
Land the UAT-approved backdrop direction as a real feature (replacing the dev
thumbnail prototype):
- 6 generated abstract SVG backdrops in public/backdrops/, one tuned to each
  accent scheme (dark, mixed-colour, few light areas). Non-figurative + generated
  = repo-safe, tiny, accent-matched.
- New data-backdrop="on" state (dark theme + the setting + not perf) paints the
  per-scheme image on <body> (app root now transparent so it shows app-wide and
  the glass refracts it) AND switches the glass to its translucent tier (the
  user's tuned values: surface 50 / card 60 / scrim 30 / blur 10 / edge 25).
  Off / light / perf -> flat colour + solid glass (the readable fallback).
- Settings > Appearance 'Background image' toggle (bgImage pref, dark-only),
  trilingual. Flat is the fallback + opt-out.
- Remove the FeedBackdrop prototype + its tuner controls; tuner baseline resynced
  to the glass-over-image tier, add a Backdrop-fade slider.
GOTCHA fixed: the dark-mode ambient 'background' shorthand reset background-size
to auto (image rendered un-covered); out-specified it with [data-theme="dark"].
2026-07-12 21:20:40 +02:00
peter e1b8916be4 feat(glass): dev-prototype feed backdrop (blurred rotating thumbnail)
Idea from UAT-2: give the feeds the same 'content behind glass' lift the Plex
detail page gets. New FeedBackdrop paints a faint, slowly-rotating feed thumbnail
as a fixed backdrop on <main> (the proven art-backdrop path — a fixed z:-1 layer
is occluded by the root's opaque bg-bg). Blur = tiny-canvas downscale + cover
upscale (no CSS filter; ytimg is CORS-readable so it uses a data-URL, else raw).
Flat --bg stays as the 'perf mode' look. Behind the GlassTuner toggle + a
Backdrop-fade slider (--feedbg-fade) so it's judged/tuned in UAT before anything
is committed for real.
2026-07-12 20:40:20 +02:00
peter 96b7650d6a fix(glass): soften the Matrix scheme accent (neon phosphor → muted emerald)
UAT: #2fe36e read as too vivid/stabby. Drop to #46b87c (lower saturation) +
slightly less green-tinted fg; update the swatch to match.
2026-07-12 20:27:37 +02:00
peter cfd02ba471 feat(glass): bake two-tier defaults (Adaptive chrome + media scope), add Starship/Matrix schemes
From UAT: glass reads strongest where there's content behind it, so split the
look into two tiers instead of one global compromise.
- index.css: global :root defaults are now the 'Adaptive' baseline (solid-enough
  dark glass for content-less chrome). New .glass-media scope class carries the
  UAT-tuned translucent values (blur 10 / surface 50% / card 60% / scrim 30% /
  edge 25%) for surfaces that float over real artwork.
- Apply .glass-media to the art-backed Plex detail views (PlexInfo page variant,
  PlexShowView, PlexSeasonView) and drop PlexInfo's hard-coded inline 55%
  overrides so those panels are scope-driven and consistent (hero no longer
  reads solid while the season strips are translucent).
- Two new colour schemes: Starship (deep-space blue, azure accent) and Matrix
  (near-black, phosphor green), each with dark+light tokens + swatches.
- Remove the thumbnail-mosaic backdrop (did nothing usefully; its home is the
  future 'videos behind glass' phase) — deletes MosaicBackdrop, mosaic vars/CSS,
  and the tuner's mosaic controls. GlassTuner presets resynced to the new
  baseline (Current / Media / Max-readable).
2026-07-12 20:12:25 +02:00
peter 2004ab004e feat(glass): variable-drive the glass system + dev GlassTuner (Phase 0/0b)
Foundation for the app-wide glass-consistency epic:
- index.css: every look-driving value (blur/saturate/dark-brightness/panel-card-
  menu opacity/border/inset/edge/scrim/ambient + mosaic) is now a CSS custom
  property defaulting to the historical hard-coded value — one-place tunable, no
  visual change until a value is bumped.
- GlassTuner.tsx: dev-only (localhost-gated) always-on corner panel that live-
  drives those vars, with treatment presets (Adaptive/Gradient+/Edge+scrim),
  per-scheme palette editor, muted thumbnail-mosaic toggle, localStorage persist,
  and Copy-CSS export. Deleted once the tuned finals are baked in.
- MosaicBackdrop.tsx: opt-in muted (blur+darken+desaturate) mosaic of on-screen
  thumbnails so dark glass has real content to refract.
2026-07-12 19:14:40 +02:00
peter 08919199c2 chore(release): 0.41.0 — admin audit log + channel About enrichment/fixes 2026-07-12 17:57:39 +02:00
peter c9949dda42 Merge feature/channel-card-enrich: channel About-tab enrichment + fixes
About tab now shows country/language/topics/keywords (migration 0055_channel_keywords);
Discovery tab links each channel to its in-app page; fixes: tab-switch header shift
(scrollbar-gutter:stable), hide the Source filter on the channel-scoped feed, recover a
throttled channel banner (retry + cache-buster), and match the no-banner fallback bar to
the banner height so the avatar no longer overlaps Back. /code-review findings fixed;
E2E + user UAT passed.
2026-07-12 17:52:28 +02:00
peter 3c104458d1 fix(channel): no-banner fallback bar matches the banner height (avatar/Back overlap)
The no-banner placeholder was a short h-14 (56px) bar while a real banner renders
~150px, so the -mt-8 avatar rode up into the absolutely-positioned Back button on
banner-less channels (e.g. Billet Box). Give the fallback the same aspect-ratio +
maxHeight as the banner container so the overlapping avatar has the same room and
the header layout is consistent whether or not a channel has a banner.
2026-07-12 17:01:29 +02:00
peter 26230b52e8 fix(channel): recover a throttled channel banner instead of leaving it broken
The banner URL is valid, but googleusercontent intermittently drops the banner
request when the channel page fires the banner + avatar + ~16 thumbnails at once;
the browser then negative-caches the miss, so the banner stays broken with no
auto-retry (even though the same URL loads fine on a fresh request). On error we
now retry a few times with a ?r= cache-buster (a fresh URL that bypasses the
negative cache), and only fall back to the blank bar if it keeps failing (a
genuinely dead URL). Fixes the broken-banner on channel pages like BASSHUNTER.
2026-07-12 16:54:55 +02:00
peter 763d8533f2 fix(channel): hide the Source (search-results) filter on the channel-scoped feed
The Source selector (organic / include-search / search-only) is a global-catalog
concept — it picks how videos ENTERED the library. On a single channel's page the
view is pinned to librarySource=all and would only offer a near-empty, confusing
slice, so hide the selector there (the main feed keeps it).
2026-07-12 16:36:32 +02:00
peter 05aa70ed33 fix(channel): harden About-enrich edge cases from review
- apply_channel_details: (branding.get("channel") or {}) so a "channel": null
  in brandingSettings can't AttributeError-abort the enrich batch.
- topicLabels: drop empty labels so a malformed topic URL can't render a blank chip.
2026-07-12 16:17:17 +02:00
peter 0c26464c90 feat(channel): enrich the About tab + fix the tab-switch header shift
- fix: reserve the scrollbar gutter (scrollbar-gutter:stable) on the channel
  page's scroll container, so switching between the tall Videos tab and the
  short About tab no longer shifts the banner/avatar/buttons a few px sideways
  (the vertical scrollbar was appearing/disappearing between the two tabs).
- About tab now shows Country (flag + localized name), Language, Topics
  (topicCategories → readable chips), and Keywords (brandingSettings keywords
  parsed into chips, quoted multi-word tags kept whole). country/language/topics
  were already stored; keywords is new (migration 0055_channel_keywords, mapped
  in apply_channel_details, returned by channel_detail).
- Discovery → channel page: the Channel-manager "Discover from playlists" tab now
  links each channel name to its in-app channel page (ChannelLink onView), so a
  discovered channel's About/videos can be inspected BEFORE subscribing (was
  subscribe-only, plain text before).

Note: the channel info-card epic itself was already delivered in v0.19.0; this is
the About-tab enrichment follow-up + the header-shift fix. external_links stays
empty by design (YouTube removed the field from the Data API ~2023).
2026-07-12 16:12:39 +02:00
peter 5bad18d571 Merge feature/audit-log: admin audit log (Notification P3)
Append-only who/what/when trail for admin actions + scheduler run summaries,
generalizing QuotaEvent, at action/run-summary granularity (never per-row). New
admin-only Audit log page (DataTable), retention GC (audit_gc, default 180d),
trilingual. /code-review findings fixed (no-op-run flood, proxy-cred redaction,
target_id truncation, config no-op skip). E2E-verified; durability proven.
2026-07-12 08:04:59 +02:00
peter 201b8b00b3 fix(audit): address /code-review findings
- scheduler _run_changed: count a run as "changed" only on a truthy NUMERIC
  value — status-string no-op dicts like {"skipped":"disabled"} (Plex off, the
  default) or {"skipped":"no demo account"} were flooding the trail every interval.
- audit.record: truncate target_id to the column width (128) like summary[:255],
  so an over-long target (e.g. a 128+ char demo email) can't abort the mutation
  the audit row is committed alongside.
- config set/reset: redact URL userinfo (user:pass@) from logged non-secret
  values — a proxy setting can embed credentials that shouldn't land in the trail.
- config set: skip the audit row when a non-secret value didn't actually change
  (no-op re-save shouldn't add noise); secrets are write-only so always logged.
- audit page title (pageMeta): add the missing "audit" case so the browser tab
  reads "Audit log · Siftlode" instead of falling back to "Feed".
2026-07-12 07:47:07 +02:00
peter 911f5100d8 feat(audit): admin audit log (Notification P3)
Append-only who/what/when trail for admin actions + scheduler run summaries,
generalizing QuotaEvent. Logged at ACTION / run-summary granularity — never
per-row (a scheduler run touching thousands of videos is ONE row).

Backend:
- migration 0054_audit_log + AuditLog model (actor_id FK SET NULL, action,
  target_type/id, summary, before/after JSON, created_at).
- app/audit.py: AuditAction constants (single source of truth, i18n'd on the
  client) + record() (adds to the caller's txn) + gc(retention_days).
- producers wired: role change / suspend / delete / invite approve-deny-add /
  demo add-remove-reset / discovery purge (admin.py); config set/reset — secret
  VALUES never logged, key only (config.py); scheduler interval + maintenance
  tune (scheduler routes); sync pause/resume (sync.py); and the scheduler _job()
  choke point writes ONE run-summary row per run — manual runs + errors always,
  automatic runs only when they changed something (no no-op-poll spam).
- retention: audit_gc scheduler job prunes rows older than audit_retention_days
  (sysconfig, default 180; 0 = keep forever).
- admin API routes/audit.py (/api/admin/audit): recent-window list (actor emails
  resolved, System for background) + clear (leaves one tamper-evidence row).

Frontend:
- new admin-only "Audit log" nav page (ScrollText) using the shared DataTable
  (first admin page to reuse it) — sort/filter/paginate/persist, action select
  filter, actor text filter, before→after detail, Clear-all with confirm.
- AuditLog.tsx + auditColumns.tsx + api.adminAudit/clearAudit + AuditRow type.
- trilingual audit + auditActions namespaces (HU/EN/DE) + nav + config-group +
  scheduler job labels; Audit config group (retention days).
2026-07-12 07:32:41 +02:00
peter 2c4d4fa193 chore(release): 0.40.0 — deferred-bugs closeout 2026-07-12 06:54:49 +02:00
peter 2846129363 Merge chore/deferred-bugs: close the code-hygiene sweep's deferred 🟡/🟢 tails
Verified every deferred per-subsystem item against current source (8 parallel
agents), then resolved all of them: 20 fixed, 14 WONTFIX (recorded with rationale
in siftlode-ops/CODE-HYGIENE.md), 2 no-op (already-shipped / already-mitigated).
ruff+tsc+knip green; backend runtime-smoke-tested; /code-review (high) findings all
addressed; SB2 confirm-dialog E2E-verified. Detail in the 3 branch commits.
2026-07-12 06:50:10 +02:00
peter 64a97ede3d fix(deferred): address /code-review findings on the closeout diff
- channels CB3: sort discovery rows NULLs-last (title is None) to match the DB
  ORDER BY exactly — coercing NULL title to "" floated untitled channels to the top.
- AdminUsers SC1: track in-flight rows in a Set, not a single id — per-row disabling
  now lets the admin start concurrent row actions, and a shared id was cleared by
  whichever settled first (re-enabling a still-pending row → possible double-submit).
- ChatThread CT4: reset the incoming-count ref when partnerId changes — the Messages
  page reuses one ChatThread across conversation switches, so a same-count switch
  could skip the open-marks-read badge refresh.
- messages MB3: push a bare {type:"unread"} (drop the now-dead count query — the
  client re-fetches on the signal and ignored the number anyway).
- api.ts: extract the shared keepalive `beacon()` helper (saveProgressBeacon +
  plexProgressBeacon were near-verbatim copies).
2026-07-12 06:21:29 +02:00
peter 5ac30abe1f fix(deferred): close frontend UI/UX tails from the hygiene sweep
- player YB2: keepalive pagehide beacon (api.saveProgressBeacon) so the resume
  position isn't lost on F5/close within 5s of the last checkpoint (mirrors Plex).
- player YB4: bound consecutive unplayable items in auto-advance so an
  auto-advancing/loop=all queue can't spin over a run of dead videos.
- downloads B6 (client): localise structured quota/edit errors centrally in
  api.req() via localizeDetail() + Intl.NumberFormat (fixes HU/DE + decimal sep);
  + 3-locale download error strings.
- channels FB1: focusChannelToken bump so re-clicking the same channel re-seeds
  the search box; FB2: syncSubs invalidates feed/feed-count (set can change now).
- config CB2: reseed the ConfigPanel draft on a key-set dataVersion + explicit
  post-save token, so a mid-edit refetch can't clobber an in-progress edit.
- config AB3 (UI): a blank allow_empty field stores "" instead of resetting.
- admin SB2/SC2: confirm + success toast on demo-whitelist remove and deny-invite
  (+ trilingual strings); SC1: disable only the acted-on row (pendingId), not all;
  SB3: pause the 1s scheduler countdown ticker while the tab is hidden.
- messages CT4: only invalidate conversations/unread when the incoming-message
  count actually changed, not on every 20s poll; MB3 (client): react to the live
  unread ping (onUnread → invalidate); MC3: extract e2ee installKey (setup/unlock);
  CC2: hoist POLL_MS to messaging.ts; MB4: document the reload-scoped socket.
2026-07-12 05:59:25 +02:00
peter 229502a61e fix(deferred): close backend correctness/efficiency tails from the hygiene sweep
Verified each deferred per-subsystem item against current source, then fixed the
real ones (tradeoffs left as-is, see siftlode-ops/CODE-HYGIENE.md closeout):

- search BUG-3: don't finalise shorts_probed on un-enriched stubs (enrichment
  failure/omitted rows) — restores the scheduler's enriched_at guard so a real
  Short can't leak into the feed and never get reclassified.
- downloads GC: 4th pass reaps orphaned errored, fileless, unreferenced assets
  (they carry no TTL, so the expiry passes never cleared them).
- downloads B6: quota/edit errors now return a structured {code,reason,limit}
  the trilingual client localises, instead of hardcoded English + dot-decimal GB.
- channels BB1: reset-backfill re-arms deep sync on every subscriber (bulk update)
  instead of 404ing when the admin isn't personally subscribed.
- channels BB2: enrich the stub on BOTH the normal and "already exists" desync
  path (nest the insert try inside the client `with`).
- channels CB3: drop the redundant second _discovery_rows read (identity-mapped
  rows are already enriched; re-sort in Python for the title tie-break).
- playlists PC1: read the live playlist once in push() and share it with plan_push
  + push_playlist (halves read-quota, closes the second TOCTOU window).
- playlists PC2/PC5: batch the cover thumbnails in one window query (was N+1);
  rename combines count+duration into one aggregate + a single cover lookup.
- messages MB2: get_thread only resolves a messageable partner OR one we already
  share a thread with (closes the user-enumeration oracle).
- messages MB3: push a live unread-count to the user's other tabs after mark-read.
- messages MC4: list_conversations uses DISTINCT ON + grouped COUNT instead of
  loading the whole message history into memory.
- config AB3: per-spec allow_empty so smtp_user/youtube_api_proxy can be blanked
  to disable them rather than snapping back to the env default.
2026-07-12 05:59:03 +02:00
peter 216200eebf release: v0.39.1
OAuth scope-clobber fix (sign-out/in no longer drops YouTube access) + admin
access-request email accuracy/multi-admin + auth loose-ends (register/reset timing,
messages_ws dedup, revoke decrypt).
2026-07-12 04:57:06 +02:00