Compare commits

...
151 Commits
Author SHA1 Message Date
peter 0f9ed7cdbf test: a list key waits for the list's registration, not only its rows
gate / frontend (push) Successful in 6m5s
gate / backend (push) Successful in 7m10s
The server gate's frontend job went red on 0.96.0: PlexPlaylistView's j/Delete
test pressed j after the rows painted but before useListNav's passive effect
registered the list, so the key did nothing on the slower runner. Every test
that presses a list key after an async load now waits for [data-nav-list] on
its list first (PlexPlaylistView, Playlists, Home, PlexDiscover). Checked
against a 30 ms delayed registration: the old tests fail, the new ones pass.
2026-09-27 23:01:57 +02:00
peter ffcea0cb07 release: 0.96.0 (feature/e28-design-constitution)
gate / frontend (push) Failing after 6m12s
gate / backend (push) Successful in 7m22s
2026-09-27 22:46:39 +02:00
peter a3f8314c9a chore(release): 0.96.0 — the design constitution
E28: one action list per object, keyboard reorder, touch drawers and player
controls, the table pager, plus four deep-review fix rounds (playlist sync
locking, player restart and focus behaviour).
2026-09-27 22:46:39 +02:00
peter d5ccb1091a fix(player): a held key on faded bands stays one blind press; a play asked for mid-restart is the new session's
The repeats of a Space/Enter that landed on faded bands pressed the control once the bands were up;
they are swallowed with it. A play or pause while a session restart is out now sets that session's
intent instead of playing the outgoing stream. Tests: Skip intro outside the bands keeps its keys,
every restore takeover (wheel, finger, scrollbar, key) is covered, and the chip handoff test tells
the chip before from the row's first control.
2026-09-27 22:34:14 +02:00
peter 528774d640 fix(playlists): a push stops at a list deleted mid-way, and a rename made during it stays unsynced
A delete landing while a push read YouTube left a plan over no items, so the push emptied the
YouTube playlist a local-only delete keeps: the push now stops at a vanished list (PlaylistGone,
404), and a delete is refused while a sync of the list runs. The closing baseline fingerprints the
title YouTube got, not the one its lock re-reads, so a rename made during the writes stays dirty.
The other-tab test helper bounds its lock wait, so a lock-order regression fails instead of hanging.
2026-09-27 22:34:14 +02:00
peter 3a9ea9e330 test: the side panel's focus step is held in its commit; the MenuCheck contract counts chosen roles 2026-09-27 21:56:24 +02:00
peter 25f2d9d0e4 fix(ui): a removed chip's focus outlives its neighbour; a scroll restore yields to the reader
A chip handed its focus to a Clear all or +N that went in the same removal, and the focus fell to
the body. The restore now stops when the reader scrolls, and finishes the way once the Back-to-top
spacer brings the saved spot within reach.
2026-09-27 21:56:23 +02:00
peter 5ab6778b97 fix(player): a Restart of the paused film plays the new session; a key on faded bands only shows them
The same-film restart read the paused state, restarted the session without play, and resumed the
outgoing stream beside it. Space or Enter on a control the bands had faded from pressed it unseen;
now it only brings them back, and Space plays or pauses.
2026-09-27 21:56:23 +02:00
peter 26697472cf fix(playlists): every sync path locks the list before its items, and holds it to the write
The mirror refresh re-ingested inside its row lock, and that ingest's commit let go of the lock
before the replace: a remove made then was undone and the list read as synced. Push, re-pull and a
delete touched items before the list row, a deadlock beside the playlist-first route edits. A push
now re-plans from the items under the lock and derives dirty after its YouTube writes.
lock_playlists moves to the sync package. Tests pin both ids of a move's lock and the claim's
own connection.
2026-09-27 21:56:23 +02:00
peter 2abd4ffcb6 fix(player): a session superseded before it played is reclaimed, not "couldn't start"
Two session requests for one item in flight at once (a quick double seek; the
StrictMode replay of the start effect) let the server's last spawn be the older
request's. The newer request's answer then named a session already gone; its
playlist 404'd before a manifest parsed, and the player stopped with "Playback
couldn't start". The 404 reclaim now covers a session that never played too,
bounded by the same budget, and keeps the start's own autoplay.
2026-09-27 20:22:43 +02:00
peter a9ac303d3e fix(side-panel): the key's focus steps into the peeked card in the commit that opens it
A frame later never came in a page the browser was not painting (measured: a hidden page runs no
requestAnimationFrame), and the focus stayed on the inert tab under the card.
2026-09-27 19:58:18 +02:00
peter 740602b9bf fix(ui): focus, paging and state that a key or a finger can reach
- A key landing on a side panel's tab steps into the card it peeks open, not onto the hidden tab.
- The Channels cards keep their status / pager row at the list's end while the band is folded.
- A menu row's note shows on an enabled row too: the download state under 'Download…', not a title.
- A removal chip hands the focus to its neighbour before it goes, for every caller (the feed's and
  Plex's active filters); the hidden-filter cue's own copy is gone.
- Back to a spot within a page's end spacer lands at the page's end, not its top.
- A running push / revert is tracked per list: another list's push is no longer refused unseen.
2026-09-27 19:43:04 +02:00
peter e31a588f19 fix(menus): one tick for every choice; a listbox holds its options only
- The saved views, the view switcher, the year filter and the table's single-choice filter mark the
  chosen row with MenuCheck, as DESIGN asks, not a trailing tick, a dot or a filled row; a contract
  test holds every checkable menu row to it. keepSelectedPaint had no caller left and is gone.
- A combobox's panel carries its listbox role on an inner PopoverList (listInside), so the failed
  load, Loading, No matches and the 'n more' line sit beside the options, not inside the listbox. The
  Plex search box's Loading line likewise.
- The Plex sidebar's playlist picker offers Create only once the lists have loaded: over a failed load
  it pushed the error and its Retry out at the first keystroke.
2026-09-27 19:43:04 +02:00
peter cda626be10 fix(player): the playing film restarts in place; arrows stay the volume; the bands' holds end
- The info overlay's collection strip listing the film itself: Play/Restart restart or resume it in
  place instead of pausing it on a frozen 0:00.
- Up/Down on the closed ⋯ and tracks buttons stay the volume (openOnArrowKey: false).
- A keyboard focus in the bands holds them for 10 s after the last key, not the whole film; a tap
  leaving a tapped button no longer re-shows the bands its own toggle then hid.
- Next leaving under the focus hands it to its neighbour (the player keeps its focus).
- The chosen audio / subtitle track wears the MenuCheck tick.
2026-09-27 19:42:53 +02:00
peter b7ce1d467c fix(playlists): item writes lock the list first; one bulk pull per user; the claim on its own connection
- Every playlist item write (add, remove, move, reorder, rename; Plex remove and remove-bulk) row-locks
  the playlist before it touches an item, a move both lists in id order: remove beside reorder was a
  deadlock, and two adds of one video a unique-key 500.
- The scheduled mirror refresh re-reads dirty under that lock, so an edit made during its YouTube calls
  is not replaced by YouTube's order.
- A user's bulk pull holds a per-user advisory claim: two at once created duplicate mirrors. The manual
  sync answers 409 sync_all_busy instead of '0 synced', and like the re-pull spends its cooldown only
  on a pull that runs.
- Deleted mirrors are committed at once, not only when a later list commits.
- The claims open an unpooled connection of their own instead of a third pooled one per push.
2026-09-27 19:42:53 +02:00
peter 52fb999af6 feat(pickers): a list that failed to load offers Retry
A picker whose options failed to load said so, but the only way back was closing its dialog.
Combobox takes `onRetry`: a Retry beside the message for the pointer, and Enter in the field for
the keyboard (the panel is a listbox, which may hold options only, so the alert names Enter for
assistive tech). Every caller with an error state passes its query's refetch: both user pickers,
the channel picker and the two Plex pickers.
2026-09-27 17:47:01 +02:00
peter e0453fa07d fix(playlists): one YouTube sync of a list at a time, across tabs and the scheduler
The client's in-flight flag covers the menus of one page only. A push reads YouTube, then writes
both sides for seconds and commits step by step, so a row lock ends at its first commit: two tabs,
or a push and the scheduled mirror refresh, could interleave into a doubled YouTube item or a
baseline recorded over the other's writes. `sync_claim` takes a session-level advisory lock on a
connection of its own (it survives those commits; a crashed worker's lock dies with its
connection, so no claim column is left stuck). Push and revert answer 409 `sync_busy`; the
scheduled refresh skips a list being synced and picks it up next run.
2026-09-27 17:47:01 +02:00
peter 2525a12264 fix(keys): a key a focused widget already handled fires no shortcut
The player's settings tabs switched tab on an arrow and prevented its default, yet the player's
window listener still sought the video 10 s. dispatch() now skips a defaultPrevented key for
every scope; listNav's private copy of the same guard goes.
2026-09-27 17:28:10 +02:00
peter 8e09dd85f8 test: the store contract reads the last match without Array.at (the node tsconfig's lib) 2026-09-27 16:38:33 +02:00
peter addb3d1b32 fix(a11y): every side-panel pin names its panel; the tab under an open card leaves the tree
The card's own pin and the drawer's still said 'Unpin panel' / 'Pin panel open' after the edge tab
learned the title (F6). Naming them too made two buttons share a name, because the invisible tab
under an open card was still in the accessibility tree: it is now aria-hidden as well as out of
the Tab order. The generic sidebar.pin / unpin keys are gone.
2026-09-27 16:30:40 +02:00
peter 86884e368e fix(ui): the page's end scrolls out from under Back to top
The button floats over the bottom-right corner, and at the end of a long table it covered the closing
pager's '100' and 'All'. While it is up, PageScroller ends the page a button's height later (only
then, so a full-height layout gets no scrollbar). Its shown state is a shared store read through a
use… wrapper: a store's .use() called straight from a component is memoized by the React Compiler
and never re-renders (contract: store.contract.test.ts).
2026-09-27 16:28:18 +02:00
peter 51f881681b feat(tables): name a filter whose column is off screen, and let its chip clear it
A tabletHidden/narrowHidden column below its width, or any column in a card
layout, took its header funnel with it: the rows were narrowed by a filter
nobody could see. The cue is shown by the inverse of the column's own
container query, so it agrees with the table at every width and text size.
Wired into DataTable, the playlist table and the channel cards (E28 F2).
2026-09-27 16:22:26 +02:00
peter 27483ae1d6 fix(focus): a removed control's focus skips a neighbour the CSS has hidden
A display:none button refuses focus(), so handing the focus to a hidden
column's header button dropped it to <body>.
2026-09-27 16:22:26 +02:00
peter 595c29e17c style: prettier 2026-09-27 15:17:02 +02:00
peter acf93a1f36 test: the video menu's download row is 'Download…'; drop home.pin.menu (its menu is gone) 2026-09-27 15:13:19 +02:00
peter 84278293df docs(ui): the raw-button rule is 'outside the control primitives', and the README table is whole
CLAUDE.md, DESIGN.md and ui/README said a raw <button> is allowed anywhere in components/ui/; the
lint rule allows it only in BUTTON_PRIMITIVES (and refuses a bare FocusSafeButton elsewhere too).
The focus-safe rule's message no longer points feature code at FocusSafeButton. The README's
'actions & inputs' row was split in two by a stray line break.
2026-09-27 15:09:22 +02:00
peter dc24d80ed4 fix(player): a description link keeps its sentence's punctuation and its own parentheses
The trailing .,;:!?)] was trimmed off the href and dropped from the text too, and a URL ending in a
balanced ")" (a Wikipedia article) was cut short. The trim keeps a closing paren that has its
opener, and the trimmed tail is put back after the link.
2026-09-27 15:08:27 +02:00
peter 3810af66da fix(channels): every subscribe and unsubscribe refreshes the same reads
Each of the four sites kept its own invalidation list and each missed something (the channel menu's
unsubscribe left the feed showing the channel's videos; the channel page's left the counts stale).
lib/subscriptionCache invalidateSubscription is the one set; a contract test holds every call site
to it.
2026-09-27 15:07:39 +02:00
peter eb339195c7 feat(channels): the band's tag chips and status row fold on a short window (B4)
Every other page band folds its filter row below ~600px of height; the Channels band never did, so
its tag chip lane and status / pager row kept a short laptop window for themselves. They fold behind
one "Filters · n" (n = picked tags + other active column filters); the tabs and the TAGS editor stay,
and a folded table keeps its controls at the list's end.
2026-09-27 15:06:32 +02:00
peter 5d1065c1bc feat(playlists): a playlist's menu copies its name and its YouTube address
Every object's action list ends its middle group with Copy name / Copy link (E28 C1, the rows a
finger cannot select); the playlist's had neither.
2026-09-27 15:04:47 +02:00
peter 6ffb5bd8c1 refactor(pins): drop the unused ComfortPinMenu and the comment that described it
Every surface composes the pin row into its object's one action list (E28 C1); the pin-only menu
had no caller left. Also un-exports isStandInFocus, which only its own file reads since H1.
2026-09-27 15:03:28 +02:00
peter dddf79ec2f refactor(ui): FilterSelect and Combobox draw their checks with MenuCheck
They still painted their own 14px box beside the menus' 16px MenuCheck. A contract test holds the
filled-box paint to MenuCheck alone.
2026-09-27 15:02:42 +02:00
peter 5ebb4cb506 fix(a11y): chips, grips, the panel tab and Back to top keep the focus somewhere real
- F5: a chip's × pressed from the keyboard hands the focus to the next chip (else the previous one,
  else what follows) before it unmounts — in DismissibleChip, so all five chip rows get it
  (`focusNeighbour` in lib/useFocusKeeper).
- F4: a drag grip names what it lifts (`common.order.dragNamed`: panel groups, personal views);
  the two generic "Drag to reorder" keys are gone.
- F6: the side panel's edge tab is out of the Tab order while the card covers it, names its panel,
  and a keyboard pin moves the focus into the card.
- F7: Back to top is no Tab stop while hidden, and a keyboard press leaves the focus on the page.
2026-09-27 15:01:22 +02:00
peter 0b1d905866 fix(ui): Enter on a selectable title opens it even with its words selected
endsTextSelection takes the click event and exempts a keyboard click (detail 0): Enter / Space
ends no selection, so a title selected and then tabbed to opened nothing. All three callers
(FocusSafeButton, the Plex caption, the video card title) go through it.
2026-09-27 14:55:50 +02:00
peter 52c15adaf2 fix(downloads): a row whose remote picture fails too falls back to its glyph
The remote thumbnail was the last resort with no fallback of its own, so a dead CDN link drew the
browser's broken-image mark. A failed remote URL is remembered like a failed poster, and the row
shows its film / music glyph.
2026-09-27 14:54:28 +02:00
peter 3372626e34 fix(notifications): "Find in feed" drops every narrowing that could hide the video
locate() spread the reader's filters and cleared only the tag, search and age cap, so a year, a
custom range, a length, a content type (a hidden Short under "no Shorts") or the scope could still
leave the video out. It builds the filters from nothing now, keeping only the sort and tag modes.
(Also types the C1 test's pending plan.)
2026-09-27 14:53:24 +02:00
peter 98bb841a6a fix(playlists): one push or revert at a time for a list, whichever menu started it
The in-flight flag was per hook instance, and the rail row's menu and the list page header's "…"
are two at once: a push from one left the other offering a second push. It is one app-wide store
(`lib/store`), read with `get()` at the start so a second start refuses before a render; a revert
re-reads it after its confirm dialog.
2026-09-27 14:51:37 +02:00
peter 98bf0a9d66 fix(i18n): the video menu's download row says "Download…", and counted copy is pluralized
- The row opens the download dialog, so it is labelled so; the download's state ("Downloaded",
  "In queue") is its hint, not its label.
- The share dialog's expiry ("1 days") and a link's view count ("1 views") go through i18next's
  plural forms (`count`).
2026-09-27 14:50:04 +02:00
peter 17bb14fb18 fix(ui): a picker whose list failed says so, and takes no pick while one is being written
- Combobox gains `error` (said in the list, not "No matches") and a soft `disabled` (keeps the
  focus, announced, opens and changes nothing).
- UserPicker passes both: the share dialog's recipient picker is off while a share is pending, and
  the share and quota pickers say when the user list failed to load.
- ChannelPicker and the Plex playlist/collection pickers say a failed load too.
- The channel menu's Tags row says loading / failed instead of "no tags yet".
2026-09-27 14:47:46 +02:00
peter 70f98b352f fix(plex): a collection sibling is a full Plex card, and the player's info overlay plays it
- PlexInfo's collection strip gives each card the related strip's action set: the "…" menu (with
  Restart for a touch reader), the watched toggle and the playlist add.
- `onPlayItem` is required, so no variant draws a Play that does nothing; the player's info overlay
  plays the sibling in the running player (`go(id, _, fromZero)` for its Restart).
2026-09-27 14:44:38 +02:00
peter efa4aeeeda fix(plex): a card offers Restart by the menu's own "started" rule
The picture read "started" off the rounded progress %, so a resume point under 1 % showed Play
while the card's menu offered "from the start". Both use `plexStarted` now.
2026-09-27 14:38:27 +02:00
peter a7d41eb5f3 fix(plex): the player's settings panel is a tab list with named, touch-sized rows
- Its section switch is `ui/Tabs` (new `tone="media"` paint for the dark chrome) with a wired
  tabpanel; the panel opens with the focus on the selected tab.
- On/off rows are `CheckRow` (the whole row, 44 on touch), the "no background" checkbox too.
- Every slider and the four colour wells carry the name their row shows.
- The clock's date-style buttons reach the touch target.
2026-09-27 14:38:27 +02:00
peter 76eead976f fix(plex): the player's stage follows the tablet toolbar, and its bands stay out of a double-tap
- The stage is sized in dvh (80dvh × 1.25 windowed, 100dvh fullscreen); the viewport-units
  contract no longer exempts the player.
- A tap that reveals the bands keeps them pointer-events-none for the double-tap window, so the
  second tap of a skip at the bottom is not a seek.
- An arrow on the volume slider wakes the bands, and a keyboard focus in a band holds them
  (`holdOnFocus`, `:focus-visible` only, so a clicked button does not pin the bar).
- Next stays mounted (disabled) while the next episode's detail loads, keeping the focus.
2026-09-27 14:34:31 +02:00
peter 922d589b09 fix(ui): Tabs is an ARIA tab list with roving focus and a wired panel
The rail was a row of aria-pressed toggles: every tab a Tab stop, no arrows,
nothing tying a tab to its content. tablist/tab/aria-selected, one Tab stop,
arrows on either axis, Home/End, and useTabPanel for aria-controls/labelledby.
2026-09-27 14:22:37 +02:00
peter a975ab627a fix(plex): the player's ⋯ and tracks lists are real menus, and every close returns the focus
They were hand-built divs: no menu roles, no arrow keys, and Escape left the
focus on <body>. Both run on the popover engine now (media-tone rows), and the
tuning panel is a labelled dialog that takes the focus and hands it back to ⋯.
2026-09-27 14:22:37 +02:00
peter 9bedd96774 fix(nav): Back restores the focus after a click on the page's blank area
The page region is <main tabIndex=-1>, so a background click focused it and the
Back restore took that for the reader's choice. A resting place is marked and
one predicate (focusIsUnclaimed) decides "the focus is nowhere" everywhere.
2026-09-27 14:06:16 +02:00
peter 383c85231a fix(plex): a Restart does not replay on a reload or a Forward onto the player
fromStart rode in history.state._sub, so reloading the player entry restarted
the item from 0 again and the progress checkpoint overwrote its resume point.
A sub-view config can now say what the history entry keeps of a view.
2026-09-27 14:03:48 +02:00
peter de62035889 fix(playlists): an Undo puts a row back after its old neighbour, not at a stale index
A raw index recorded at removal drifted once anything changed before the Undo.
Both lists now read the list fresh and re-insert each row after the row that
preceded it (lib/reinsert). A failed watched toggle in a Plex playlist also
puts the row's state back: its local copy was never re-seeded.
2026-09-27 14:02:15 +02:00
peter b456232d00 fix(playlists): lock the playlist row before reading and writing item positions
Two adds read the same max(position) and a reorder racing an add collided with
it. Every add/insert/reorder takes the parent row FOR UPDATE; the YouTube list
breaks position ties by id and a Plex reorder renumbers rows it was not sent.
2026-09-27 13:58:23 +02:00
peter c9e95ecea6 fix(channels): a channel YouTube no longer returns is not asked for on every view
channels.list simply omits a terminated channel, so nothing was stamped and the
channel page spent a quota unit on it every time. The miss is stamped like a
fetch; the published_at sentinel that kept it stale is gone.
2026-09-27 13:58:23 +02:00
peter 7987bd8a37 fix(plex): a stray byte keeps a UTF-8 subtitle UTF-8; untagged and Serbian read as cp1250
One invalid byte sent a whole UTF-8 sidecar to a legacy code page, an untagged
file fell to Windows-1252 (ő/ű became õ/û) and Serbian was read as Cyrillic.
The cache key moves to sub3_ so a track the old rules mis-decoded is read again.
2026-09-27 13:58:23 +02:00
peter 3411265dc1 docs(design): tablet columns, glyph pager, match switch, missing picture, Copy 2026-09-27 07:12:51 +02:00
peter 098b8dbe43 feat(downloads): a bare link is titled once, a missing picture shows a glyph
A job with no name yet is titled by its short link, as the link itself;
the channel line no longer falls back to the ref and the link line does
not repeat it. No picture at all draws a film (or note) glyph instead of
an empty box.
2026-09-27 07:12:51 +02:00
peter f48f16830e feat(filters): the any/all switch sits on the panel above its picker
ui/MatchToggle replaces the Combobox popover footer (Feed tags, topics,
languages; Plex collections) and PlexSidebar's own toggle (genres,
actors): shown from two picks, one wording. A closed picker had hidden
that "all" was on.
2026-09-27 07:12:51 +02:00
peter 955c23d321 feat(tables): a glyph pager with first/last, and tablet-width columns step aside
The pager is four named arrows round the page box (first, previous,
next, last). A table hides a tabletHidden column below 64rem of its own
width, as narrowHidden does below 42rem, through one shared helper that
also hides the column's <col>. The Channels table drops Prio, About and
Length there: at 1024 the channel name had 60 px.
2026-09-27 07:12:51 +02:00
peter d3e1d8219c docs(design): Fjord stays a fifth scheme beside Midnight
The trial is over: the owner kept it. The trial wording goes from the
tokens, the scheme list, the lab and DESIGN; B2's own-hue rule still
binds any new scheme.
2026-09-27 06:46:52 +02:00
peter 4480839db0 merge: bug/channel-details-refresh into E28
# Conflicts:
#	frontend/src/components/ChannelPage.tsx
2026-09-27 06:42:37 +02:00
peter 8e25567ff2 merge: bug/hu-subtitle-encoding into E28 2026-09-27 06:42:08 +02:00
peter 8f65bd184e fix(i18n): a locale edit hot-swaps the texts instead of re-running main.tsx
The locale JSON's importer did not accept HMR, so the update climbed to
main.tsx, whose re-run called createRoot on the live container again; the two
roots tore each other's DOM down ('removeChild ... not a child' by the dozen),
each a fatal toast evicting an old notification. Dev only.
2026-09-27 06:35:40 +02:00
peter ae9c248987 fix(downloads): Retry draws the refresh glyph, not Play (E28 S4)
The first UI-review run (Downloads) found a failed row whose Retry wore the
Play glyph in the row and its menu, promising a video. DESIGN.md gains
'a glyph means one thing' and a log row.
2026-09-27 06:35:40 +02:00
peter bd6aaa1e43 feat(design): the Copy rule — names, verbs, dates and units (E28 S3g)
The owner's picks (all the recommended options) become DESIGN.md's Copy
chapter and a sweep of both languages (742 values, no key drift):

- One name per feature: the rail's label, which may shorten it (Alerts
  -> Notifications, Fiókok -> Felhasználók, Download Center -> Downloads,
  Channel manager -> Channels); one "sync from YouTube" verb.
- Four verbs, four meanings (Delete / Remove / Dismiss / Discard, and
  their Hungarian pairs), checked against what each action does.
- Sentence case, the app's places capitalised; "…" on a command that
  asks first (a popover or form title got its own key without it);
  one-sentence messages without a stop or a tick, "Couldn't …"; curly
  quotes.
- Dates only from lib/format.ts, in the UI language: formatDateTime
  names the month and stops at the minute (it printed "9/27/2026,
  8:30:14 AM"), formatDateTimeSec for the log, the audit and the
  recorder; ui/RelativeTime pairs "3d ago" with the exact minute on
  hover. local/date-format refuses toLocale*String and Intl date
  formatters elsewhere.
- Units from Intl, short (formatUnit): runtimes, totals, the Scheduler
  countdown, the stats range, the player's tap skip. "2h 13m" was
  English in both languages.
- src/i18n/copyRule.test.ts holds the machine-readable part (quotes,
  "...", ticks, bare unit letters).
2026-09-27 05:46:17 +02:00
peter 2c65edfffd fix(downloads): the failure link keeps the 24px floor on a mouse (E28 S3g)
The S3g measure found the row's failure link 20px tall (the meta line's
text height): DESIGN B1 puts nothing under 24. Coarse pointers already
grew it to 44.
2026-09-27 05:13:32 +02:00
peter 6c2c01e67b fix(design): the S3g conformance sweep — primitives at their rung (E28 S3g)
The probe re-run over the 15 pages at 1920x1080 found every button, icon
button, chip, field, tab, segment and menu row drawn by its primitive
except the S3h Plex picture, a div with the button role. It is a
Pressable now, and the button-primitive lint rule refuses an element
with role="button" outside the control primitives.

- SegmentedToggle defaults to the toolbar rung (28, text-xs): the old
  small default had put the pager's page sizes and the YouTube result
  count on 10px type beside 28px buttons. The Plex match toggle asks
  for the small rung explicitly.
- The language badge on the rail uses text-3xs (was an 8px one-off);
  the Plex playlist's watched mark and the Home hero's length use
  MEDIA_BADGE instead of hand-copied classes.
- A media picture shows the pointer whether it is a link or a button.
- DESIGN.md: a Conformance chapter (the four-step check before a UI
  change is done, with the S3g baseline), the segmented rung, two log
  rows. CLAUDE.md points at the check.
2026-09-27 05:11:34 +02:00
peter ef343dbec0 feat(design): side panels open as a drawer on touch and below 768 (E28 S3f3)
C2 as decided: on a coarse pointer, or a window below 768, the panel's
tab (shown at every width, 44 wide on touch) opens the panel as a
start-edge drawer over the content, with a scrim. It is ui/Modal's new
drawer-start placement, so Esc, Back, the scrim, the focus trap and the
scroll lock are the dialog's own. A tap never peeks; pinning is a button
in the drawer's header, offered only at 1024 and up; a pin saved on a
desktop is left alone below that. A mouse on a wide window keeps
hover-to-peek and click-to-pin.

DESIGN: C2 as built, rows answer to their own width, phone-only layout
deferred to its own epic (principle 2).
2026-09-27 04:38:42 +02:00
peter a58a398427 feat(design): the three 375 rows (E28 S3f3)
- Download row: answers to its own width (container query); below 40rem
  it stacks: picture beside a two-line title, the meta line across the
  row (one line, never wraps: zoomed on the iPad its pieces broke apart),
  then the source link and the actions on one wrapping line. The paste
  field takes a line of its own when the buttons would squeeze it.
- YT playlist row: below 42rem the length rides on the picture and the
  title runs two lines over the channel.
- Channels tag row: the suggestions take a line of their own below
  40rem (the chip lane was 0 px and the row's menu off the screen).
- Pager: on a phone the arrows drop their words (names kept), and the
  size label stays one line.
- Lint: a class split where the interpolation is the variant itself
  (${NARROW}:w-24) is refused too; it had shipped with no CSS.
2026-09-27 04:15:08 +02:00
peter d510c8a534 fix(plex): the show and season restart names its object and starts at 0 (E28 S3f2)
The header button said only "Play from start" and resumed the first
episode at its saved point. It now reads "Season from the start · S6 · E1"
or "Show from the start · S1 · E1" and asks the player for 0.
2026-09-27 03:30:04 +02:00
peter dc5206f9d3 docs(design): C3 + C5 as built, and its log row (E28 S3f2) 2026-09-27 02:12:54 +02:00
peter 89e65ca331 feat(design): players on touch and the C5 control set, restart on the card (E28 S3f2)
Plex player: a finger's tap on the picture only shows or hides the controls,
the hide timer starts with playback, a double-tap on a side third skips 10 s,
the seek bar is a 2.75rem touch-none hit area with its head shown on touch.
The row is play/pause, Back by position, Next only with a next item, mute,
time, tracks, a fixed ⋯ (settings, media info, stats, shortcuts, download)
and fullscreen; Stop, Restart and the ±10 s buttons are gone.

A started card shows the video card's Continue + Restart pair (one shared
MediaResumeButtons, beside the picture's button), and its menu lists the
restart; the player opens that item at 0 (fromStart). YouTube modal: the
description opens from an ⓘ, the queue arrows sit inside the screen.
2026-09-27 02:12:54 +02:00
peter 17916b411a docs(design): C4 as built, and its log row (E28 S3f1) 2026-09-27 01:33:37 +02:00
peter 3ef2c38eb4 feat(design): dialogs open by purpose and keep their commit row pinned (E28 S3f1)
Modal takes a required initialFocus (field / close / action), a field that
loads late takes the focus while it still rests on the close button, and
ModalFooter portals a dialog's commit row under the scrolling body from any
depth. The header and footer fades follow the body's scroll timeline, and the
viewport's interactive-widget=resizes-content keeps the footer above an
on-screen keyboard.
2026-09-27 01:33:37 +02:00
peter 4c63d6660d docs(design): the media card chapter and its log rows (E28 S3h) 2026-09-27 00:54:28 +02:00
peter a453b77f04 feat(design): one media card, and Home's hero and shelves as cards (E28 S3h)
- ui/MediaCard + mediaCardStyles: one frame for every video, Plex title and
  Home tile; the meta line pinned to the bottom edge, the length a badge on
  the picture, the media Play only where the click plays, the hover row at
  the picture's bottom-right (the owner's pick C).
- Shelf density: on any strip a one-line title over one meta line; mixed
  Home shelves are landscape; rails turn content-visibility off (a card's
  reserved height had become the strip's). Home slot 232px, so the Play
  and the hover row no longer touch; a landscape shelf takes the small Play.
- Home hero is a card (hover row, the whole menu, a touch "…"), three
  quarters wide and centred; channel / playlist tiles get Play, open and
  "…"; a show tile acts on its next episode, ⓘ opens the show.
- Plex preview: a ~0.9 s mouse rest opens a passive panel with the whole
  title, facts and synopsis (popover `pointerOnly`); the synopsis and title
  tooltips are gone.
- Compact list row B: the title leads, "…" at the end, toggles over the
  date on hover.
- Media-info layout B (collections, crew, cast beside the facts sidebar;
  an empty column is not drawn); filtering values are chips there and on
  the show page; "Cast" over the cast-only strips.
- Browse tiles hover like a card; prose capped by the `measure` utility.
2026-09-27 00:54:19 +02:00
peter 8e1004fe6f fix(channel): load the banner without a referrer
Google's image CDN refuses a banner request that carries a Referer from our origin - every
time, not only under load (measured: 6/6 refused with it, 8/8 served without). The banner
<img> now sends none, as ui/Avatar does for the same CDN; the comments that blamed a
transient burst throttle are corrected.
2026-09-26 21:45:46 +02:00
peter 663f30feeb fix(sync): refresh a channel's details once they are a week old
Channel metadata (banner, avatar, description, counts) was fetched only the first time a
channel was seen: the subscription import asked only for channels with no details, and the
channel page only for ones missing a join date. 394 of 397 subscribed channels had July
metadata. Both now also refresh details older than DETAILS_MAX_AGE (7 days), through one
shared staleness rule; channels.list costs 1 unit per 50 channels.
2026-09-26 21:45:46 +02:00
peter 6653d81a80 fix(channel): no broken-image glyph while the banner retries (E28)
The banner is retried on error; each failed attempt showed the browser's
broken-image glyph for as long as the retry waited. The <img> now stays
invisible until its source has actually loaded.
2026-09-26 20:55:34 +02:00
peter 21df7c8348 docs(design): the background menu as built, and the log row (E28 S3e4c) 2026-09-26 20:48:53 +02:00
peter 97381e65d0 feat(design): a page's background menu on the five list pages (E28 S3e4c)
A right-click on the EMPTY area of a page opens a menu of that page's own
toolbar: the feed (sort, reshuffle, view, watch state, content, source, clear
filters), the Plex library grid (sort, watch state, clear), the channel manager
(layout, the cards' sort, sync status, edit tags, clear the tag filter, read
subscriptions), a playlist (play all, group by, reset view, save order,
undo/redo, the list's own menu) and the notification center (mark all read).
No destructive row and no backfill.

PageShell listens on the page area; lib/backgroundTarget decides empty (not
text, a control, a link, media, or a row/card with its own menu). Shift+right-
click, a touch long-press and a right-click on selected words keep the
browser's menu. ui/PageMenu registers while mounted, so a loading or error
state keeps the browser's menu too. The rows mirror each control through
ui/pageMenuItems (sortSubmenu via pickSort, choiceSubmenu, multiSubmenu).
pageMenuScope.contract.test.ts pins the five pages.
2026-09-26 20:48:53 +02:00
peter f8ced6d819 docs(design): the flush-row ring, the fade at rest, the narrow table, and their log rows (E28) 2026-09-26 20:01:04 +02:00
peter f254588409 fix(playlists): hide '#' and Channel by the table's width, not the window's (E28)
Beside the playlist rail an 800 px window left the table 432 px: the fixed
columns took all of it and the title column was 0 px wide, its thumbnail
drawn over the channel. The table is an @container now and the columns hide
under 42rem of its own width (narrowHidden, was phoneHidden).
2026-09-26 20:01:04 +02:00
peter 3e7c79fa69 fix(design): the keyboard ring of a flush row stays visible (E28)
A list item's ring sat 2 px outside it. On a table row it ran under the
sticky head and past the table's sides; on a Plex playlist show header, flush
in its clipping card, it was cut away and the focus was invisible. A table row
(by its tag), a log line and the Plex show header and episode rows now ring
inside themselves, the show header with its card's corners.

The sticky header's fade-below was drawn at rest, over the first row's top
16 px, hiding the ring's top edge: it now fades in with the first 1rem of
scroll (a scroll-driven animation; no fade where unsupported).
2026-09-26 20:01:04 +02:00
peter 47c12f9405 docs(design): right-click on a selection, and the log row (E28 S3e4b) 2026-09-26 19:03:34 +02:00
peter bd0914b7ed fix(design): a right-click on selected words opens the browser's own menu (E28 S3e4b)
The row menu took every right-click, so selected text could only be copied
with Ctrl+C. On the selection (its laid-out line boxes) the event is left to
the browser; beside it the object's menu opens as before.
2026-09-26 19:03:34 +02:00
peter 079cee4e9c docs(design): titles in click surfaces stay selectable, and the log row (E28 S3e4b) 2026-09-26 18:46:24 +02:00
peter edf2f22ef9 feat(design): titles in click surfaces stay selectable (E28 S3e4b)
A button whose text is a title or a name is now `selectable` (FocusSafeButton,
under Pressable and Button): Chrome selects inside a button once it is
user-select: text (measured), and the click that ends a selection, or a press
dragged past the slop, opens nothing. 23 surfaces carry it (cards, rows, people,
channels, playlists, users, views, messages, log lines, description links); the
Plex caption div gets the same guard. A contract test pins every plain Pressable
with its reason, so a new one has to choose.
2026-09-26 18:46:24 +02:00
peter e1ef56cdbc docs(design): the non-drag reorder forms, and the log row (E28 S3e4a) 2026-09-26 18:14:28 +02:00
peter 4d0abc61d5 feat(design): reorder without a drag everywhere a grip drags (WCAG 2.5.7, E28 S3e4a)
One rule, two forms: useOrderItems (Top / Up / Down / Bottom rows of a row
menu) and OrderButtons (four buttons with the place, or a compact Up / Down
pair). A move that would go nowhere is shown off.

- YouTube playlist rows: the four moves now come from useOrderItems.
- Plex playlist rows: a block among the blocks, an episode within its show,
  off while a write is in flight; drag and menu share moveBlock/moveEpisode.
- Saved views editor: the selected view's four buttons (owner: A), and the
  grip's keyboard pick-up (its instructions promised one it did not have).
- Side panel groups in edit mode: an Up / Down pair (owner: A); a group the
  stored order does not hold yet moves too; the name wraps there.
- The grip's touch-action: none is now test-held.
2026-09-26 18:14:28 +02:00
peter 7c2b22bafa docs(design): the channel menu's Tags and Priority, MenuCheck, and the log row (E28 S3e3b) 2026-09-26 17:34:47 +02:00
peter 5e5c8a42a9 feat(design): the channel menu's Tags and Priority submenus (C1, E28 S3e3b)
Tags A-Z as in-place checks (the menu stays open for the next); Priority
as the fixed steps 10 5 3 2 1 0, an off-scale current value ticked in its
place. A pick re-sorts the list at once, moving only that row
(placeByPriority, the server's order), and the table / card pager and
the focus follow it (DataTable and useCardPager take a reveal seq,
listNav.focusNavItem lands on the row's own "…", a virtualized card
revealed first). ActionMenu rows take check: many | one. The menu reads
the row fresh from the cache and keys its target by id.
2026-09-26 17:34:47 +02:00
peter 2b3094ffc5 refactor(design): one drawn check for every checkable row that cannot be an input (E28 S3e3b)
ui/MenuCheck: a box filled with the accent for a set (a minus for a group
only partly in), a bare tick for one choice, in the row's leading slot.
Replaces seven hand-drawn marks: three box sizes, a tinted box, a cross
for "on" in the table filter, a trailing tick in the language menu.
2026-09-26 17:34:46 +02:00
peter 9c91eb5d22 feat(design): a library row keeps Share (the link copy) in sight, ahead of save (E28 S3e3)
The owner's call: sharing a download's link is the library's main errand, more
important than saving the file, so it does not hide in the row's "…". Manage
sharing stays in the menu. The "…" tests press as a mouse, so a stray long-press
timer no longer fires after teardown.
2026-09-26 07:21:21 +02:00
peter 2df60fbf07 docs(design): how the download, notification and Scheduler action lists are built, and the log row (E28 S3e3) 2026-09-26 07:10:02 +02:00
peter a2d17d693c feat(design): the download, notification and Scheduler action lists (C1) — the rows' "…", the band's and the strip's "…" (E28 S3e3)
A download row keeps play (pause / resume / retry in the queue) and the save link; the editors,
sharing, re-download, the copies and cancel / delete / remove moved into its "…"
(useDownloadActions). A link inside the row keeps the browser's own right-click menu. A
notification row keeps its link and its unread mark and shows its dismiss on hover; an activity
notice's Find in feed and Undo are in its "…". The inbox band keeps Mark all read and puts Clear
everything, now behind a confirm, in its "…". The Scheduler strip keeps Run all and Pause and puts
Purge discovery in its "…". Polled lists key their menus by id (keyOf). ui/menuGroups draws a
rule only between two non-empty groups. Find in feed also drops the tag, search and age filters
that could hide the video it looks for.
2026-09-26 07:10:02 +02:00
peter c4917619ee fix(design): a row's "…" closes its own menu when its row object is rebuilt (E28 S3e2)
The "…" told its own menu from another row's by payload identity. The Plex playlist page builds
each row's menu target afresh on every render, so a second press on the same "…" reopened the
menu instead of closing it. useContextMenu takes an optional `keyOf`, and the page keys its
targets by the row key.
2026-09-26 07:10:02 +02:00
peter 760bdae278 docs(design): how a channel's and a Plex playlist's action lists are built, and the log row (E28 S3e2) 2026-09-26 05:56:08 +02:00
peter 1b946113c6 feat(design): a channel's and a Plex playlist's one action list (C1) — the row's "…", Delete + undo, an action toast's 8 s floor (E28 S3e2)
- Channels: the row's full-history, hide and unsubscribe buttons become its "…" at the row end
  (useChannelActions: open, pin, YouTube, copy name / link, full history or the admin reset,
  hide with an undo toast, unsubscribe behind its danger confirm); the card layout docks the "…"
  beside the name (ChannelLayoutGrid moreKey). The admin reset's confirm now names the reset.
- Plex playlist page: a title, a show group and a season each open the page's one row menu (a
  title's is its usePlexCardActions list plus the removal); no X at rest; removals take an undo
  toast whose Undo puts every row back at its own index in one request (bulk add `positions`);
  j/k walk the visible rows, Delete removes the walked one; rename and delete in the header's "…".
- notify: a toast with an action lives at least 8 s (the owner's call), whatever the toast setting.
2026-09-26 05:56:07 +02:00
peter b0e51c6f89 docs(design): how a playlist's action list is built, and the log row (E28 S3e2) 2026-09-26 04:05:39 +02:00
peter 7455fff401 feat(design): a playlist's one action list (C1) — the row's "…", Delete + undo, the header's "…" (E28 S3e2)
- Playlist rows: the X column became the row's "…" (play, pin, copy title/link, move, order,
  remove). Remove runs at once, with no confirm; its toast's Undo puts the row back at its index.
  The Delete key removes a keyboard-walked row, and focus moves on to the next.
- POST /api/playlists/{id}/items takes an optional `position`, so an undo is one request that
  renumbers the list around the row.
- A new `listDelete` key scope. Only a list with `onDelete` registers it, so the help sheet does not
  offer Delete where nothing handles it.
- A playlist has one list, `usePlaylistActions.items`: rename, pin, YouTube open/push/revert,
  delete. The rail row's "…" and the page header's "…" both open it. Push and revert moved into
  the hook; the header keeps Play all.
- Phone-width playlist table: "#" and Channel are hidden below sm (`Column.phoneHidden`). Their
  fixed widths had left the title no width.
2026-09-26 04:05:39 +02:00
peter aaf1843b09 docs(design): how a Plex title's action list is built, and the log row (E28 S3e2) 2026-09-26 01:55:29 +02:00
peter a4737b92e4 feat(design): a Plex title's one action list (C1) — the poster's "…", details and the pin in its menu (E28 S3e2)
Every Plex poster list (library, Discover rails, person, show, season, collection, Home's rails)
now opens one menu per list from the right-click, the long-press, Shift+F10 and the card's "…":
play/open, watched, add to playlist, media info, copy title, the pin (usePlexCardActions +
PlexMenu, replacing the pin-only PlexPinMenu). The card keeps its state at rest and shows
watched / add to playlist / "…" on its picture on hover (ui/reveal.ts, shared with the video
card); on touch the toggles are gone and a 44px "…" sits beside the title, while the watched pill
stays. The hover ⓘ left the card; an episode's add button moved from its caption to the hover
row. Seasons get their own short menu (open, watched, add, copy), labelled "Open season" (the
card's overlay said "Open show"). Related shows are ordinary poster cards now. A collection's
admin "Remove" X moved into its menu, still behind the confirmation. The collection grid is two
columns on a phone. The unused .reveal-idle rule is gone.
2026-09-26 01:51:53 +02:00
peter 75ae810866 feat(design): a video's one action list (C1) — the card's "…", the menu holds the rest (E28 S3e1)
- useContextMenu gains buttonProps (a row's visible "…" opens the same menu under the button; a
  second press closes it) and anchor (the element the menu came from, for a panel a row opens).
- ui/MoreButton; ActionMenu rows can run inGesture (a clipboard write inside the click, Safari).
- useVideoActions: one builder for a video's menu (play / restart, channel, watched, save, add to
  playlist, download, details, copy title / link, hide, reset, pin), composed by the feed and by
  Home's rails; the playlists and details panels open anchored beside the card (VideoFollowUp).
- The card keeps watched / save / add to playlist and the "…": on the picture on hover (the
  duration steps aside), in the rows' action column; on touch only a 44px "…" beside the title.
  Download, hide, reset and the hover info card leave the card.
- The title is selectable: a click ending a selection neither plays nor follows the link.
- DESIGN.md: how C1 is built + the log row.
2026-09-26 01:23:44 +02:00
peter 9d7526ee57 fix(feed): a reset clears the card's resume position at once, not only its status
The optimistic layer overrode only the status, so after "Reset — clear watch progress/status" the
card kept its progress bar and its Continue button until the next refetch. Found in the E28 S3e1
UAT.
2026-09-26 01:23:44 +02:00
peter fa0b0edd63 docs(design): how B4, B9 and the viewport rule are built, and the log rows (E28 S3d4) 2026-09-26 00:39:10 +02:00
peter bfc6d199c0 fix(a11y): a video card's thumbnail link is named by the title, not the duration badge (E28 S3d4)
Its only text was the duration, so every card's first Tab stop was announced as "44:10".
2026-09-26 00:39:10 +02:00
peter 4d6e59b80a feat(design): the public watch page on the app's surfaces (B6) — the scheme backdrop, the title and actions on a glass panel, a glass Download (E28 S3d4) 2026-09-26 00:39:10 +02:00
peter 4646aae320 fix(design): the table header takes the B9 fade utility, and every size is dvh, not vh (E28 S3d4)
The table header carried an arbitrary-value copy of fade-below; a contract refuses a hand gradient of a surface colour. 100vh is the viewport with a tablet browser's toolbar retracted, so h-screen / min-h-screen / static vh hid the last rows under it; a contract holds dvh (the Plex player stage changes with S3f, where it is played on the tablet).
2026-09-26 00:39:10 +02:00
peter eddeca82d3 feat(design): the page band (B4) — one h1 per page, section headings, skip to content, filters fold on a short window (E28 S3d4)
- PageShell names the page with a visually hidden h1; a view with its own title renders it as
  PageHeading and the hidden one steps aside (contract: no hand <h1> inside the shell).
- Section captions are SectionLabel headings (h2, h3 in a dialog), ui/form Section included.
- "Skip to content" is the first Tab stop and focuses the scroller's <main>.
- Below 600px of height the band's filter row folds into "Filters · n" (Feed, Plex, server log),
  the same row shown in place.
- The band holds controls only: Downloads and Notifications lose their title cards, the Config and
  channel-discovery intros scroll with the content, the Stats tabs and the audit pager join the band,
  and the server log's toolbar sticks with the fade under it.
2026-09-26 00:39:01 +02:00
peter 69edeecc45 fix(a11y): a labelled rail row says its label and the exact unread count, not the capped badge first (E28)
With the labels on, the row's name was its text, badge first: 15 unread was heard as "9+ Alerts". The
name is now the visible label plus the count, as the icon-only row's is, and the drawn badge is hidden.
2026-09-25 23:59:20 +02:00
peter 6088e476a7 fix(design): the command palette's active row takes the menus' roving highlight, not an invisible bg-card; the Plex typeahead's hand copy of it goes (E28)
The palette row was ~1.07:1 on its glass (B3 names bg-card hovers a violation). ROVING_HIGHLIGHT is
exported from AnchoredPopover for the listboxes that are not PopoverItems; a contract holds that no file
paints the fill itself and every hand-built option row takes the recipe.
2026-09-25 23:51:39 +02:00
peter ce171fa954 fix(feed): a custom date bound applies once its year is a plausible upload year, not per typed digit (E28)
A native date field reports a complete date on every keystroke of its year (0002, 0020, 0202, 2026),
so one typed year refetched the feed four times. The field holds a draft while focused; leaving it
empty clears the bound, leaving it half-typed restores it.
2026-09-25 23:43:26 +02:00
peter 113da76c36 fix(design): a user row answers as a whole card — the hover layer under its badges and buttons, a click anywhere opens the account (E28)
The layer was the name button's own and ended at the row's first badge or button. The card now paints
the B3 layer under its content (isolate + a new z-under level), the name button's hit layer spans the
card, and the actions sit above it.
2026-09-25 23:43:26 +02:00
peter 4ef11901db fix(design): the nav rail's footer rows (language, About, Sign out) share the rail row and glyph (E28) 2026-09-25 17:21:01 +02:00
peter 29abf609f3 fix(design): the lab draws with the primitives' own classes, not a height-ladder copy; DESIGN no longer claims accent-color tints the colour well (E28) 2026-09-25 17:15:03 +02:00
peter e13d93a3ed fix(design): a toggle is pressed, never a bare aria-pressed; static labels are flat Badges with a tone, edged label spans lint-refused (E28) 2026-09-25 17:04:08 +02:00
peter b9850b1710 feat(release-notes): terse bilingual notes as data files — new/fixed only, the dialog lists the latest ten (E28) 2026-09-25 15:49:16 +02:00
peter 905a9f2856 feat(design): Fjord, a trial fifth scheme — Midnight's navy with a steel-blue accent and steel rules (E28) 2026-09-25 15:39:49 +02:00
peter 1d8b83c37c fix(plex): a sidecar subtitle in a legacy code page rendered its accents as U+FFFD
A Hungarian .srt beside the video (Windows-1250) was decoded as UTF-8 with
errors=replace: one episode served 1391 U+FFFD and no accented letter.
decode_subtitle now lets a BOM decide, keeps valid UTF-8, and otherwise
reads the legacy code page of the stream's language (cp1250 for Central
European, cp1251 for Cyrillic, else cp1252) — on the ffmpeg path too.

The cache has no eviction, so its key is versioned (sub2_): a track cached
broken is read again once instead of being served for good.
2026-09-25 15:20:31 +02:00
peter 62dbf35ea3 fix(player): the Plex bars hold while hovered, volume is the native slider, tooltips no longer re-layer the row (E28)
- F22: the control bar faded out from under a mouse parked on it; the hide timer
  now waits while the pointer is on either band and re-arms on leave (test seen
  RED by disabling the hold).
- The hand-drawn volume bar broke DESIGN.md A6 (native sliders): it had no hover,
  focus, keyboard or screen-reader state. It is the Slider primitive now; a
  pointer drag blurs it so Space and the arrows keep driving the player.
- The control tooltips are composited from mount (will-change: opacity): a fade
  that promoted one on hover re-layered its neighbours under the player's
  scale(1.25), and the whole row shimmered.
2026-09-25 14:48:45 +02:00
peter e9ccd9bc98 fix(player): the YouTube player's full window filled only its card — a blurred ancestor trapped the fixed stage (E28)
E28 S3d1 moved the modal's card from `.glass-card` (no blur) to the `.glass` panel role, whose
`backdrop-filter` makes the card the containing block of the fixed "full window" stage: F showed a
894×161 strip inside the card with a scrolling, wrapped control bar. While the stage is maximised the
card now drops its blur (`html .glass:has(> .player-stage--max)`, outranking the dark theme's rule);
measured live: the stage 1268×861 = the viewport, the blur back on exit.
2026-09-25 14:20:23 +02:00
peter cc8e0648db fix(design): the button and chip hover colours were never generated — an act: variant instead of a runtime prefix (E28)
buttonStyles/chipStyles glued the hover guard on at runtime (`${ACT}hover:text-fg`). Tailwind emits CSS
only for a class it finds whole in the source, so all 22 guarded hovers shipped with no rule: no text or
edge colour on hover for ghost/outline/danger buttons, no white layer on the player's control row, no
underline on a link button, no accent hover on token/suggest/ghost chips. The stylesheet had 0 rules
for `not-aria-disabled`.

- `@custom-variant act` (index.css): the same guard as the state layers; `:disabled` never matches an
  <a>, so one variant serves buttons and links and the per-element variant maps go.
- ui/tailwindScan.contract.test.ts: every class the primitives can emit stands whole in their source.
- a lint rule refuses a variant right after an interpolation; the `enabled:hover:` rule now points at
  `act:hover:`.
2026-09-25 14:13:51 +02:00
peter ff80cc96fd fix(design): rating and live colour tokens, the player's menus over its notices, the info overlay on a light theme (E28)
- F16: --rating (theme-scoped gold, 4.5:1 on the page and its chip), --rating-media, --live/--live-fg,
  --media-chrome replace 14 palette literals; a lint rule refuses palette classes; the theme contract
  measures them.
- The Plex player's notices drew over its open settings/tracks popovers: notices z-base, popovers z-menu.
- The player's info overlay rendered theme tokens on its always-dark panel: on a light theme the score
  (2.6:1), collection years (3.2:1) and cast initials (1.05:1) were unreadable; onDark variants now.
- The lint-rule contract tests share one ESLint with a one-off warm-up (a cold first lint took 8 s,
  past vitest's 5 s).
2026-09-25 13:55:26 +02:00
peter 295c1db5f1 fix(design): claim audit part 1b — whole-row switches, the field-family edge, state-layer hovers, icon links, reason-disabled controls, 24/44 targets (E28)
- F12 (owner: A): SwitchRow, the whole settings row toggles; a Config boolean toggles from its title
  and hint (a label pointed at the switch).
- F14 (owner: B): the form dropdown trigger wears the field frame (see-through, the >= 3:1 edge);
  labelled pills keep the faint edge and the accent-edge hover. DESIGN says so, with log rows.
- F6: ~15 bg-card / bg-border / opacity hovers onto the B3 state layer; a lint rule (HOVER_SWAP)
  refuses the swap, contract-tested.
- F10: bare 14px icon links onto IconLink / LinkButton; a local lint rule refuses a glyph-only <a>.
- F13: reason-disabled controls are aria-disabled (Users, Channels backfill, Discovery, Scheduler
  runs, Config tests); FocusSafeButton now swallows the click of a caller's aria-disabled; the
  Config tests say why while dirty (testDirty, HU/EN).
- F5/F11: every control measured live at 24 (mouse) and 44 (coarse) across every page; touch-target
  is a utility that wins over a caller's min-h-6; link variants carry it too; SegmentedToggle,
  Tabs, NavRail, menu rows, PriorityStepper, sliders, CheckRow, InnerInput, card links and the rest.
- F9: DismissibleChip's label reaches the chip's 24; the Combobox chips are DismissibleChips.
- F15: the "+N" / "More..." open state worded as it renders.
2026-09-25 03:58:17 +02:00
peter 0521149cf1 fix(design): claim audit part 1 — a closing key no longer re-presses its trigger, the Switch box and name, named z-levels (E28)
- In-place editors (Playlists heading + rail rename, Plex collection + playlist rename, PriorityStepper,
  Scheduler interval): a real Enter closed the editor, returned the focus to the trigger, and the same
  keystroke's activation pressed that trigger again - re-opening the editor, or in the rail selecting
  the renamed playlist. useReturnFocus.arm(e) now cancels the key's default (type-enforced); the rail and
  Scheduler editors do the same. The earlier tests dispatched a synthetic keydown, which has no default.
- Plex playlist rename: an unchanged or empty title closes with no write, as the collection rename does.
- Switch: a 36x24 box around the track (the 24 floor), 44 on a coarse pointer; `label` is required, and
  the Plex two-way-sync switch had no name.
- z-index: numeric z-10/z-20 and a literal 40 moved onto the named layer scale; a lint rule + contract.
- The /watch file card's Download is the filled primary: the page's one action.
- Stale comments: the 1024x768 floor, the retired glass tuner.
2026-09-25 02:25:54 +02:00
peter b4a6569d0d fix(design): the saved-view chip on one line, one Escape per layer, the /watch action row (E28 S3d)
- SelectTrigger lays its toolbar value out as a row; the saved-view chip passes its glyph and
  modified dot through the icon/trailing slots (an <svg> is display:block and broke the chip in two).
- One Escape in a dialog's open list closed the list AND the dialog: the list's close flushes
  synchronously and pops its layer before the dialog's window listener runs. takeEscape() is the one
  close-on-Escape test (topmost AND not yet taken); Modal, the Welcome lightbox and useLayer use it,
  and keymap.dispatch ignores a taken Escape.
- /watch: the Download sits on the toolbar rung, level with the Repeat chip.
2026-09-25 01:40:45 +02:00
peter 1833f594cb fix(design): an audit of the S3d claims — the float shadow, filter hints, one action size per row (E28 S3d)
- A glass role's shadow is one token (shadow-float); the header search's drop-shadow utility had
  replaced its top highlight, and the debug pill's shadow-lg did the same. The surface-role lint
  rule now refuses a shadow size beside a role.
- Plex: every value that filters carries a hint (genre, certificate, studio had none), and the facts
  panel's year and certificate filter like the header's. filterKeyword -> filterValue.
- Downloads: the row resized its buttons with a [&_button] override that the save link escaped
  (28px beside 32px); the size is the primitive's md rung now.
2026-09-25 01:16:20 +02:00
peter 1615838f33 fix(design): the rail's active edge bar was clipped by the scrolling list (E28 S3d3)
An overflow-y container clips on both axes; the bar sits in the rail's padding, outside the row.
NavRailList spans that padding (-mx-1 px-1) so the bar stays inside the list's box.
2026-09-25 00:59:23 +02:00
peter 7fa8f4ebde feat(design): the NavRail primitive with optional short labels, the flat surfaces onto roles (E28 S3d3, B7/B5) 2026-09-25 00:47:46 +02:00
peter 6354454cc9 feat(design): skeleton first loads by shape on the shared reveal clock (E28 S3d2, B8) 2026-09-25 00:37:45 +02:00
peter c47c193ad1 feat(design): glass surfaces by role, the static Badge, LinkButton, user pickers on the Combobox (E28 S3d1) 2026-09-25 00:34:35 +02:00
peter db10bfbbca feat(design): the header search is a 40 glass pill; a wrapper field dims for its input only (E28 S3c) 2026-09-25 00:05:31 +02:00
peter dac9d89f91 feat(design): the field family, the dropdown trigger, native controls, the no-raw-input lint rule (E28 S3c)
- ui/Input, Textarea, InnerInput + fieldClass (A4): the >= 3:1 edge, a 2px accent
  focus edge, the danger edge for invalid, md/sm/xs/heading rungs, 1rem and 44 on touch
- ui/SelectTrigger (A5): SortControl, FilterSelect, the view switcher and the date
  filter on one trigger, a pill in a toolbar, r8 in a form
- ui/NativeControls (A6): Checkbox, CheckRow, Slider, ColorInput
- every raw input swept (~100 sites); inputCls retired; local/input-primitive
- useReturnFocus: an in-place editor hands the focus back to its trigger on
  Enter/Escape (the playlist and Plex title renames, the priority number);
  the Plex playlist rename gains Escape and an accessible name
- back to top: a round 48 FAB (owner decision); DESIGN.md A2 and the log
2026-09-24 23:48:22 +02:00
peter 81936e9f56 feat(design): every button on its family, the chip primitive, the lint rule (E28 S3b2)
The second half of the button sweep: the remaining hand-rolled buttons in
feature components, the design lab and the components/ui composites now
stand on Button, IconButton, Pressable, Chip or DragHandle.

- Chip + DismissibleChip (filter / token / tag / suggest / ghost, 24px
  floor, 44 on touch) replace five chip geometries; FilterChipGroup
  renders Chips; overflow lanes measure a mirror painted by chipClass.
- The Plex control row is IconButton's player rung (36, 20px glyph,
  round, overlay; 44 on touch); the YT modal bar is on the sm rung with
  pressed cycle toggles and names that survive a folded label.
- New variants: IconButton ghost-accent, danger-soft; Button warn on a
  new state-warn utility. The .btn-{tone} recipes and btnCls are gone.
- Fixed on the way: membership rows (collection editor, Plex playlist
  add) announce checked/mixed; a one-letter tag chip was 21px wide;
  descriptionLinks moved out of lib/ (it renders components).
- local/button-primitive: a raw <button>/<FocusSafeButton> outside the
  control-primitive files is a lint error (contract-tested, seen red).
2026-09-24 23:03:23 +02:00
peter 86f467bd65 feat(design): the button families on the decided ladder, first half of the sweep (E28 S3b1)
Button is rebuilt to A1/B1/B3: explicit heights (xs 24 / sm 28 / md 32, lg 40 for a hero CTA), r8 (a
pill on media), state layers instead of opacity and bg-card hovers, a `pressed` toggle, `overlay`,
`media` and `link-muted` variants. The icon-only sizes move to the new IconButton (28 / 32, 48 only for
the Play on a picture; a required label). Pressable (a card, row or tile that is the control) and
DragHandle join them. The state-* utilities now stand down on a disabled, busy or pressed control, held
by theme.contract.test.ts.

Swept onto them: the Button icon-size callers and 16 files (Plex playlist, Plex detail, the YT player
modal chrome, Welcome, Plex and video cards with AddToPlaylist and DownloadButton, the video editor,
Feed, personal views, Scheduler, Users, the nav rail, Plex detail parts, Settings): 268 raw buttons are
153. Fixed on the way: FocusSafeButton overwrote a caller's aria-disabled (the "unavailable, here is
why" state; adminInvites caught it), a season toggle and a Plex settings switch had no name/state, and
the video card's overlay follows the C5 card (Resume pill, From the start pill, a round Play).
2026-09-24 05:25:55 +02:00
peter 7d480e2ad0 feat(design): Matrix toned down to moss, text on an accent fill held to --accent-fg
The lime accent was the loudest of the four (OKLCH L 0.85, C 0.21) and read as neon; the owner chose the
moss variant at Midnight's lightness with near-neutral greys, and the Matrix backdrops follow it. Fixed
white/black parts on a solid accent fill (two switch knobs, three play buttons, the Up next pill) now use
--accent-fg, and theme.contract.test.ts refuses a fixed white or black next to a solid bg-accent.
2026-09-24 04:49:07 +02:00
peter ad136c3fe8 feat(design): the scheme set, the colour contract and the token utilities (E28 S3a)
Four schemes (Midnight; Forest cyan, Slate fuchsia, Matrix lime), YouTube
and Starship retired with no data migration: normalizeTheme maps a stored
retired scheme to Midnight. A vitest contract measures every scheme x mode
(text 4.5:1, control edge 3:1, status text, the accent's 30-degree hue gap);
it caught light-mode danger at 4.43:1, now red-700. The state layers, the
strong edge, the touch target and the fades are index.css tokens and
utilities the lab now reads. Backdrops recoloured to the new accents, with
a grey overlay grain that actually breaks the banding (longest flat run
15-26px -> 4-8px). The glass tuner is retired.
2026-09-24 04:33:13 +02:00
peter 9c4a999e54 feat(design): C5 recorded, every E28 decision taken
The owner's lean Plex control set with both refinements (Back by position,
a touch double-tap for the 10 s skip) goes into DESIGN.md's Players
chapter and the log. The lab is now the reference only.
2026-09-24 04:12:19 +02:00
peter 3f5634a44f feat(design): C5 redrawn around the owner's reworked Plex controls
Stop, Restart and the 10 s skips leave the row, Back restarts or steps to
the previous episode, Next shows only when there is a next item, and the
card carries Resume + From the start. The lab asks back two points: how
Back tells restart from previous, and a touch path for the 10 s skip.
2026-09-24 04:05:09 +02:00
peter f4bc60d659 feat(design): batch C recorded, the player row open as C5 (E28 S2d)
C1-C4 as proposed: the action classification, overlay-drawer side panels,
touch-first players, dialog focus by purpose with a pinned footer. The
players were then played live at tablet size, which found the Plex
control row running off-screen below 1024; how it fits is open as C5.
CLAUDE.md now points at DESIGN.md.
2026-09-24 03:27:26 +02:00
peter 7055548260 feat(design): batch B recorded, batch C in the design lab (E28 S2c)
DESIGN.md: B1-B9 decided (compact density, the four-scheme set with the
accent-vs-status rule, state layers, the sticky band, surface per role, the
watch page, the slim NavRail with optional labels, loading by shape, the
fade edges) with their decision-log rows. The lab keeps the chosen A and B
renderings as reference on shared tokens (labTokens.ts) and adds batch C:
the action-placement classification for review, side panels on touch and
below 768, the Plex player on touch, and the dialog conventions.
2026-09-24 03:10:00 +02:00
peter 477271907e feat(design): batch A recorded, batch B in the design lab (E28 S2b)
DESIGN.md: the Controls chapter decided (A1-A6) with the decision-log rows;
density stays open as B1. The lab keeps only the chosen A renderings as
reference and adds batch B: density, the scheme set (measured accent hue
gaps and light contrast), state layers, the page-chrome model, surface per
role, the public watch page, the nav rail, the loading form and where the
header + fade edge goes.
2026-09-24 02:21:20 +02:00
peter 50923bb684 fix(build): keep JSX line-end spaces in dev and vitest
plugin-react recognises the React Compiler only by its package name, and
reactPlugin.ts passes the imported plugin, so plugin-react left Babel's
retainLines on in dev. With the compiler's re-printed JSX that trimmed the
space at the end of a text line (the privacy page read
"for example/api/feed" on :5173). Production builds never had it on.
Turn retainLines off through generatorOpts, which wins over the plugin's
top-level option, and pin it with a DOM test seen red without the fix.
2026-09-24 02:21:20 +02:00
peter d2e40dcffb feat(design): design lab + DESIGN.md skeleton (E28 S2a)
- docs/DESIGN.md: the written source of truth for the UI. Decided chapters
  (principles, device adaptation, action placement, text stays selectable,
  interaction rules, copy, anti-patterns, decision log); controls and
  surfaces are marked Pending until the owner picks from the lab.
- /design-lab (dev only): renders each open decision's variants with the
  app's real CSS, themes and font scale. Batch A = six control decisions.
- devOnlyGuard: a production build fails if anything under src/design-lab
  reaches the bundle (seen red with an unguarded import).
- Size contract: tablet portrait + landscape and up, replacing the
  1024x768 floor with its never-built "too small" message.
2026-09-24 01:34:12 +02:00
peter 58878958d8 merge: chore/e2e-password-from-store (E2E password from the secret store, no committed default)
gate / frontend (push) Successful in 4m37s
gate / backend (push) Successful in 6m29s
2026-09-23 23:05:17 +02:00
peter b47d22cf3b chore(e2e): no committed default for the E2E account password
The localdev e2e account's password was a hard-coded default in this public repo, so it
was never a credential. E2E_PASSWORD is now required; the ops wrapper exports it from
the secret store, and the seed/setup fail with a clear message when it is missing.
2026-09-23 02:35:19 +02:00
peter e804ff3b8e release: 0.95.0 (improvement/p1-upload-auth-first)
gate / frontend (push) Successful in 4m35s
gate / backend (push) Successful in 6m18s
2026-09-23 01:32:45 +02:00
peter cd6f119883 chore(release): 0.95.0
The P1 epic: the upload route reads its own body after `require_human`
resolves, so an anonymous caller can no longer make the server spool 500 MiB
before the auth check runs. Three deep-review fix rounds on top of it.
2026-09-23 01:32:32 +02:00
peter 321776480e fix(uploads): make the drift guards fail for the reasons they claim
Deep-review round 3, targeted at the surfaces round 2's fix commit changed.
Five of the eight findings are the same shape: the guards written to stop
half-done sweeps were themselves half-done.

test_upload.py's display-field guard walked only `ast.Assign`, so the one
writer with an unbounded REQUEST HEADER behind it — `spool_upload`'s
`display_name=display_text(...)` keyword — was invisible to the guard meant
to protect it. It also stopped at `app/routes/`, missing the four writers in
`downloads/` that actually construct the row, and asserted `not offenders`,
which enforces nothing once a refactor moves the writers out of its view.
It now walks assignments AND keywords across `app/`, and holds exact set
equality against a registry of all twelve write sites with each one's value.

test_body_limit.py's release guard used `any(... ast.walk(fn))`, which
accepts a `release_read_connection` that runs AFTER the await it exists to
precede — satisfying the letter of the rule while pinning a connection for
exactly the transfer it was supposed to free. Position is checked now, and a
late release is its own, louder failure. The registry keyed sites by file
basename while scanning recursively, and `admin.py`, `discover.py` and
`playlists.py` each exist both at the top level and under `plex/` — the same
entry-collapse the registry was widened to close. Keyed by relative path now.
And all three guards recognised a body read only by the `capped_` naming
convention, so a route spelling `await request.body()` inline declared no
ceiling, released no connection, and was invisible to every one of them.

`release_read_connection`'s precondition claimed the caller "must have
NOTHING PENDING, and that is checked rather than described" while checking
only UNFLUSHED ORM work. Measured: after `add`+`flush` all three collections
are empty and the rollback loses the row, and Core DML never enters them at
all — both of them this codebase's canonical write shapes. It now also asks
the transaction directly via `pg_current_xact_id_if_assigned()`.

`errors.tooLarge` was reworded to "try a smaller file", but that key answers
every route's body cap: the issue composer's over-size JSON draft got advice
about a file it does not have. Back to payload-neutral; the file-specific
sentence keeps its own key.

The upload dialog's new danger border outranked `dragOver`, so a user
replacing an over-cap file got no sign the drop zone was live — and with a
file picked the label shows its name rather than "drop it here", so the
border was the only feedback left.

Four further findings were refuted 3/3 and are not fixed: `aria-invalid` on
the drop zone (measured in Chromium — the AX node does carry it on a native
button), a bidi-override spoof through `display_text` (the served filename
already strips those categories, and the proposed filter would have broken
every ZWJ emoji), and `capped_body` not draining (the screenshot route's edge
ceiling equals its own cap, so that refusal is unreachable, and the log sink
is sent by beacon with nobody reading the answer).
2026-09-23 01:20:22 +02:00
peter 7c0647f87a fix(uploads): finish the sweeps round 1 left half-done
Deep-review round 2, on round 1's own fix commit. It found MORE than round 1
did (28 raw, against 13), and most of it was mine -- a new piece of UI attracts
findings a backend-only diff never does. The pattern in almost all of them is
the same: a principle applied to the site that prompted it and not to its
siblings.

* `display_text` reached three of the FIVE writers of a user-supplied name.
  `POST /api/downloads` and `POST /api/downloads/edit` still handed Postgres an
  unbounded string, so a 300-character name pasted into the Add-download dialog
  or the editor was a bare 500 (main.py re-raises every DataError but the NUL
  case). Four of the seven reviewers found it independently. The bound now lives
  in `models.py`, the three columns are DECLARED with it, and the guard for the
  next writer is parsed rather than remembered: every `display_name` /
  `display_uploader` assignment under `app/routes/` must go through the helper.
  That guard immediately found two more -- the Google sign-in callback writes
  the profile name into the same 255-wide column -- so it is six, not five.

* The connection release reached one of the THREE routes that read their own
  body. The screenshot route (10 MiB, from a phone) and the client log sink both
  still held a pooled connection idle-in-transaction for the length of the
  upload, which is the invariant db.py sizes the pool on. All three now call
  `db.release_read_connection`, whose precondition -- nothing pending on the
  shared session -- is CHECKED rather than described, because a whole-session
  rollback discarding a caller's staged write is a mistake this repo has already
  made once. `test_body_limit.py` holds the rule for the next such route.

* The AST drift registry keyed its sites by (module, limit expression), so a
  second route copied from the first collapsed onto the same entry and went
  uncovered -- the exact hole the registry exists to close. Keyed per call site
  now, and it carries the release requirement too.

* The dialog's own refusal was announced once and never again: two over-cap
  picks leave an identical node with identical text, and an untouched live
  region says nothing. It is keyed per pick now, the drop zone reads as invalid
  and points at the sentence, and the reason is reachable from the control
  rather than only from a one-shot announcement.

* Two tests rebuilt the blind spot they were written to remove: the dialog's cap
  test sat nowhere near the boundary (a `>` loosened to `>=` survived it, which
  is the mutation round 1 fixed on the server side), and the header-precedence
  test only covered an exact-case duplicate while a differently-cased one would
  have been COMBINED onto the wire rather than replaced. Both fixed, and the
  merge now dedupes case-insensitively because header names are.

Also: the upload dialog's unstubbed usage query was firing a real request from
jsdom in a neighbouring suite and leaking a delayed "connection lost" toast
across test boundaries; `GET /api/downloads/usage` had no test at all though the
whole client-side check hangs off it; and the comment claiming the edge 413 is
readable now says what was measured -- readable to 200 MiB over loopback, a bare
`onerror` through a proxy -- so the pre-send check is named as the mechanism and
that branch as the backstop it is.

Every test added or changed here was mutation-checked. One mutation SURVIVED and
is recorded rather than papered over: the `tooBig` term inside `submit()` cannot
be reached, because `ui/Button` swallows the activation itself, so it is marked
as belt and braces instead of being described as the guard.
2026-09-22 23:31:55 +02:00
peter efd7b217ca fix(uploads): make both caps answerable, and stop holding a connection
The epic-end deep review found three defects in P1 and four tests that did not
hold what they claimed. They land together because they share the upload route
and its test module; splitting by hunk would have produced commits that do not
individually run green.

* The edge ceiling and the route's own cap are the SAME number at the default
  configuration, so the edge always answers first: a declared Content-Length
  over the cap is refused before a byte is read, and an undeclared body trips
  the middleware on the same chunk the route would have tripped on. The route's
  numbered 422 -- and the drain written for it last week -- are reachable only
  once an admin LOWERS the knob, which is the one shape the browser UAT tested.
  Measured: headroom does not fix this, because the declared-length check fires
  first for any body big enough to matter and the drain pushes the count back
  over the edge for the rest. So the edge's own refusal is made readable
  instead: `errors.tooLarge` localizes `too_large` the way `upload_too_large`
  was already localized, and the dialog refuses an over-cap file BEFORE sending
  it, off the `upload_max_bytes` that GET /api/downloads/usage now reports.
  Measured in Chrome against the API directly: that 413 arrives as `onload`
  with a readable body in 43 ms.

* Reading the body behind the auth dependency put the whole transfer inside the
  request's database session -- the auth SELECT autobegins a transaction, so one
  of the twenty pooled connections sat `idle in transaction`, with nothing to
  time it out, for as long as the client cared to keep sending. That is the
  invariant db.py sizes the pool on and plex/playback.py records an incident
  for. The read transaction now ends before the await. Measured on the dev
  server: an 1818 ms upload shows no idle-in-transaction sample while the bytes
  move, only afterwards, when the handler actually writes.

* The display name moved into a request header, where nothing bounds its length,
  while the column stayed varchar(255) -- so a long enough name was a DataError
  500 after the whole file had already transferred. One `display_text` helper
  now serves all three writers (the metadata PATCH already truncated, the upload
  ingest did not), and the dialog stops at the same number.

* The gate invariant asked `route.dependant.body_params`, which holds only the
  route's OWN callable's parameters: a body declared in a SUB-DEPENDENCY -- the
  upload route's own idiom -- leaves it empty while FastAPI still reads the body
  before auth. It asks `route.body_field` now, which is what FastAPI itself
  consults. The AST registry of body-reading call sites had also never followed
  `capped_stream_to`, so the one route the whole invariant exists for was the
  single site the drift guard could not see.

Four tests that passed over their own mutation are fixed: the cap boundary (a
`>` loosened to `>=` survived the entire suite), the percent-decoding (every
filename the suite sends is ASCII, where quote() is the identity function), the
display-name header (no test at all), and the staging cleanup (deleting the
unlink left the suite green while every refused upload leaked its partial bytes
onto the volume). Every test added or changed here was mutation-checked.
2026-09-22 22:41:22 +02:00
peter 3d7ef9e945 fix(uploads): drain an over-cap body before refusing it
Found in the browser UAT, not by the gate. Moving the cap onto the arriving
bytes meant it fires while the client is still sending, and answering + closing
mid-upload is what XHR reports as a network failure: the upload dialog said
"the server couldn't be reached" while the access log showed a correct 422
carrying the limit. A refusal the client cannot read is an outage as far as the
user is concerned.

`capped_stream_to` now reads the remainder to nowhere before raising, so the
client finishes sending and reads the real answer -- re-measured in the
browser, which now names the limit. Nothing past the cap is written, and the
request is still bounded by bodylimit's ceiling, so the cost is bandwidth an
AUTHENTICATED caller was sending anyway. The anonymous path is untouched:
auth still resolves before a byte is read.

The regression test sits on the helper, not the route, and the reason is worth
keeping: the route-level version was written first and PASSED with the drain
deleted. httpx's ASGI transport pumps the request body whether or not the app
reads it, so "drained" and "abandoned" are indistinguishable through
TestClient. Its own mutation check is what exposed it.
2026-09-22 02:11:21 +02:00
peter 7fce7a7ce5 chore(deps): drop python-multipart -- nothing parses a multipart body now
Its own entry in requirements.txt said the download center's local-file upload
was "the only multipart endpoint in the app". Since that route reads a raw
body, the claim is false and the package is dead weight: no route declares
File/Form, and nothing calls `request.form()`.

Removed from requirements.txt and from its single pinned line in
requirements.lock. The lock is generated and normally regenerated wholesale,
but a full regeneration would bump every transitive dependency, which has no
business riding inside a security fix -- the next legitimate regeneration
reproduces exactly this state, since the package is gone from the source.

The `multipart` entry in logs.py's third-party level map goes with it: a level
for a logger that can no longer exist.
2026-09-22 01:59:09 +02:00
peter f5018bdcbc fix(uploads): read the body after authenticating, not before it
`POST /api/downloads/upload` declared `file: UploadFile = File(...)`, and
FastAPI parses and spools a declared body before the first dependency runs --
`require_human` included. An unauthenticated caller could therefore make the
process write the whole per-file allowance (500 MiB by default) to temp, once
per request. SEC-20's ceiling bounded that number but could not stop the
writing, which is why E27 parked it rather than closing it.

The route now takes no body parameter. `spool_upload` names `require_human`
first and only then streams `request.stream()` to the staging temp file, so a
stranger is refused before a byte moves -- the shape the screenshot route has
used since E16. Filename and display name travel as percent-encoded headers
(latin-1 on the wire, so a Hungarian filename needs the encoding) instead of
multipart parts.

`capped_stream_to` joins `capped_body` in routes/_common.py: the same counting
rule, written to a file descriptor rather than accumulated, because holding
500 MiB would trade a disk problem for a memory one. Its refusal is the
caller's, so the upload route keeps answering 422 `upload_too_large` with the
admin knob's own number.

With no envelope to allow for, the edge is now exactly that knob, and
MULTIPART_ENVELOPE_ALLOWANCE is gone.

The invariant stops being prose: test_body_limit.py asks FastAPI itself
(`route.dependant.body_params`) and fails the gate if any route with a raised
ceiling declares a body. A comment could not have prevented the defect it
described; that test would have.

Both new tests were seen red for the right reason -- before the fix the
anonymous probe got its 401 only after the server had read 4236 bytes of a
multipart body, and re-declaring the File parameter turns the invariant test
red naming the route and the parameter.
2026-09-22 01:59:03 +02:00
523 changed files with 34779 additions and 12787 deletions
+815 -1387
View File
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -46,11 +46,13 @@ how it gets built. Concretely:
behind each option and the weight of the decision. These decision points are where the product owner
engages with the build and learns from it, so make them understandable — a reasoned menu, not a bare one.
- **Mockups for design/UX decisions.** For any visual or interaction choice, show mockup options to
choose from before building.
choose from before building. How the UI looks and behaves is decided in
[`docs/DESIGN.md`](docs/DESIGN.md) (its variants render in the dev-only `/design-lab`): build to it,
and change it only through its decision log.
- **Challenge unsound requests.** If a request is technically ill-advised and you can back it with real
arguments, push back or propose an alternative — don't comply blindly.
- **Verify UI in the browser before calling it done.** tsc/unit/DOM tests miss visual and interaction
bugs; do a real visual check of any UI change.
bugs; do a real visual check of any UI change, and run DESIGN.md's four-step Conformance check.
- **Be terse everywhere else.** Outside those decision points, lead with the answer or decision. During
implementation and debugging especially: no restating the same point reworded, no filler.
@@ -79,7 +81,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` |
| Request body | a Pydantic model | `payload: dict` |
| Request SIZE | nothing — `bodylimit` caps every route (SEC-20); need more? add a ceiling to `ROUTE_MAX_BODY_BYTES` | `capped_body` on a route that does not read raw bytes itself |
| Request SIZE | nothing — `bodylimit` caps every route (SEC-20); need more? add a ceiling to `ROUTE_MAX_BODY_BYTES` | `capped_body` on a route that does not read raw bytes itself |
| Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` |
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
@@ -90,10 +92,13 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
| FE action button | `ui/Button` — pick a `SIZES` rung | a hand-rolled `<button className="px-3 py-2 …">` |
| FE button | `ui/Button` (a `SIZES` rung) · `IconButton` · `Pressable` (a clickable card/row) · `Chip` · `DragHandle` | a raw `<button>` or `FocusSafeButton` outside the control primitives (lint-enforced) |
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
| FE text field / checkbox / slider | `ui/Input` · `Textarea` · `InnerInput` in a `fieldClass` frame · `Checkbox`/`CheckRow` · `Slider` | a raw `<input>`/`<textarea>` anywhere but those primitives (lint-enforced) |
| FE glass surface | a role: `SURFACE.panel` / `card` / `menu` / `popover` / `tooltip` (`ui/surfaces.ts`) | a bare `glass`/`glass-card`/`glass-menu` beside a hand-picked radius (lint-enforced) |
| FE static label / button-like link | `Badge` (flat, no edge) · `LinkButton` / `IconLink` | a glass pill that reads as a chip; an `<a>` hand-painted like a button |
| FE control | `SegmentedToggle`; a long or user's list: `FilterSelect` | a native `<select>` (lint-enforced) |
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
+1 -1
View File
@@ -1 +1 @@
0.94.0
0.96.0
+36 -20
View File
@@ -53,31 +53,47 @@ DEFAULT_MAX_BODY_BYTES = 1024 * 1024
#
# These are RAISED CEILINGS, NOT EXEMPTIONS, and the difference is the whole point. An exemption
# would mean "this route is counted by something else" — which is only true when the route's own
# limit applies to the bytes as they ARRIVE. That holds for the screenshot route (no declared body
# parameter: `capped_body` streams it, and the owner check resolves first) and it does NOT hold for
# the upload route: `file: UploadFile = File(...)` is a declared body, so FastAPI parses and spools
# the whole multipart part to a temp file BEFORE the first dependency runs — `require_human`
# included. Its in-handler cap therefore measures bytes that have already landed. Left uncounted,
# an anonymous caller could stream without bound into the container's tmpfs; counted here, the
# process refuses at the same number the route itself enforces, before any of it is stored.
# limit applies to the bytes as they ARRIVE. That, in turn, is only true of a route with NO DECLARED
# BODY PARAMETER: FastAPI parses and spools a declared body BEFORE the first dependency runs, so a
# route that declares one has already stored the bytes by the time its own cap — or its auth — gets
# a say.
#
# Both listed routes now satisfy that. The screenshot route always did (`capped_body` streams it,
# and the owner check resolves first). The upload route did NOT until P1: it declared
# `file: UploadFile = File(...)`, so an anonymous caller could make the process write the whole
# per-file allowance to temp before `require_human` refused them — this middleware bounded that
# number but could not stop the writing. It now reads its own body from a dependency declared after
# the auth one, so its in-handler cap measures bytes as they arrive, like everything else here.
#
# That rule is no longer only written here: `test_body_limit.py` parses the routes and fails the
# gate if any route in this dict declares a body parameter. A comment could not have stopped the
# defect it describes; the test can.
#
# A route belongs here ONLY IF ITS OWN CAP IS LARGER THAN THE DEFAULT. That is why
# `POST /api/diagnostics/events` is absent: its `capped_body` limit is 512 KiB, already below the
# default, so listing it could only widen what it accepts.
# What a multipart envelope costs on top of the file itself: the boundary lines, the part headers
# and the sibling `display_name` field. The edge counts RAW BODY bytes while the upload route counts
# the DECODED file, so without this allowance a file of exactly the configured maximum — the number
# the admin page advertises, and the first thing anyone testing the limit reaches for — is refused
# one layer before the route that would have accepted it. Generous on purpose: its job is to keep
# the two layers from disagreeing at the boundary, not to be a second limit.
MULTIPART_ENVELOPE_ALLOWANCE = 64 * 1024
ROUTE_MAX_BODY_BYTES: dict[tuple[str, str], int] = {
# Ingesting a local video file. `sysconfig`'s `download_upload_max_bytes` is bounded so it can
# never exceed this (see sysconfig.SPECS), which is what stops an admin from raising the route's
# own cap past the edge and collecting 413s from a layer the admin page never mentions.
("POST", "/api/downloads/upload"): settings.download_upload_max_bytes
+ MULTIPART_ENVELOPE_ALLOWANCE,
# Ingesting a local file. The body IS the file since P1 — no multipart envelope to allow for —
# so this ceiling and the route's own cap count exactly the same bytes. `sysconfig`'s
# `download_upload_max_bytes` is bounded by this same constant (see sysconfig.SPECS), so an
# admin can only ever LOWER the route's cap below the edge, never raise it past one.
#
# WHICH LAYER ANSWERS, measured rather than assumed (deep-review round 1):
# * knob LOWERED (an override below the default) — the route's cap is the smaller number, so
# `capped_stream_to` trips first and answers 422 `upload_too_large` carrying the admin's own
# figure, after draining the rest of the body so the client can read it.
# * knob at the DEFAULT — the two numbers are EQUAL, and the edge always wins: a declared
# Content-Length over the cap is refused here before a byte is read, and an undeclared body
# trips this counter on the same chunk the route would have tripped on. The route's 422 is
# unreachable in that configuration, and no amount of headroom would change it — the
# declared-length check fires first for any body big enough to matter, and the route's drain
# pushes the count past the edge for the rest.
#
# So this 413 is a refusal a real user meets on a default install, not a theoretical edge, and
# it has to be as readable as the route's own: the client localizes `too_large` (`errors.tooLarge`)
# exactly as it does `upload_too_large`, and the upload dialog refuses an over-cap file before
# sending it at all, off the `upload_max_bytes` that `GET /api/downloads/usage` reports.
("POST", "/api/downloads/upload"): settings.download_upload_max_bytes,
("POST", "/api/debug-sessions/{sid}/screenshots"): debugsession.SCREENSHOT_MAX_BYTES,
}
+51 -2
View File
@@ -1,6 +1,6 @@
from collections.abc import Iterator
from sqlalchemy import create_engine
from sqlalchemy import create_engine, text
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from app.config import settings
@@ -12,7 +12,9 @@ class Base(DeclarativeBase):
# Pool sized above the sync-endpoint threadpool (~40) so a burst of short DB sessions can't starve on
# checkout. The image proxies deliberately DON'T hold a connection during their slow external fetch
# (see routes/plex/), so this headroom is only for the many brief sessions, not long ones.
# (see routes/plex/), so this headroom is only for the many brief sessions, not long ones. The one
# long hold — a playlist sync's advisory claim, for a whole push — opens its own unpooled connection
# (sync/playlists.py `_claim_engine`) instead of drawing a third one from here.
engine = create_engine(
settings.database_url,
pool_pre_ping=True,
@@ -34,3 +36,50 @@ def get_db() -> Iterator[Session]:
yield db
finally:
db.close()
def release_read_connection(db: Session) -> None:
"""End a request's READ transaction so its pooled connection goes back BEFORE a long await.
SQLAlchemy 2.0 autobegins on the first SELECT, so a request that has merely authenticated is
already holding one of the connections above — `idle in transaction` to Postgres, and nothing
times it out. A route that then awaits something slow (a request body still arriving, an
external fetch) pins it for the whole wait, which is precisely the invariant the pool is sized
on in the comment above. Measured: the next checkout after this call reuses the very backend
that was released, and a 1.8 s upload goes from one sustained `idle in transaction` to none.
Every route that reads its own body from a dependency needs this, and `test_body_limit.py`
holds that — the three that do are the ones with a raised (or self-imposed) body ceiling, and
they got here one at a time, which is how the upload route ended up the only one fixed.
The caller must have NOTHING PENDING, and that is checked rather than described. The session is
shared with every dependency that ran before, so a rollback here would silently discard a write
one of them staged — `service.enqueue` already learned that lesson the expensive way and uses a
SAVEPOINT for it. CLAUDE.md: a rule stated in a comment must also be enforced in code.
TWO checks, because the obvious one is blind to the shapes this codebase actually writes in.
`db.new`/`deleted`/`dirty` hold only UNFLUSHED unit-of-work: the moment a helper calls
`db.flush()` — CLAUDE.md's canonical split is "the handler commits; helpers flush" — the object
is persistent with no unflushed history and all three sets are empty again, and Core DML
(`db.execute(update(...))`) never enters them at all. Measured: after `add`+`flush` the sets are
empty, the old check passed, and the rollback lost the row. So the transaction is asked
directly. Postgres assigns a transaction id on the first WRITE and never for a read, so
`pg_current_xact_id_if_assigned()` is NULL for exactly the state this function is safe in — and
it sees a raw `text("UPDATE …")` too, which no session-level bookkeeping would.
"""
if db.new or db.deleted or db.dirty:
raise RuntimeError(
"release_read_connection() with pending work: something ahead of this dependency "
"staged a write, which ending the transaction would discard. Commit it first, or do "
"it after the await."
)
if (
db.in_transaction()
and db.execute(text("SELECT pg_current_xact_id_if_assigned()")).scalar() is not None
):
raise RuntimeError(
"release_read_connection() on a transaction that has already WRITTEN: something ahead "
"of this dependency flushed a row or ran an UPDATE/INSERT/DELETE, which ending the "
"transaction would discard. Commit it first, or do it after the await."
)
db.rollback()
+2 -2
View File
@@ -25,7 +25,7 @@ from app.downloads.jobdb import (
)
from app.downloads.stated import StatedFailure
from app.downloads.status import JobStatus
from app.models import DownloadJob, MediaAsset
from app.models import DISPLAY_TEXT_MAX, DownloadJob, MediaAsset
from app.notifications import create_notification
from app.titles import normalize_title
@@ -597,7 +597,7 @@ def _download(
}
if job and not job.display_name and meta.title:
# display_name is VARCHAR(255) — clip a long title (see _fill_asset_meta_early).
fields["display_name"] = meta.title[:255]
fields["display_name"] = meta.title[:DISPLAY_TEXT_MAX]
# Atomic terminal write: finalizes ONLY while the row is still running, so a child that
# finishes just after a route cancel/pause can't resurrect it (see finalize_if_running).
finalized = service.finalize_if_running(db, job_id, **fields)
+8 -5
View File
@@ -20,7 +20,7 @@ from app.downloads import credentials
from app.downloads import edit as editmod
from app.downloads import formats, mediakind, quota, storage
from app.downloads.status import HOLDING, JobStatus
from app.models import Channel, DownloadJob, MediaAsset, Video
from app.models import DISPLAY_TEXT_MAX, Channel, DownloadJob, MediaAsset, Video
class EditError(Exception):
@@ -179,7 +179,7 @@ def enqueue(
profile_id=profile_id,
profile_snapshot=spec,
display_name=display_name
or (asset.title[:255] if asset.status == "ready" and asset.title else None),
or (asset.title[:DISPLAY_TEXT_MAX] if asset.status == "ready" and asset.title else None),
status="queued",
queue_pos=next_queue_pos(db),
)
@@ -198,10 +198,13 @@ def enqueue(
def _clip_title(source_title: str | None, display_name: str | None) -> str:
# Clipped to DISPLAY_TEXT_MAX, not to a literal: this feeds `DownloadJob.display_name` through
# `enqueue_edit`, the same column `display_text` guards. A machine-made title, so it is clipped
# rather than run through `display_text` — blank here means "no title", not None.
if display_name:
return display_name[:255]
return display_name[:DISPLAY_TEXT_MAX]
base = source_title or "Clip"
return f"{base} (clip)"[:255]
return f"{base} (clip)"[:DISPLAY_TEXT_MAX]
def enqueue_edit(
@@ -255,7 +258,7 @@ def enqueue_edit(
source_asset_id=src_asset.id,
edit_spec=spec,
profile_snapshot={},
display_name=display_name or asset.title,
display_name=display_name or asset.title[:DISPLAY_TEXT_MAX],
status="queued",
queue_pos=next_queue_pos(db),
)
+2 -2
View File
@@ -25,7 +25,7 @@ from sqlalchemy.orm import Session
from app.config import settings
from app.downloads import edit as editmod
from app.downloads import mediakind, probe, quota, service, storage
from app.models import DownloadJob, MediaAsset
from app.models import DownloadJob, MediaAsset, display_text
def _title_from_filename(filename: str) -> str:
@@ -99,7 +99,7 @@ def ingest_upload(
source_kind="upload",
source_ref=ref,
profile_snapshot={},
display_name=(display_name or "").strip() or None,
display_name=display_text(display_name),
status="done",
progress=100,
queue_pos=service.next_queue_pos(db),
-1
View File
@@ -466,7 +466,6 @@ _THIRD_PARTY_LEVELS = {
"httpx": "WARNING",
"httpcore": "WARNING",
"asyncio": "WARNING",
"multipart": "WARNING",
"PIL": "WARNING",
"alembic": "INFO",
# uvicorn's own startup/shutdown/WebSocket lines are worth keeping; its ACCESS log is not —
+31 -3
View File
@@ -23,6 +23,34 @@ from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
from app.db import Base
# Every user-supplied display string in this schema shares one width, and it lives HERE so the
# columns below are declared with it rather than with a literal: a name a person typed goes into
# `User.display_name` (their Google profile name), `DownloadJob.display_name` and
# `DownloadJob.display_uploader`. A value wider than the column is a psycopg DataError, which
# `main.py` re-raises for everything but the NUL case — i.e. a bare 500 for a name somebody pasted.
DISPLAY_TEXT_MAX = 255
def display_text(raw: str | None) -> str | None:
"""A user-supplied display string on its way into one of those columns: stripped, truncated,
blank -> None.
One definition because there are SIX writers and they had drifted: `POST /api/downloads`,
`POST /api/downloads/edit`, `POST /api/downloads/upload`, the metadata PATCH's two fields, and
the Google sign-in callback. Only the PATCH truncated. Truncating is the right answer rather
than refusing: the name is a label on work the user has already done — a file already
transferred, an account already authenticated — and losing its tail is not worth failing the
request over.
`test_upload.py` holds both halves: that every route assigning one of these fields goes through
this helper (parsed, not grepped), and that each of those routes truncates rather than 500s. A
sweep is exactly the kind of thing that gets done five-sixths of the way, which is how this
ended up being written twice.
"""
return (raw or "").strip()[:DISPLAY_TEXT_MAX] or None
from app.utils import canonical_email
@@ -114,7 +142,7 @@ class User(Base, TimestampMixin, CanonicalEmailMixin):
# not cleared eagerly — it simply stops counting).
diagnostics_level: Mapped[str | None] = mapped_column(String(8))
diagnostics_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
display_name: Mapped[str | None] = mapped_column(String(255))
display_name: Mapped[str | None] = mapped_column(String(DISPLAY_TEXT_MAX))
avatar_url: Mapped[str | None] = mapped_column(String(1024))
role: Mapped[str] = mapped_column(String(16), default="user", server_default="user")
# The user's own YouTube channel id, resolved lazily (channels.list?mine=true) and
@@ -953,11 +981,11 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
ForeignKey("download_profiles.id", ondelete="SET NULL")
)
profile_snapshot: Mapped[dict] = mapped_column(JSON)
display_name: Mapped[str | None] = mapped_column(String(255))
display_name: Mapped[str | None] = mapped_column(String(DISPLAY_TEXT_MAX))
# Per-user display overrides + extras (like display_name — metadata only, never the file).
# `display_uploader`/`display_uploader_url` override the asset's auto channel name/link;
# `extra_links` is an optional list of extra reference URLs shown on the card + watch page.
display_uploader: Mapped[str | None] = mapped_column(String(255))
display_uploader: Mapped[str | None] = mapped_column(String(DISPLAY_TEXT_MAX))
display_uploader_url: Mapped[str | None] = mapped_column(Text)
extra_links: Mapped[list | None] = mapped_column(JSON)
status: Mapped[str] = mapped_column(
+84 -4
View File
@@ -32,6 +32,7 @@ window still took the full 145 s and emitted the whole track), so windows are ex
zero-based clock and shifted back to absolute time here with `shift_vtt`.
"""
import codecs
import json
import re
import subprocess
@@ -217,7 +218,11 @@ _FULL_END_S = 1_000_000_000.0 # stand-in for "to the end of the file" (JSON can
def cache_key(rating_key: str, sub_ord: int) -> str:
return f"sub_{rating_key}_{sub_ord}"
# Versioned whenever `decode_subtitle` changes what a track decodes to — `sub2_` when legacy code
# pages were cached as U+FFFD, `sub3_` when a stray byte, an untagged file or Serbian still went
# to the wrong code page. The cache has no eviction, so every track is read again once rather
# than a broken one being served for good; the older files are just left behind.
return f"sub3_{rating_key}_{sub_ord}"
def _vtt_path(key: str) -> Path:
@@ -904,15 +909,90 @@ def _full_pass_start(rating_key: str, targets: dict[int, int]) -> float:
return max(0.0, float(f"{min(ends) - WINDOW_PRE_S:.3f}"))
# The legacy code page a subtitle in this language (Plex's ISO 639-2 `languageCode`) was most likely
# saved in. Serbian sits here, not with Cyrillic: its releases are overwhelmingly Latin script in
# Windows-1250, like Croatian and Bosnian.
_CP1250 = {
"hun",
"ces",
"cze",
"pol",
"slk",
"slo",
"slv",
"hrv",
"bos",
"srp",
"ron",
"rum",
"sqi",
"alb",
}
_CP1251 = {"rus", "ukr", "bel", "bul", "mkd", "mac"}
# Western European languages keep Windows-1252 (cp1250 has no à/è/ñ). An UNTAGGED file falls to
# Windows-1250: its ASCII and á/é/í/ó/ö/ú/ü match Windows-1252 byte for byte, and it also gets ő/ű
# right, which Windows-1252 turns into õ/û — the library this reads is mostly Hungarian.
_CP1252 = {
"eng",
"fre",
"fra",
"ger",
"deu",
"spa",
"ita",
"por",
"dut",
"nld",
"swe",
"dan",
"nor",
"fin",
"ice",
"isl",
"cat",
}
# A file that is almost all valid UTF-8 is UTF-8 with a few stray bytes (a pasted Windows-1252 quote),
# not a legacy file: accents in a legacy code page are nearly never valid UTF-8 sequences, so a real
# legacy file has hardly any. At least this many valid multi-byte characters per invalid byte.
_UTF8_VALID_PER_STRAY = 10
def decode_subtitle(raw: bytes, language_code: str | None) -> str:
"""A sidecar subtitle file as text. SubRip carries no charset, and a Hungarian release is
typically Windows-1250: decoded as UTF-8 with `replace`, every ő/ű/é came out as U+FFFD (found
live, 1391 of them in one episode). So: a BOM decides; else valid UTF-8 stays UTF-8 (a legacy
code page with accents is almost never valid UTF-8), and so does NEARLY valid UTF-8 — one stray
byte must not send a whole UTF-8 file to a legacy code page; else the language's legacy code page."""
if raw.startswith(codecs.BOM_UTF8):
return raw[len(codecs.BOM_UTF8) :].decode("utf-8", "replace")
if raw.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
return raw.decode("utf-16", "replace")
try:
return raw.decode("utf-8")
except UnicodeDecodeError:
# silent-ok: not strictly UTF-8 — nearly-UTF-8 or a legacy code page, told apart below.
pass
lenient = raw.decode("utf-8", "replace")
stray = lenient.count("�")
valid = sum(1 for ch in lenient if ch > "" and ch != "�")
if valid >= stray * _UTF8_VALID_PER_STRAY:
return lenient
lang = (language_code or "").lower()
legacy = "cp1251" if lang in _CP1251 else "cp1252" if lang in _CP1252 else "cp1250"
return raw.decode(legacy, "replace")
def _sidecar_vtt(db: Session, stream: dict) -> str:
"""An external subtitle file, fetched through Plex and converted to WebVTT."""
"""An external subtitle file, fetched through Plex and converted to WebVTT. Its bytes are made
UTF-8 first (`decode_subtitle`), on the ffmpeg path too: ffmpeg reads a text subtitle as UTF-8."""
with PlexClient(db) as plex:
raw = plex.raw_get(stream["key"])
text = decode_subtitle(raw, stream.get("languageCode"))
codec = (stream.get("codec") or stream.get("format") or "").lower()
if codec in ("srt", "subrip", ""):
return srt_to_vtt(raw.decode("utf-8", "replace"))
return srt_to_vtt(text)
with tempfile.NamedTemporaryFile(suffix="." + (codec or "sub"), delete=False) as fh:
fh.write(raw)
fh.write(text.encode("utf-8"))
tmp = fh.name
try:
return ffmpeg_to_vtt(tmp, None)
+51 -6
View File
@@ -6,7 +6,7 @@ and "fetch a row this user owns or 404" idioms have one definition, one status c
one existence-leak policy.
"""
from typing import Any, TypeVar
from typing import IO, Any, TypeVar
from fastapi import HTTPException, Request
from sqlalchemy.orm import Session
@@ -28,10 +28,13 @@ def not_found(what: str = "generic") -> HTTPException:
return HTTPException(status_code=404, detail={"code": "not_found", "reason": what})
def get_or_404(db: Session, model: type[M], id_: Any, *, what: str = "generic") -> M:
def get_or_404(
db: Session, model: type[M], id_: Any, *, what: str = "generic", lock: bool = False
) -> M:
"""Fetch a row by primary key or raise 404. Replaces the `db.get(Model, id); if None: 404`
idiom repeated across routes."""
obj = db.get(model, id_)
idiom repeated across routes. `lock` takes it FOR UPDATE (bypassing the identity map), so two
requests that read-then-write its children (a playlist's item positions) run one after the other."""
obj = db.get(model, id_, with_for_update=True if lock else None)
if obj is None:
raise not_found(what)
return obj
@@ -45,12 +48,13 @@ def owned_or_404(
*,
owner_attr: str = "user_id",
what: str = "generic",
lock: bool = False,
) -> M:
"""Fetch a row by primary key that `user` owns, or raise 404. A row owned by someone
"""Fetch a row by primary key that `user` owns, or raise 404 (`lock`: see `get_or_404`). A row owned by someone
else 404s (NOT 403) on purpose — a 403 would confirm the id exists to a stranger.
It replaced the near-identical per-route ownership helpers, and is called directly — a thin
`_own_x` wrapper around it is the ad-hoc shape CLAUDE.md's patterns table rules out."""
obj = get_or_404(db, model, id_, what=what)
obj = get_or_404(db, model, id_, what=what, lock=lock)
if getattr(obj, owner_attr) != user.id:
raise not_found(what)
return obj
@@ -79,6 +83,47 @@ async def capped_body(request: Request, limit: int) -> bytes:
return bytes(body)
async def capped_stream_to(
request: Request, out: IO[bytes], limit: int, refusal: HTTPException
) -> int:
"""`capped_body`'s sibling for a body too large to hold: the same counting rule, written
straight to `out` instead of accumulated, returning the size.
Two deliberate differences from `capped_body`. It never builds a `bytearray` — the upload route
admits 500 MiB by default, so holding the body would trade a disk problem for a memory one. And
the refusal is the CALLER'S, passed in rather than assumed: `capped_body`'s flat 413 is right
for a screenshot, while the upload route answers 422 `upload_too_large` carrying the admin
knob's own number, which is what its dialog shows the user. One counting rule, two answers,
neither of them a second copy of the loop.
The caller owns `out` and owns cleaning it up: a body refused half-way has already written what
arrived before the limit was passed.
**The refusal DRAINS the rest of the body before it is raised, and that is not politeness.**
Answering mid-upload and closing the connection is what the client sees as a network failure,
not as a refusal: the browser's XHR fires `onerror` before it can read the response, so a file
over the limit reported itself as "the server couldn't be reached" instead of naming the limit —
measured in a real browser, not reasoned about. Reading the remainder to nowhere lets the client
finish sending, read the 422 and say the true thing.
The bytes are discarded, never written, and the whole request is already bounded by
`bodylimit`'s per-route ceiling — so the worst case is bandwidth an authenticated caller was
sending anyway, against the old behaviour which wrote exactly those bytes to disk."""
size = 0
over = False
async for chunk in request.stream():
size += len(chunk)
if over:
continue # draining — see the docstring; counted, not stored
if size > limit:
over = True
continue
out.write(chunk)
if over:
raise refusal
return size
def issue_refused(exc: gitea.GiteaError) -> HTTPException:
"""The refusal an issue-tracker failure (E16) answers as — the send, the preview and the
admin's connection test alike. Literal reasons, one per case: the frontend's contract test reads
+3 -3
View File
@@ -39,7 +39,7 @@ from app.auth import (
)
from app.config import settings
from app.db import SessionLocal, get_db
from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User
from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User, display_text
from app.ratelimit import RateLimiter, client_key
from app.security import encrypt, hash_password, hash_token, verify_password
from app.utils import canonical_email, valid_email
@@ -472,7 +472,7 @@ async def callback(
if email:
user.google_email = email
_sync_google_email(db, user, email)
user.display_name = userinfo.get("name")
user.display_name = display_text(userinfo.get("name"))
user.avatar_url = userinfo.get("picture")
# ADMIN_EMAILS (env) is the bootstrap admin list and always wins, so the configured admin can't
# be locked out. Everyone else's role is managed in the admin UI and stored in the DB, so a
@@ -594,7 +594,7 @@ def _complete_link(
if linked_email:
user.google_email = linked_email # see the sign-in callback: the other half of the identity
if not user.display_name:
user.display_name = userinfo.get("name")
user.display_name = display_text(userinfo.get("name"))
if not user.avatar_url:
user.avatar_url = userinfo.get("picture")
_store_token(db, user, token)
+15 -14
View File
@@ -28,7 +28,7 @@ from app.models import (
from app.routes._common import get_or_404, not_found, owned_or_404, youtube_refusal
from app.sync.explore import explore_ingest_page
from app.sync.runner import backfill_channel_now, run_recent_backfill
from app.sync.subscriptions import apply_channel_details
from app.sync.subscriptions import apply_channel_details, details_stale
from app.utils import iso
from app.youtube.client import YouTubeClient, YouTubeError
from app.refusal import refused
@@ -190,7 +190,7 @@ def discover_channels(
quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER),
YouTubeClient(db, user) as yt,
):
apply_channel_details(db, yt.get_channels(need))
apply_channel_details(db, yt.get_channels(need), need)
db.commit()
# Match _discovery_rows' ORDER BY exactly: video-count DESC, then title (NULLs LAST,
# as Postgres orders them — coercing NULL→"" would wrongly float untitled channels up).
@@ -296,17 +296,20 @@ def channel_detail(
"""One channel's "About" detail for the channel page header/About tab, plus this user's
relationship to it (subscribed / explored) and how many of its videos are in the catalog.
Lazily fills the About metadata (view count, join date, banner, links) the first time it's
needed — a stub, or a channel last enriched before those fields existed (published_at is a
reliable sentinel: every real channel has a creation date). The enrich uses the API key
(1 unit, cached after), so it's skipped for the quota-less demo account."""
needed (a stub) and refreshes it once it is stale (`DETAILS_MAX_AGE`), which is how a channel nobody subscribes to (an explored one, outside the
subscription import) gets a banner that still resolves. The enrich uses the API key (1 unit,
cached after), so it's skipped for the quota-less demo account."""
channel = get_or_404(db, Channel, channel_id, what="channel")
if channel.published_at is None and not user.is_demo:
# No `published_at is None` sentinel: a terminated channel never gets one, so it re-fetched on
# every view; rows enriched before that field existed are long past DETAILS_MAX_AGE anyway.
stale = details_stale(channel, datetime.now(timezone.utc))
if stale and not user.is_demo:
try:
with (
quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER),
YouTubeClient(db, user) as yt,
):
apply_channel_details(db, yt.get_channels([channel_id]))
apply_channel_details(db, yt.get_channels([channel_id]), [channel_id])
db.commit()
channel = db.get(Channel, channel_id)
except YouTubeError as exc:
@@ -412,12 +415,10 @@ def explore_channel(
YouTubeClient(db, user) as yt,
):
# Enrich for the About panel + the uploads playlist id (needed to ingest videos).
if (
channel.details_synced_at is None
or channel.uploads_playlist_id is None
or channel.published_at is None
):
apply_channel_details(db, yt.get_channels([channel_id]))
# Stale, not "a field is missing": a terminated channel never gets an uploads playlist
# or a creation date, and asking again on every explore would spend quota on a miss.
if details_stale(channel, datetime.now(timezone.utc)):
apply_channel_details(db, yt.get_channels([channel_id]), [channel_id])
db.commit()
channel = db.get(Channel, channel_id)
result = explore_ingest_page(db, yt, channel, page_token)
@@ -590,7 +591,7 @@ def subscribe(
# must not block recording the subscription the user asked for.
if channel.details_synced_at is None:
try:
apply_channel_details(db, yt.get_channels([channel_id]))
apply_channel_details(db, yt.get_channels([channel_id]), [channel_id])
except YouTubeError as exc:
log.warning(
"subscribe: stub enrich failed: %s",
+8 -2
View File
@@ -22,7 +22,7 @@ from sqlalchemy.orm import Session
from app import audit, debugsession, logs, logsink
from app.audit import AuditAction
from app.auth import require_human
from app.db import get_db
from app.db import get_db, release_read_connection
from app.issues import bundle, gitea
from app.ratelimit import RateLimiter
from app.refusal import refused
@@ -335,7 +335,13 @@ def _upload_target(
return owned_or_404(db, DebugSession, sid, user, what="debug_session")
async def _image_body(request: Request) -> bytes:
async def _image_body(request: Request, db: Session = Depends(get_db)) -> bytes:
# The owner check above has already run, and it was a SELECT — so this session is holding a
# pooled connection `idle in transaction` and would hold it for the whole 10 MiB upload, from a
# client that may be on a phone. Hand it back first; the handler's own work re-takes one. The
# `session` object the handler holds is expired by this and reloads on its next attribute read,
# which is the correct moment to touch the database again.
release_read_connection(db)
return await capped_body(request, debugsession.SCREENSHOT_MAX_BYTES)
+6 -2
View File
@@ -31,7 +31,7 @@ from sqlalchemy.orm import Session
from app import debugsession, diagnostics, logs, logsink
from app.auth import current_user
from app.db import get_db
from app.db import get_db, release_read_connection
from app.models import DebugSession, LogEvent, User
from app.ratelimit import RateLimiter
from app.routes._common import capped_body
@@ -73,7 +73,7 @@ class ClientEventsIn(BaseModel):
events: list[ClientEvent] = Field(max_length=EVENTS_MAX)
async def _parsed_events(request: Request) -> ClientEventsIn:
async def _parsed_events(request: Request, db: Session = Depends(get_db)) -> ClientEventsIn:
"""The body as the model — parsed only after the size cap, which is why this is a dependency and
not a plain body parameter (FastAPI reads a declared body in full before anything else runs).
@@ -83,6 +83,10 @@ async def _parsed_events(request: Request) -> ClientEventsIn:
media_type = request.headers.get("content-type", "").split(";")[0].strip().lower()
if media_type != "application/json" and not media_type.endswith("+json"):
raise HTTPException(status_code=415, detail={"code": "json_required"})
# `current_user` resolved above on a SELECT, so this session is holding a pooled connection.
# Give it back before awaiting the body — the sink is a background beacon and its sender may be
# on a link far slower than the 512 KiB suggests. See `db.release_read_connection`.
release_read_connection(db)
raw = await capped_body(request, BODY_MAX_BYTES)
try:
return ClientEventsIn.model_validate_json(raw)
+121 -47
View File
@@ -9,11 +9,13 @@ serves finished files (range-aware, with the user's custom display name), and sh
import os
import re
import tempfile
from collections.abc import AsyncIterator
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from urllib.parse import parse_qs, unquote, urlparse
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy import func, select
@@ -22,7 +24,7 @@ from sqlalchemy.orm import Session
from app import audit, sysconfig
from app.auth import admin_user, require_human
from app.config import settings
from app.db import get_db
from app.db import get_db, release_read_connection
from app.downloads import credentials as dlcreds
from app.downloads import edit as editmod
from app.downloads import links as linksmod
@@ -37,8 +39,9 @@ from app.models import (
DownloadShare,
MediaAsset,
User,
display_text,
)
from app.routes._common import get_or_404, not_found, owned_or_404
from app.routes._common import capped_stream_to, get_or_404, not_found, owned_or_404
from app.security import hash_password
from app.userscope import is_messageable_user, messageable_clauses
from app.utils import iso
@@ -398,7 +401,7 @@ def enqueue_download(
status_code=422, detail={"code": "url_blocked", "reason": e.reason}
) from e
spec, profile_id = _resolve_spec(db, user, body.profile_id, body.spec)
display_name = (body.display_name or "").strip() or None
display_name = display_text(body.display_name)
try:
job = service.enqueue(db, user.id, source_kind, source_ref, spec, profile_id, display_name)
except quota.QuotaExceeded as e:
@@ -406,61 +409,119 @@ def enqueue_download(
return _serialize_job(db, job)
# Read the upload in 1 MiB chunks so a large file never sits fully in memory, and the size cap is
# enforced on the ACTUAL bytes read (a client-set Content-Length can't be trusted to gate it).
_UPLOAD_CHUNK = 1024 * 1024
# The upload's filename and display name travel as HEADERS, not as multipart parts. That is not a
# style choice: a DECLARED body parameter (`file: UploadFile = File(...)`) is read and spooled by
# FastAPI *before the first dependency runs*, so `require_human` could not gate it and an anonymous
# caller could make the process write the whole per-file allowance to temp, per request (P1; SEC-20
# bounded the number but could not stop the writing). Reading the body ourselves, from a dependency
# declared AFTER the auth one, is the only shape that closes it — the same one
# `POST /api/debug-sessions/{sid}/screenshots` uses, and `test_body_limit.py` now enforces it for
# every route with a raised ceiling rather than leaving it to a comment like this one.
#
# Headers are latin-1 on the wire, so both are percent-encoded by the client and unquoted here; a
# Hungarian filename would otherwise be unsendable.
UPLOAD_FILENAME_HEADER = "x-upload-filename"
UPLOAD_DISPLAY_NAME_HEADER = "x-upload-display-name"
@router.post("/upload")
def upload_download(
file: UploadFile = File(...),
display_name: str | None = Form(None),
@dataclass(frozen=True)
class SpooledUpload:
"""What the body dependency hands the handler: the bytes already on disk, plus the two names
that came alongside them."""
path: Path
size: int
user_id: int
filename: str
display_name: str | None
def _header_text(request: Request, name: str) -> str:
"""One percent-encoded header, decoded. A header that is not valid UTF-8 once unquoted is the
caller's doing, not a server fault — it degrades to empty rather than 500ing, and the route's
own fallback names the file."""
raw = request.headers.get(name, "")
try:
return unquote(raw, errors="strict").strip()
except (UnicodeDecodeError, ValueError):
# silent-ok: a mis-encoded header is the caller's own malformed request, and this one is
# cosmetic — it names the file. Refusing the upload over it would fail a 500 MiB transfer on
# a label; logging it would hand any client a free log line per request.
return ""
async def spool_upload(
request: Request,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
"""Ingest a local file into the caller's library (E7 S5). Any file type is accepted; media
(video/audio) gets the full function set, a generic file is download-only. The file is streamed
to a temp path under the download volume (enforcing the per-file cap as it goes), then handed to
the non-yt-dlp ingest which probes + places it and returns an already-`done` job.
) -> AsyncIterator[SpooledUpload]:
"""Stream the request body to a staging temp file, enforcing the per-file cap as it arrives.
A sync (threadpool) handler on purpose: the ingest does blocking work — an ffprobe of up to 30s
plus sync DB writes — so it must NOT run on the event loop. `file.file` is the sync file object
Starlette spools the multipart body into."""
`user` is taken but unused: it is the whole point. Dependencies resolve in declaration order, so
naming `require_human` here makes the auth check a precondition of reading the body at all — a
stranger is refused before a byte moves. The `yield` shape owns the temp file's life: ingest
MOVES it into place on success (the unlink is then a no-op), and on any other exit — cap hit,
empty, quota, probe or move error, a client that vanished mid-upload — it is removed."""
cap = sysconfig.get_int(db, "download_upload_max_bytes")
user_id = user.id
# Hand the pooled connection back BEFORE the transfer starts — see `db.release_read_connection`
# for why, and for the precondition it enforces. Everything this dependency still needs is
# already an int or a header in hand: touching an ORM attribute below would reload `user` and
# check a connection straight back out.
release_read_connection(db)
staging = Path(settings.download_root) / ".staging" / "uploads"
staging.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(staging))
tmp_path = Path(tmp)
try:
size = 0
with os.fdopen(fd, "wb") as out:
while chunk := file.file.read(_UPLOAD_CHUNK):
size += len(chunk)
if cap and size > cap:
raise HTTPException(
status_code=422, detail={"code": "upload_too_large", "limit": cap}
)
out.write(chunk)
if size == 0:
raise HTTPException(status_code=400, detail={"code": "upload_empty"})
try:
job = uploadmod.ingest_upload(
db,
user.id,
filename=file.filename or "upload",
src_path=tmp_path,
size=size,
display_name=(display_name or "").strip() or None,
size = await capped_stream_to(
request,
out,
cap,
HTTPException(status_code=422, detail={"code": "upload_too_large", "limit": cap}),
)
except quota.QuotaExceeded as e:
raise HTTPException(status_code=422, detail=_quota_detail(e))
return _serialize_job(db, job)
yield SpooledUpload(
path=tmp_path,
size=size,
user_id=user_id,
filename=_header_text(request, UPLOAD_FILENAME_HEADER) or "upload",
display_name=display_text(_header_text(request, UPLOAD_DISPLAY_NAME_HEADER)),
)
finally:
# Ingest MOVES the temp file into place on success (unlink is then a no-op); on any early exit
# — cap hit, empty, quota, probe/move error — this removes the abandoned staging file.
tmp_path.unlink(missing_ok=True)
@router.post("/upload")
def upload_download(
upload: SpooledUpload = Depends(spool_upload),
db: Session = Depends(get_db),
) -> dict:
"""Ingest a local file into the caller's library (E7 S5). Any file type is accepted; media
(video/audio) gets the full function set, a generic file is download-only. The body IS the file
(see the header note above); `spool_upload` has already authenticated the caller and streamed it
to a temp path under the download volume, so what is left here is the non-yt-dlp ingest which
probes + places it and returns an already-`done` job.
A sync (threadpool) handler on purpose: the ingest does blocking work — an ffprobe of up to 30s
plus sync DB writes — so it must NOT run on the event loop. The streaming half is async because
reading the body is; keeping them in separate callables is what lets each run where it belongs."""
if upload.size == 0:
raise HTTPException(status_code=400, detail={"code": "upload_empty"})
try:
job = uploadmod.ingest_upload(
db,
upload.user_id,
filename=upload.filename,
src_path=upload.path,
size=upload.size,
display_name=upload.display_name,
)
except quota.QuotaExceeded as e:
raise HTTPException(status_code=422, detail=_quota_detail(e))
return _serialize_job(db, job)
class EnqueueEditIn(BaseModel):
source_job_id: int = 0
edit_spec: dict
@@ -480,7 +541,7 @@ def enqueue_edit(
if source_job.status != "done" or source_job.asset_id is None:
raise refused(409, "edit_unfinished")
spec = body.edit_spec
display_name = (body.display_name or "").strip() or None
display_name = display_text(body.display_name)
try:
job = service.enqueue_edit(db, user.id, source_job, spec, display_name)
except quota.QuotaExceeded as e:
@@ -516,7 +577,20 @@ def list_downloads(
@router.get("/usage")
def my_usage(user: User = Depends(require_human), db: Session = Depends(get_db)) -> dict:
return quota.usage(db, user.id)
"""The caller's download quota, plus the per-file upload cap.
The cap rides here rather than in `quota.usage` because it is not a quota value — it is a
`sysconfig` knob about one route — and this is the route layer, where composing the two is the
job. The upload dialog needs it to refuse an over-cap file BEFORE sending it: both server-side
caps answer too late to be pleasant (the edge ceiling refuses the request outright, see
`bodylimit.ROUTE_MAX_BODY_BYTES`), and making the user push 600 MB up the wire to be told the
limit is 500 is a bad answer even when it is a correct one. It is a convenience, not the
boundary: the caps stay where they are and still refuse a client that ignores this.
"""
return {
**quota.usage(db, user.id),
"upload_max_bytes": sysconfig.get_int(db, "download_upload_max_bytes"),
}
# Priority when a source has several jobs (e.g. a done one plus a fresh queued one).
@@ -611,9 +685,9 @@ def update_download_meta(
job = owned_or_404(db, DownloadJob, job_id, user, what="download")
sent = body.model_fields_set
if "display_name" in sent:
job.display_name = ((body.display_name or "").strip())[:255] or None
job.display_name = display_text(body.display_name)
if "display_uploader" in sent:
job.display_uploader = ((body.display_uploader or "").strip())[:255] or None
job.display_uploader = display_text(body.display_uploader)
if "display_uploader_url" in sent:
job.display_uploader_url = _clean_url(body.display_uploader_url)
if "extra_links" in sent:
+128 -71
View File
@@ -4,9 +4,8 @@ mirror the user's own YouTube playlists in (`/sync-youtube`) and push/revert loc
out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, Field
from sqlalchemy import func, select, and_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, aliased
from app import quota
@@ -15,14 +14,18 @@ from app.db import get_db
from app.feedquery import feed_columns, serialize
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
from app.sync.playlists import (
PlaylistGone,
lock_playlists,
merge_playlist,
plan_merge,
recompute_dirty,
pull_user_playlists,
repull_playlist,
sync_user_playlists,
sync_claim,
user_sync_claim,
)
from app.ratelimit import RateLimiter
from app.routes._common import get_or_404, owned_or_404, youtube_refusal
from app.routes._common import get_or_404, not_found, owned_or_404, youtube_refusal
from app.youtube.client import YouTubeClient, YouTubeError
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
@@ -79,7 +82,10 @@ def _next_playlist_position(db: Session, user_id: int) -> int:
def _next_item_position(db: Session, playlist_id: int) -> int:
"""Append position for a new item at the end of a playlist."""
"""Append position for a new item at the end of a playlist. Locks the playlist row first: two
adds (two tabs, the rail and the page) otherwise both read the same max and land on one
position, and the list's order between them is then arbitrary."""
lock_playlists(db, playlist_id)
return (
db.scalar(
select(func.coalesce(func.max(PlaylistItem.position), -1)).where(
@@ -92,7 +98,10 @@ def _next_item_position(db: Session, playlist_id: int) -> int:
def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool:
"""Append a video to a playlist if not already present (idempotent). Returns whether it
was added. `mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later)."""
was added. `mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later).
Locked BEFORE the presence check: two adds of one video both saw "absent" and the second one's
insert hit uq_playlist_video as a 500."""
lock_playlists(db, pl.id)
exists = db.scalar(
select(PlaylistItem).where(
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
@@ -113,6 +122,7 @@ def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) ->
def _remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool:
"""Remove a video from a playlist if present (idempotent). Returns whether it was removed.
`mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later)."""
lock_playlists(db, pl.id)
item = db.scalar(
select(PlaylistItem).where(
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
@@ -321,13 +331,18 @@ def sync_youtube(
Also runs automatically on the scheduler; this is the manual 'sync now'."""
if not has_read_scope(user):
raise _refused(403, "youtube_read_scope")
_check_pull_cooldown(user)
try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
return {"ok": True, **sync_user_playlists(db, user)}
except YouTubeError as exc:
db.rollback()
raise youtube_refusal(exc)
with user_sync_claim(db, user.id) as free:
if not free:
# Another run (the scheduler, another tab) is pulling right now: "0 synced" would lie.
raise _refused(409, "sync_all_busy")
# After the claim, like the re-pull's: a refused attempt must not spend one of the pulls.
_check_pull_cooldown(user)
try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
return {"ok": True, **pull_user_playlists(db, user)}
except YouTubeError as exc:
db.rollback()
raise youtube_refusal(exc)
@router.post("/{playlist_id}/revert-youtube")
@@ -344,13 +359,20 @@ def revert_youtube(
raise _refused(400, "not_on_youtube")
if not has_read_scope(user):
raise _refused(403, "youtube_read_scope")
_check_pull_cooldown(user)
try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
return {"ok": True, **repull_playlist(db, user, pl)}
except YouTubeError as exc:
db.rollback()
raise youtube_refusal(exc)
with sync_claim(db, pl.id) as free:
if not free:
raise _refused(409, "sync_busy")
# The cooldown is spent only by a pull that runs: checked after the claim, so clicking
# Revert while a push is in flight does not use up the three pulls per ten minutes.
_check_pull_cooldown(user)
try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
return {"ok": True, **repull_playlist(db, user, pl)}
except PlaylistGone:
raise not_found("playlist")
except YouTubeError as exc:
db.rollback()
raise youtube_refusal(exc)
def _pushable(db: Session, user: User, playlist_id: int) -> Playlist:
@@ -392,25 +414,32 @@ def push(
neither side loses an item). Refuses if the estimated quota exceeds what's left today, so a
big sync can't strand half-applied. Write scope required (require_write_scope)."""
pl = _pushable(db, user, playlist_id)
try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
# Read the live playlist ONCE (linked playlists only) and share it with both the
# plan and the merge, halving read-quota and closing the second read→write TOCTOU
# window. A fresh export (no yt_playlist_id) reads nothing.
live = None
if pl.yt_playlist_id:
with YouTubeClient(db, user) as yt:
live = (
list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)),
yt.get_playlist_snippet(pl.yt_playlist_id),
)
plan = plan_merge(db, user, pl, live=live)
if plan["units_estimate"] > quota.remaining_today(db):
raise _refused(429, "quota_exceeded")
return {"ok": True, **merge_playlist(db, user, pl, live=live)}
except YouTubeError as exc:
db.rollback()
raise youtube_refusal(exc)
# One sync of a list at a time, across tabs, devices and the scheduler (`sync_claim`): the
# client's own in-flight flag only covers the menus of one page.
with sync_claim(db, pl.id) as free:
if not free:
raise _refused(409, "sync_busy")
try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
# Read the live playlist ONCE (linked playlists only) and share it with both the
# plan and the merge, halving read-quota and closing the second read→write TOCTOU
# window. A fresh export (no yt_playlist_id) reads nothing.
live = None
if pl.yt_playlist_id:
with YouTubeClient(db, user) as yt:
live = (
list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)),
yt.get_playlist_snippet(pl.yt_playlist_id),
)
plan = plan_merge(db, user, pl, live=live)
if plan["units_estimate"] > quota.remaining_today(db):
raise _refused(429, "quota_exceeded")
return {"ok": True, **merge_playlist(db, user, pl, live=live)}
except PlaylistGone:
raise not_found("playlist")
except YouTubeError as exc:
db.rollback()
raise youtube_refusal(exc)
@router.get("/{playlist_id}")
@@ -427,7 +456,8 @@ def get_playlist(
.join(Channel, Channel.id == Video.channel_id)
.outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id))
.where(PlaylistItem.playlist_id == pl.id)
.order_by(PlaylistItem.position)
# `id` breaks a position tie the same way on every read (the renumbering add does too).
.order_by(PlaylistItem.position, PlaylistItem.id)
).all()
return {
"id": pl.id,
@@ -451,7 +481,8 @@ def rename_playlist(
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
# Locked: the new name moves `dirty`, which the scheduled mirror refresh reads under this lock.
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist", lock=True)
if "name" in body.model_fields_set:
name = (body.name or "").strip()
if not name:
@@ -491,33 +522,65 @@ def delete_playlist(
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
if pl.kind == "watch_later":
raise _refused(400, "watch_later_undeletable")
if on_youtube:
if not pl.yt_playlist_id:
raise _refused(400, "not_on_youtube")
if not has_write_scope(user):
raise _refused(403, "youtube_editing_off")
try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_DELETE):
with YouTubeClient(db, user) as yt:
yt.delete_playlist(pl.yt_playlist_id)
except YouTubeError as exc:
raise youtube_refusal(exc)
db.delete(pl) # items cascade
db.commit()
# Not in the middle of a sync of it (`sync_claim`): a push that read the list before the delete
# and wrote YouTube after it planned from no items at all, so it emptied the YouTube playlist a
# local-only delete means to keep. The push checks for a vanished list too (`PlaylistGone`);
# this refuses the delete while a sync runs rather than race it.
with sync_claim(db, pl.id) as free:
if not free:
raise _refused(409, "sync_busy")
if on_youtube:
if not pl.yt_playlist_id:
raise _refused(400, "not_on_youtube")
if not has_write_scope(user):
raise _refused(403, "youtube_editing_off")
try:
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_DELETE):
with YouTubeClient(db, user) as yt:
yt.delete_playlist(pl.yt_playlist_id)
except YouTubeError as exc:
raise youtube_refusal(exc)
# The playlist first, as every item write: the cascade deletes the items before the list row.
lock_playlists(db, pl.id)
db.delete(pl) # items cascade
db.commit()
return {"deleted": playlist_id}
class AddItemIn(VideoRefIn):
# Where the video lands among the list's items, in their stored order (0 = first). Omitted, it
# is appended. The list page's "Undo" of a removal puts the row back where it was with this —
# ONE request, because append-then-reorder leaves it at the end whenever the second call fails.
position: int | None = Field(default=None, ge=0)
@router.post("/{playlist_id}/items")
def add_item(
playlist_id: int,
body: VideoRefIn,
body: AddItemIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
video_id = body.video_id
get_or_404(db, Video, video_id, what="video")
_add_item(db, pl, video_id, mark_dirty=True)
added = _add_item(db, pl, video_id, mark_dirty=False)
if added and body.position is not None:
# Renumber the whole list with the new row at `position` (clamped to the end): positions
# can have gaps after removals, so "index N" is only meaningful over the ordered rows.
rows = list(
db.execute(
select(PlaylistItem)
.where(PlaylistItem.playlist_id == pl.id)
.order_by(PlaylistItem.position, PlaylistItem.id)
).scalars()
)
new = next(r for r in rows if r.video_id == video_id)
rows.remove(new)
rows.insert(min(body.position, len(rows)), new)
for pos, row in enumerate(rows):
row.position = pos
recompute_dirty(db, pl)
db.commit()
return {"ok": True, "playlist_id": pl.id, "video_id": video_id}
@@ -562,6 +625,8 @@ def move_item(
raise HTTPException(
status_code=400, detail={"code": "playlist_move", "reason": "same_playlist"}
)
# Both lists, in id order: a move A→B beside a move B→A would otherwise lock crosswise.
lock_playlists(db, src.id, dst.id)
item = db.scalar(
select(PlaylistItem).where(
PlaylistItem.playlist_id == src.id, PlaylistItem.video_id == video_id
@@ -581,20 +646,11 @@ def move_item(
if already is None:
# Re-parent the existing row rather than delete+insert: `added_at` ("when I saved this")
# survives the move, and there is no window where the video belongs to neither list. The
# check above is not a lock: a second tab's add or the scheduler's YouTube merge can insert
# the same (playlist, video) in between, and uq_playlist_video then refuses the re-parent.
# That is the already-present outcome arriving late, not a failure — answer it the same way
# instead of a raw 500.
try:
with db.begin_nested():
item.playlist_id = dst.id
item.position = _next_item_position(db, dst.id)
db.flush()
dst_changed = True
# silent-ok: the conflict IS the answer — the target already holds the video, which is the
# idempotent outcome; the savepoint put the row back in the source list, dropped below.
except IntegrityError:
pass
# check above holds: both lists are locked, and an insert into the target (another tab's
# add, a YouTube merge) waits for the lock — its foreign key needs the target row.
item.playlist_id = dst.id
item.position = _next_item_position(db, dst.id)
dst_changed = True
if not dst_changed:
db.delete(item)
# The source always changed; the target only when the row landed in it. `_touch` on a target
@@ -619,7 +675,8 @@ def reorder_items(
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
# Locked: a concurrent add between reading the items and writing the order would collide.
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist", lock=True)
order = body.video_ids
items = {
it.video_id: it
+94 -17
View File
@@ -3,8 +3,10 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import delete, func, select, update
from typing import Annotated
from pydantic import BaseModel, Field, model_validator
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app.auth import current_user
@@ -217,7 +219,8 @@ def playlist_add_item(
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
# Locked (like every add / reorder here): two adds otherwise read the same max position.
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
it = by_rating_key(db, PlexItem, body.item_rating_key)
if it is None:
raise not_found("item")
@@ -249,7 +252,9 @@ def playlist_remove_item(
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
# Locked like the adds and the reorder: the list row first, then its items — a remove that
# locked an item row before a reorder locked the list deadlocked against it.
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
it = by_rating_key(db, PlexItem, item_rating_key)
if it is not None:
db.execute(
@@ -285,16 +290,75 @@ def _items_by_rks(db: Session, rks: list) -> list[PlexItem]:
return out
class PlexItemAddBulkIn(PlexItemKeysIn):
# Where each key goes back (E28 S3e2, the list page's "Undo" of a removal): an index over the
# ORDERED rows, one per key. The two-call shape (append, then send the whole order) leaves the rows
# at the end whenever the second call fails, and the order it sends would overwrite anything
# another tab changed in between.
positions: list[Annotated[int, Field(ge=0)]] | None = None
@model_validator(mode="after")
def _one_position_per_key(self):
if self.positions is not None and len(self.positions) != len(self.item_rating_keys):
raise ValueError("positions must pair one-to-one with item_rating_keys")
return self
def _insert_at(db: Session, pid: int, placed: list[tuple[int, PlexItem]]) -> int:
"""Insert new rows at their indices (ascending, so each index is where the row stood when the
rows before it were back), renumber the whole list 0..n-1, and say how many were added. An item
already in the list does not move: the add stays idempotent."""
rows = list(
db.execute(
select(PlexPlaylistItem)
.where(PlexPlaylistItem.playlist_id == pid)
.order_by(PlexPlaylistItem.position, PlexPlaylistItem.id)
).scalars()
)
present = {r.item_id for r in rows}
added = 0
for at, it in sorted(placed, key=lambda p: p[0]):
if it.id in present:
continue
row = PlexPlaylistItem(playlist_id=pid, item_id=it.id, position=0)
db.add(row)
rows.insert(min(at, len(rows)), row)
present.add(it.id)
added += 1
for i, r in enumerate(rows):
r.position = i
return added
@router.post("/playlists/{pid}/items/bulk")
def playlist_add_bulk(
pid: int,
body: PlexItemKeysIn,
body: PlexItemAddBulkIn,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Append many items at once (a whole season/show). Already-present items are skipped; the input
order is preserved for the newly-added ones. Returns how many were added + the new total."""
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
order is preserved for the newly-added ones. With `positions`, each key is put back at its index
instead of appended. Returns how many were added + the new total."""
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
if body.positions is not None:
by_rk = {it.rating_key: it for it in _items_by_rks(db, body.item_rating_keys)}
placed = [
(at, by_rk[str(rk)])
for rk, at in zip(body.item_rating_keys, body.positions, strict=True)
if str(rk) in by_rk
]
added = _insert_at(db, pid, placed)
if added:
pl.updated_at = datetime.now(timezone.utc)
db.commit()
total = (
db.scalar(
select(func.count(PlexPlaylistItem.id)).where(PlexPlaylistItem.playlist_id == pid)
)
or 0
)
return {"ok": True, "added": added, "item_count": total}
items = _items_by_rks(db, body.item_rating_keys)
existing = set(
db.execute(
@@ -337,7 +401,7 @@ def playlist_remove_bulk(
) -> dict:
"""Remove many items at once (a whole season/show, or a multi-select). Returns how many were
removed + the new total."""
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
ids = [it.id for it in _items_by_rks(db, body.item_rating_keys)]
removed = 0
if ids:
@@ -367,7 +431,7 @@ def reorder_playlist(
db: Session = Depends(get_db),
) -> dict:
"""Set the playlist order from an explicit list of item rating_keys (0-based positions)."""
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
order = body.item_rating_keys
id_by_rk = {
it.rating_key: it.id
@@ -375,14 +439,27 @@ def reorder_playlist(
select(PlexItem).where(PlexItem.rating_key.in_(order or [""]))
).scalars()
}
for pos, rk in enumerate(order):
iid = id_by_rk.get(rk)
if iid is not None:
db.execute(
update(PlexPlaylistItem)
.where(PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id == iid)
.values(position=pos)
)
rows = {
r.item_id: r
for r in db.execute(
select(PlexPlaylistItem).where(PlexPlaylistItem.playlist_id == pid)
).scalars()
}
pos = 0
seen: set[int] = set()
for rk in order:
row = rows.get(id_by_rk.get(rk, -1))
if row is not None and row.item_id not in seen:
row.position = pos
pos += 1
seen.add(row.item_id)
# Rows the payload omitted (added in another tab meanwhile) keep their relative order after
# the given ones, so no two rows share a position (the YouTube reorder does the same).
for row in sorted(
(r for iid, r in rows.items() if iid not in seen), key=lambda r: (r.position, r.id)
):
row.position = pos
pos += 1
pl.updated_at = datetime.now(timezone.utc)
db.commit()
return {"ok": True}
+1 -1
View File
@@ -130,7 +130,7 @@ def _ingest_candidates(
try:
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER):
details = yt.get_channels(new_channel_ids)
apply_channel_details(db, details)
apply_channel_details(db, details, new_channel_ids)
db.commit()
except YouTubeError:
# The stubs remain and the details can be backfilled later — but until they are, those
+185 -31
View File
@@ -8,13 +8,15 @@ ingested (with a stub channel) so the mirror is faithful."""
import hashlib
import json
from collections.abc import Callable
from collections.abc import Callable, Iterator
from contextlib import AbstractContextManager, contextmanager
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import TypeVar
from sqlalchemy import delete, select
from sqlalchemy import Engine, create_engine, delete, func, select
from sqlalchemy.orm import Session
from sqlalchemy.pool import NullPool
from app import logs, progress, quota
from app.auth import has_read_scope
@@ -27,6 +29,87 @@ log = logs.get_logger(__name__)
# Cost of a single YouTube write op (playlists/playlistItems insert/update/delete).
WRITE_UNIT_COST = 50
# The advisory-lock namespaces ("PL" per playlist, "PU" per user), so a key cannot meet another
# feature's lock on the same number (Postgres keeps the two-int keys apart from the one-bigint ones).
_SYNC_LOCK_NS = 0x504C
_USER_SYNC_LOCK_NS = 0x5055
# The claims' own connections, outside the app pool: a claim is held for a whole push (seconds of
# YouTube calls) beside the request's pooled session and quota's, so a burst of pushes drawing a
# third pooled connection each ran the pool dry and stalled every other request for pool_timeout.
# NullPool opens one per claim and closes it after, so a lock can never ride back into a pool either.
_claim_engines: dict[str, Engine] = {}
def _claim_engine(db: Session) -> Engine:
url = db.get_bind().engine.url
key = url.render_as_string(hide_password=False)
eng = _claim_engines.get(key)
if eng is None:
eng = _claim_engines[key] = create_engine(url, poolclass=NullPool, hide_parameters=True)
return eng
@contextmanager
def _advisory_claim(db: Session, ns: int, key: int) -> Iterator[bool]:
"""Try a session-level advisory lock for the block on a connection of its own; yields whether it
was free. It survives the block's commits, and a crashed worker's lock dies with its connection
— no claim column that a crash leaves stuck."""
conn = _claim_engine(db).connect()
held = False
try:
held = bool(conn.scalar(select(func.pg_try_advisory_lock(ns, key))))
conn.commit()
yield held
finally:
try:
if held:
conn.scalar(select(func.pg_advisory_unlock(ns, key)))
conn.commit()
finally:
conn.close()
def sync_claim(db: Session, playlist_id: int) -> AbstractContextManager[bool]:
"""Claim one playlist's YouTube sync (push, re-pull, the scheduled mirror refresh) for the block;
yields whether it was free. A push reads YouTube, then writes both sides for seconds and commits
step by step, so a row lock ends at its first commit and two tabs (or a push and the scheduler)
interleave into a doubled YouTube item or a baseline recorded over the other's writes."""
return _advisory_claim(db, _SYNC_LOCK_NS, playlist_id)
class PlaylistGone(Exception):
"""The list a sync was working on was deleted meanwhile (another tab, the mirror drop). Its
items went with it, so a plan made now reads "every item removed here" — and would delete them
all from the YouTube playlist the reader chose to keep. The route answers 404."""
def lock_playlists(db: Session, *playlist_ids: int) -> set[int]:
"""Row-lock playlists before touching their items, several in id order. Every item write takes
the PLAYLIST first — the route edits, a push, a re-pull, the scheduled mirror refresh and a
delete alike: a writer that locked an item row first and the playlist second (a push's item
replace beside a remove) deadlocks against one that went the other way, and the loser is a
500. The rows are re-read, so `dirty`, the name and the sync baseline are the ones a finished
writer committed. Held to the caller's next commit — so no commit may come between this and
the write it guards. Returns the ids it found: a list deleted meanwhile is not among them."""
rows = db.scalars(
select(Playlist)
.where(Playlist.id.in_(sorted(set(playlist_ids))))
.order_by(Playlist.id)
.with_for_update()
.execution_options(populate_existing=True)
).all() # consumed: rows fetched are what refreshes the loaded objects
return {pl.id for pl in rows}
def _lock_or_gone(db: Session, pl: Playlist) -> None:
"""`lock_playlists` for a sync that goes on to write YouTube or the items: a list deleted since
it was read raises `PlaylistGone` (rolled back) instead of being planned as an empty one."""
pl_id = pl.id # read first: the rollback expires `pl`, and its row is gone
if pl_id not in lock_playlists(db, pl_id):
db.rollback()
raise PlaylistGone(pl_id)
def fingerprint(name: str, ids: list[str]) -> str:
"""A stable checksum of a playlist's name + ordered video ids. Stored as the synced
@@ -91,11 +174,18 @@ def parse_unavailable(pl: Playlist) -> set[str]:
return _load_baseline(pl)[1]
def _set_baseline(pl: Playlist, ids: list[str], unavailable: list[str] | None = None) -> None:
def _set_baseline(
pl: Playlist,
ids: list[str],
unavailable: list[str] | None = None,
*,
name: str | None = None,
) -> None:
"""Record `ids` as both the hash (drives `dirty`, over the local ids only) and the raw ordered
set (drives merge). `unavailable` = un-ingestable-but-live YouTube ids to carry forward so the
next merge skips them; stored only when non-empty (keeps the common case a plain list)."""
pl.synced_fingerprint = fingerprint(pl.name, ids)
next merge skips them; stored only when non-empty (keeps the common case a plain list). `name`:
the title YouTube now has, when that may differ from the list's own (see the push)."""
pl.synced_fingerprint = fingerprint(pl.name if name is None else name, ids)
if unavailable:
pl.synced_video_ids = json.dumps({"ids": ids, "unavailable": list(unavailable)})
else:
@@ -103,6 +193,25 @@ def _set_baseline(pl: Playlist, ids: list[str], unavailable: list[str] | None =
pl.dirty = False
def _set_baseline_under_lock(
db: Session,
pl: Playlist,
ids: list[str],
unavailable: list[str] | None = None,
*,
name: str,
) -> None:
"""A push's closing baseline: `ids` and `name` are what YouTube now holds. The push spent
seconds on YouTube writes since it replaced the local items, so an edit may have landed
meanwhile — lock the list and derive `dirty` from what is there, or the edit would read as
synced and never reach YouTube. The name too: the lock re-reads a rename made meanwhile, and a
baseline taken over THAT name matched it, so the list read clean with YouTube's title still the
old one (E28 review round 4)."""
_lock_or_gone(db, pl)
_set_baseline(pl, ids, unavailable, name=name)
recompute_dirty(db, pl)
def recompute_dirty(db: Session, pl: Playlist) -> None:
"""Set `dirty` by comparing the current state to the synced baseline. Unlinked lists
(and Watch later) are never dirty; a missing baseline counts as dirty (unknown YT state)."""
@@ -159,15 +268,13 @@ def _ensure_videos(db: Session, yt: YouTubeClient, video_ids: list[str]) -> None
db.commit()
def _replace_items_from_youtube(
db: Session, yt: YouTubeClient, pl: Playlist, ids: list[str]
) -> list[str]:
def _replace_items_from_youtube(db: Session, pl: Playlist, ids: list[str]) -> list[str]:
"""Mirror a YouTube playlist's ordered video ids into `pl`'s local items (authoritative
replace): ensure the videos exist in the catalog, keep only those present in their YT
order (de-duplicated), swap the items out, and reset the synced fingerprint + dirty flag.
`pl.name` must already be set to the desired name. The caller commits. Returns the final
ordered ids."""
_ensure_videos(db, yt, ids)
replace): keep only those present in the catalog in their YT order (de-duplicated), swap the
items out, and reset the synced fingerprint + dirty flag. The caller has ingested `ids`
(`_ensure_videos`, which commits) and THEN taken `lock_playlists`, set `pl.name`, and commits
after: an ingest in here re-fetched every id YouTube will not return (a private or deleted
video) and its commit let go of the lock before the replace. Returns the final ordered ids."""
present = (
set(db.execute(select(Video.id).where(Video.id.in_(ids))).scalars().all()) if ids else set()
)
@@ -196,6 +303,21 @@ def sync_user_playlists(db: Session, user: User) -> dict:
the user's local playlists and the built-in Watch later untouched."""
if not has_read_scope(user):
return {"synced": 0, "reason": "no read scope"}
with user_sync_claim(db, user.id) as free:
if not free:
return {"synced": 0, "reason": "busy"}
return pull_user_playlists(db, user)
def user_sync_claim(db: Session, user_id: int) -> AbstractContextManager[bool]:
"""Claim a user's bulk mirror pull for the block; yields whether it was free. Two runs at once
(the scheduler beside a manual "sync now", two tabs) both saw a YouTube list unmirrored and
each created its mirror — a duplicate row."""
return _advisory_claim(db, _USER_SYNC_LOCK_NS, user_id)
def pull_user_playlists(db: Session, user: User) -> dict:
"""The mirror pull itself; the caller holds `user_sync_claim`."""
synced = 0
with YouTubeClient(db, user) as yt:
yt_playlists = list(yt.iter_my_playlists())
@@ -219,11 +341,15 @@ def sync_user_playlists(db: Session, user: User) -> dict:
.scalars()
.all()
)
# Drop mirrors whose YouTube playlist no longer exists.
for ytid, pl in existing.items():
if ytid not in yt_ids:
db.delete(pl)
db.flush()
# Drop mirrors whose YouTube playlist no longer exists — locked first, as every item write.
gone = [pl for ytid, pl in existing.items() if ytid not in yt_ids]
if gone:
lock_playlists(db, *(pl.id for pl in gone))
for pl in gone:
db.delete(pl)
# Committed now: a run where no list below commits (all dirty, or all claimed) would
# otherwise drop the deletions with the session.
db.commit()
for idx, p in enumerate(yt_playlists):
ytid = p.get("id")
@@ -245,12 +371,27 @@ def sync_user_playlists(db: Session, user: User) -> dict:
)
db.add(pl)
db.flush()
else:
with sync_claim(db, pl.id) as free:
if not free:
# The user is pushing or re-pulling this very list: it writes the same rows.
# The next scheduled run refreshes it.
continue
ids = list(yt.iter_my_playlist_video_ids(ytid))
# Ingest first — it commits — so the row lock below is held from the dirty check
# to the commit that replaces the items.
_ensure_videos(db, yt, ids)
# `dirty` read above is a snapshot from before the YouTube calls (sessions keep
# loaded rows past a commit): a remove or reorder made meanwhile must not be
# replaced by YouTube's order. Item writes lock the playlist row, so this waits for
# one in flight and sees what it committed.
lock_playlists(db, pl.id)
if pl.dirty:
db.rollback()
continue
pl.name = p.get("title") or pl.name
# Mirror is authoritative: replace the items to match YouTube's order.
ids = list(yt.iter_my_playlist_video_ids(ytid))
_replace_items_from_youtube(db, yt, pl, ids)
db.commit()
# Mirror is authoritative: replace the items to match YouTube's order.
_replace_items_from_youtube(db, pl, ids)
db.commit()
synced += 1
return {"synced": synced}
@@ -488,7 +629,8 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
breaker = _Breaker(playlist_id=pl.id)
with YouTubeClient(db, user) as yt:
if not pl.yt_playlist_id:
pl.yt_playlist_id = yt.create_playlist(pl.name)
pushed_name = pl.name
pl.yt_playlist_id = yt.create_playlist(pushed_name)
db.commit()
created = 1
# Brand-new playlist: just append every local video in order.
@@ -501,7 +643,7 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
if not isinstance(added, _Failed):
inserted += 1
if not failures:
_set_baseline(pl, local)
_set_baseline_under_lock(db, pl, local, name=pushed_name)
db.commit()
return {
"created": created,
@@ -542,6 +684,14 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
# merged order authoritatively. Do this first so local reflects the merge even if a later
# YouTube write trips the breaker.
_ensure_videos(db, yt, mp.add_to_local)
# `local` was read before the YouTube calls above: an add or remove committed meanwhile
# (another tab) would be undone by the replace below. Lock the list — the ingest's commit is
# behind us — and plan again from what is there now. Nothing new needs ingesting: a video
# the re-read adds is already a local row. A list deleted meanwhile stops here, before a plan
# over its now-empty items removes them all from YouTube.
_lock_or_gone(db, pl)
local = _local_video_ids(db, pl)
mp = merge_plan(parse_baseline(pl), local, yt_ids, effective_unavailable)
# Only the ids that actually exist in the catalog can become local rows (a private/deleted
# YouTube video won't ingest); keep the merged order, dropping any that didn't materialize.
present = (
@@ -555,10 +705,12 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
_set_local_items(db, pl, final_local)
db.commit()
# --- YouTube side (metered, breaker-guarded).
if snippet is not None and (snippet.get("title") or "") != pl.name:
# --- YouTube side (metered, breaker-guarded). The name as of the lock above: the closing
# baseline records THIS one, whatever a rename made during the writes below says.
pushed_name = pl.name
if snippet is not None and (snippet.get("title") or "") != pushed_name:
try:
yt.update_playlist_title(pl.yt_playlist_id, pl.name)
yt.update_playlist_title(pl.yt_playlist_id, pushed_name)
except YouTubeError as exc:
# All the caller passes on is the word "title", which says the rename did not
# happen but not that YouTube refused it, or why.
@@ -626,7 +778,7 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
(effective_unavailable | {v for v in mp.add_to_local if v not in present})
& yt_id_set
)
_set_baseline(pl, final_local, unavailable)
_set_baseline_under_lock(db, pl, final_local, unavailable, name=pushed_name)
db.commit()
return {
"created": created,
@@ -652,9 +804,11 @@ def repull_playlist(db: Session, user: User, pl: Playlist) -> dict:
snippet = yt.get_playlist_snippet(pl.yt_playlist_id)
if snippet is None:
raise YouTubeError("Playlist no longer exists on YouTube")
pl.name = snippet.get("title") or pl.name
ids = list(yt.iter_my_playlist_video_ids(pl.yt_playlist_id))
ordered = _replace_items_from_youtube(db, yt, pl, ids)
_ensure_videos(db, yt, ids)
_lock_or_gone(db, pl)
pl.name = snippet.get("title") or pl.name
ordered = _replace_items_from_youtube(db, pl, ids)
db.commit()
return {"items": len(ordered)}
+39 -8
View File
@@ -1,8 +1,9 @@
"""Import the authenticated user's YouTube subscriptions and channel metadata."""
from datetime import datetime, timezone
from collections.abc import Iterable
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app import logs
@@ -47,8 +48,37 @@ def _external_links(branding: dict) -> list | None:
return out or None
def apply_channel_details(db: Session, items: list[dict]) -> None:
# How old a channel's metadata may get before it is fetched again. Banners, avatars, descriptions and
# counts change on YouTube, and an old banner URL stops resolving at all — without a refresh a channel
# kept whatever it had the day it was first seen. A week keeps counts roughly current at almost no
# cost: `channels.list` is 1 quota unit per 50 channels, and only the stale ones are asked for.
DETAILS_MAX_AGE = timedelta(days=7)
def details_stale(channel: Channel, now: datetime) -> bool:
"""Whether `channel`'s metadata is missing or older than DETAILS_MAX_AGE (see `details_stale_clause`)."""
synced = channel.details_synced_at
return synced is None or synced < now - DETAILS_MAX_AGE
def details_stale_clause(now: datetime):
"""`details_stale` as a WHERE clause, so the batch import and a single channel agree on "stale"."""
return or_(
Channel.details_synced_at.is_(None),
Channel.details_synced_at < now - DETAILS_MAX_AGE,
)
def apply_channel_details(db: Session, items: list[dict], asked: Iterable[str]) -> None:
"""Write `channels.list`'s answer onto the rows. `asked` is every id the call asked for: one
YouTube did not return (terminated, deleted) is stamped as synced too — otherwise it stays
stale forever and every view / import asks for it again, spending quota on a known miss."""
now = datetime.now(timezone.utc)
returned = {item["id"] for item in items}
for cid in set(asked) - returned:
missing = db.get(Channel, cid)
if missing is not None:
missing.details_synced_at = now
for item in items:
channel = db.get(Channel, item["id"])
if channel is None:
@@ -92,7 +122,7 @@ def should_prune(fetched_count: int, existing_count: int) -> bool:
def import_subscriptions(db: Session, user: User) -> dict:
"""Pull all subscriptions for `user`, upsert channels + subscription links, prune
channels the user has unsubscribed from on YouTube, and fetch channel details for
any channel we don't have details for yet."""
any channel whose details are missing or stale (`DETAILS_MAX_AGE`)."""
fetched: dict[str, dict] = {}
new_channels = 0
@@ -145,12 +175,12 @@ def import_subscriptions(db: Session, user: User) -> dict:
removed += 1
db.commit()
# Fetch details for channels that don't have them yet.
# Fetch details for channels that don't have them yet, or had them too long ago.
need_details = (
db.execute(
select(Channel).where(
Channel.id.in_(fetched.keys()),
Channel.details_synced_at.is_(None),
details_stale_clause(datetime.now(timezone.utc)),
)
)
.scalars()
@@ -158,8 +188,9 @@ def import_subscriptions(db: Session, user: User) -> dict:
)
detailed = 0
if need_details:
items = yt.get_channels([c.id for c in need_details])
apply_channel_details(db, items)
ids = [c.id for c in need_details]
items = yt.get_channels(ids)
apply_channel_details(db, items, ids)
detailed = len(items)
db.commit()
+10 -5
View File
@@ -137,13 +137,18 @@ SPECS: tuple[ConfigSpec, ...] = (
min=1,
max=100_000,
),
# min=1, NOT 0: the per-file cap is enforced as `if cap and size > cap`, so a stored 0 would read
# as falsy and SILENTLY disable the limit (an unbounded streamed write to the volume). Floor it at
# 1 so the cap is always active; the overall footprint is separately bounded by the byte quota.
# `max` is not cosmetic: `bodylimit.ROUTE_MAX_BODY_BYTES` refuses an upload body over roughly
# min=1, NOT 0: a stored 0 used to read as "no per-file cap" (the cap was enforced as
# `if cap and size > cap`, so a falsy value disabled it). Since P1 the route passes this number
# straight to `capped_stream_to`, where 0 would refuse every upload instead — the opposite
# failure, and the reason `config.download_upload_max_bytes` carries its own `ge=1`. Either way
# a stored 0 is wrong, so it is floored at 1; the overall footprint is separately bounded by the
# byte quota.
# `max` is not cosmetic: `bodylimit.ROUTE_MAX_BODY_BYTES` refuses an upload body over exactly
# this number at the edge, before the route's own check ever sees it. An admin allowed to raise
# the knob past the edge would get 413s from a layer the admin page never mentions, so the two
# are pinned together here and `tests/test_body_limit.py` holds them that way. `_coerce` also
# are pinned together here and `tests/test_body_limit.py` holds them that way. Below the edge is
# the useful direction and the one the knob exists for: a lowered knob is what makes the route's
# own 422 — the one carrying this figure — the answer the user gets. `_coerce` also
# REJECTS a stored value outside these bounds on read (falling back to the env default, with a
# warning) — neither bound has always been what it is now, so a legal-when-written override can
# be sitting in the database already.
+2 -2
View File
@@ -43,7 +43,7 @@ from app.downloads.jobdb import (
_staging_dir,
)
from app.downloads.status import STOPPED_BY_ROUTE, JobStatus
from app.models import DownloadJob, MediaAsset
from app.models import DISPLAY_TEXT_MAX, DownloadJob, MediaAsset
log = logs.get_logger(__name__)
_stop = threading.Event()
@@ -562,7 +562,7 @@ def _finish_from_cache(job_id: int, asset_id: int) -> None:
fields = {"status": JobStatus.DONE, "progress": 100, "phase": None}
if not job.display_name and asset.title:
# display_name is VARCHAR(255); a title can be longer (e.g. a recipe in the title).
fields["display_name"] = asset.title[:255]
fields["display_name"] = asset.title[:DISPLAY_TEXT_MAX]
asset.last_access_at = datetime.now(timezone.utc)
# Atomic finalize: only marks the job done while it is still running, so a concurrent
# cancel/pause is never resurrected (see service.finalize_if_running).
-1
View File
@@ -47,7 +47,6 @@ pydantic-settings==2.15.0
pydantic==2.13.4
pydantic_core==2.46.4
python-dotenv==1.2.3
python-multipart==0.0.32
requests==2.34.2
rich==15.0.0
starlette==1.6.0
-3
View File
@@ -1,7 +1,4 @@
fastapi>=0.115,<1.0
# Multipart form parsing — required by Starlette/FastAPI to accept file uploads (UploadFile/Form).
# The download center's local-file Upload (E7) is the only multipart endpoint in the app.
python-multipart>=0.0.9,<1.0
uvicorn[standard]>=0.30,<1.0
sqlalchemy>=2.0,<2.1
psycopg[binary]>=3.2,<4.0
+22
View File
@@ -15,6 +15,7 @@ tests that COMMIT (the whole point of this lane — transaction-ownership behavi
import os
import shutil
from urllib.parse import quote
import pytest
from alembic import command
@@ -248,6 +249,27 @@ def as_user(db, override_dep, _configured):
return _as
@pytest.fixture
def upload_headers():
"""Build the headers `POST /api/downloads/upload` takes since P1.
Shared, because two test modules drive that route and a second copy of this is exactly the drift
that lets the wire shape change in one place only. The body IS the file now; the names ride as
percent-encoded headers (`quote` mirrors the client's `encodeURIComponent`, since headers are
latin-1 and a Hungarian filename would not survive raw)."""
def _headers(filename: str, display_name: str | None = None) -> dict[str, str]:
headers = {
"content-type": "application/octet-stream",
"x-upload-filename": quote(filename),
}
if display_name is not None:
headers["x-upload-display-name"] = quote(display_name)
return headers
return _headers
@pytest.fixture(autouse=True)
def _reset_rate_limiters():
"""Every RateLimiter is a module-level singleton keyed by client IP, and TestClient always
+1 -1
View File
@@ -100,7 +100,7 @@ def test_the_single_playlist_repull_shares_the_pull_cooldown(db, as_user, make_u
db.commit()
monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True)
monkeypatch.setattr(plroutes, "repull_playlist", lambda d, u, p: {"ok": True})
monkeypatch.setattr(plroutes, "sync_user_playlists", lambda d, u: {"ok": True})
monkeypatch.setattr(plroutes, "pull_user_playlists", lambda d, u: {"ok": True})
c = as_user(user)
codes = [c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code for _ in range(4)]
+313 -30
View File
@@ -204,46 +204,271 @@ def test_the_default_ceiling_clears_the_largest_body_a_default_route_takes():
)
# Every `capped_body` call site in the routers, as (module, limit constant). A route that reads its
# own body declares the size it MEANS to accept; if that is above the global default and its route
# is not in ROUTE_MAX_BODY_BYTES, this middleware refuses a request the route would have taken —
# silently, in production, on a path nobody tested. The point of the list is the third entry.
# Every body-reading call site in the routers, as (module, function, limit expression). A route that
# reads its own body declares the size it MEANS to accept; if that is above the global default and
# its route is not in ROUTE_MAX_BODY_BYTES, this middleware refuses a request the route would have
# taken — silently, in production, on a path nobody tested. The point of the list is the third entry.
#
# BOTH helpers, not just `capped_body`: P1 added `capped_stream_to` as its sibling and the registry
# did not follow, so the upload route — the one this whole invariant was written for — was the one
# site the drift guard could not see (deep-review round 1). The names come from `_common.py` itself
# below, so a THIRD sibling cannot be added without this list noticing either — and a route that
# skips the helpers altogether and awaits `request.body()` lands here as `<no ceiling declared>`
# rather than vanishing (round 3).
#
# The module is the path relative to `app/routes/`, so a site under `plex/` is spelled
# `plex/admin.py` and cannot collide with the top-level `admin.py`.
CAPPED_BODY_SITES = {
("diagnostics.py", "BODY_MAX_BYTES"),
("debug_sessions.py", "debugsession.SCREENSHOT_MAX_BYTES"),
("diagnostics.py", "_parsed_events", "capped_body", "BODY_MAX_BYTES"),
("debug_sessions.py", "_image_body", "capped_body", "debugsession.SCREENSHOT_MAX_BYTES"),
("downloads.py", "spool_upload", "capped_stream_to", "cap"),
}
def test_every_capped_body_site_is_known_and_fits_under_its_ceiling():
"""Parsed, not grepped. A regex over the call text has to guess how the arguments are spelled,
and the shapes it guesses wrong — a bare `4 * 1024 * 1024`, a parameter not named `request` —
are the natural ways the NEXT site gets written, so a miss reads as "no new sites" rather than
as a failure. `ast` sees every call whatever it looks like."""
def _body_readers() -> set[str]:
"""The names of the body-reading helpers `_common.py` exports — asked of the module rather than
listed here, so adding a fourth one cannot quietly leave the registry below behind."""
common = pathlib.Path(__file__).resolve().parents[1] / "app" / "routes" / "_common.py"
tree = ast.parse(common.read_text("utf-8"), filename=str(common))
return {
node.name
for node in tree.body
if isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef))
and node.name.startswith("capped_")
}
def test_the_registry_covers_every_body_reading_helper():
"""The mutation guard for the tests below: if a helper were renamed or added, the site list
would still match itself while the new one went uncovered."""
assert _body_readers() == {"capped_body", "capped_stream_to"}, (
"routes/_common.py grew or renamed a body-reading helper — add its call sites to "
"CAPPED_BODY_SITES and this expectation."
)
# The ways a route can read its own body WITHOUT one of `_common.py`'s helpers. Every `Request`
# parameter under `app/routes/` is named `request`, so the base name is an exact filter and not a
# guess — the only other `.json()` in the package is `exc.json()` on a ValidationError.
#
# Without this set the three guards below know only the `capped_*` naming convention, which means
# they are blind to the shape `_common.py` itself is written in. A route spelling `await
# request.body()` inline declares no ceiling, hands no connection back, and would be invisible to
# all three — reading as "no new sites" rather than as a failure, which is the exact way a drift
# guard dies quietly (deep-review round 3).
RAW_BODY_READS = {"body", "stream", "form", "json"}
def _call_name(node: ast.Call) -> str | None:
if isinstance(node.func, ast.Attribute):
return node.func.attr
return getattr(node.func, "id", None)
def _body_read_calls(fn: ast.AST, readers: set[str]) -> list[tuple[ast.Call, str]]:
"""Every call inside `fn` that reads the request body, as (node, name) in source order."""
out: list[tuple[ast.Call, str]] = []
for node in ast.walk(fn):
if not isinstance(node, ast.Call):
continue
if (
isinstance(node.func, ast.Attribute)
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "request"
and node.func.attr in RAW_BODY_READS
):
out.append((node, f"request.{node.func.attr}"))
elif _call_name(node) in readers:
out.append((node, _call_name(node) or ""))
return sorted(out, key=lambda pair: (pair[0].lineno, pair[0].col_offset))
def _body_reading_functions() -> dict[tuple[str, str], dict]:
"""Every function under `app/routes/` that reads a request body itself, keyed by
(module, function).
Parsed, not grepped. A regex over the call text has to guess how the arguments are spelled, and
the shapes it guesses wrong — a bare `4 * 1024 * 1024`, a parameter not named `request` — are
the natural ways the NEXT site gets written, so a miss reads as "no new sites" rather than as a
failure. `ast` sees every call whatever it looks like.
Keyed by the ENCLOSING FUNCTION, not just the module: a set keyed by module and limit
expression collapses two call sites that spell their limit the same way, which is exactly what
a second route copied from the first would do.
The module half of the key is the path RELATIVE to `app/routes/`, not the basename: the scan
recurses, and `admin.py`, `discover.py` and `playlists.py` each exist both at the top level and
under `plex/`. Keying by basename would let the second of a colliding pair overwrite the first
and take its `releases` flag out of the comparison with it — the same entry-collapse this
registry was widened to close.
`_common.py` is skipped: it is where the helpers live, so its own `request.stream()` loops are
the implementation of the rule rather than a violation of it.
"""
readers = _body_readers()
routes_dir = pathlib.Path(__file__).resolve().parents[1] / "app" / "routes"
found: set[tuple[str, str]] = set()
for py in routes_dir.rglob("*.py"):
found: dict[tuple[str, str], dict] = {}
for py in sorted(routes_dir.rglob("*.py")):
if py.name == "_common.py":
continue
module = py.relative_to(routes_dir).as_posix()
tree = ast.parse(py.read_text("utf-8"), filename=str(py))
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
for fn in ast.walk(tree):
if not isinstance(fn, (ast.AsyncFunctionDef, ast.FunctionDef)):
continue
fn = node.func
name = fn.attr if isinstance(fn, ast.Attribute) else getattr(fn, "id", None)
if name != "capped_body":
calls = _body_read_calls(fn, readers)
if not calls:
continue
limit = next(
(kw.value for kw in node.keywords if kw.arg == "limit"),
node.args[1] if len(node.args) > 1 else None,
)
found.add((py.name, ast.unparse(limit) if limit is not None else "<none>"))
names = [name for _, name in calls]
limits = []
for node, name in zip([n for n, _ in calls], names):
if name.startswith("request."):
limits.append("<no ceiling declared>")
continue
limit = next((kw.value for kw in node.keywords if kw.arg == "limit"), None)
if limit is None:
pos = 2 if name == "capped_stream_to" else 1
limit = node.args[pos] if len(node.args) > pos else None
limits.append(ast.unparse(limit) if limit is not None else "<none>")
# POSITION, not presence. `any(...)` over the function accepted a release that ran
# AFTER the await it exists to precede — the natural spelling for someone reading the
# name as "release it when you're done" — while pinning a connection for the whole
# transfer. The first body read is what the release has to beat.
releases = [
node
for node in ast.walk(fn)
if isinstance(node, ast.Call) and _call_name(node) == "release_read_connection"
]
first_read = calls[0][0].lineno
found[(module, fn.name)] = {
"helpers": names,
"limits": limits,
"releases": any(node.lineno < first_read for node in releases),
"releases_late": bool(releases)
and not any(node.lineno < first_read for node in releases),
}
return found
def test_every_body_reading_site_is_known_and_fits_under_its_ceiling():
found = {
(module, fn, site["helpers"][i], site["limits"][i])
for (module, fn), site in _body_reading_functions().items()
for i in range(len(site["helpers"]))
}
assert found == CAPPED_BODY_SITES, (
f"a capped_body call site appeared, moved or changed shape: {found ^ CAPPED_BODY_SITES}. "
f"a body-reading call site appeared, moved or changed shape: {found ^ CAPPED_BODY_SITES}. "
"Each one declares a body size; check it against bodylimit.DEFAULT_MAX_BODY_BYTES and, if "
"it is larger, add its route to bodylimit.ROUTE_MAX_BODY_BYTES — then update this list."
)
# And the two known ones really do satisfy the rule.
# And the known ones really do satisfy the rule.
assert diagnostics_routes.BODY_MAX_BYTES <= DEFAULT_MAX_BODY_BYTES
assert debugsession.SCREENSHOT_MAX_BYTES > DEFAULT_MAX_BODY_BYTES
assert ("POST", "/api/debug-sessions/{sid}/screenshots") in bodylimit.ROUTE_MAX_BODY_BYTES
# The upload site's limit is the sysconfig knob, which is bounded BY the edge ceiling rather
# than being a constant to compare against — that pairing is its own test below.
assert ("POST", "/api/downloads/upload") in bodylimit.ROUTE_MAX_BODY_BYTES
def test_release_read_connection_ends_the_transaction_it_was_called_for(db):
"""The helper itself: a SELECT has autobegun, and afterwards it has not."""
from sqlalchemy import select
from app.db import release_read_connection
db.execute(select(SystemConfig).limit(1))
assert db.in_transaction(), "nothing autobegan, so this test would pass vacuously"
release_read_connection(db)
assert not db.in_transaction()
def test_release_read_connection_refuses_to_discard_pending_work(db):
"""The precondition, ENFORCED rather than described.
The session belongs to the whole request, not to the dependency calling this — so if something
ahead of it ever stages a write, ending the transaction would throw that write away with
nothing in the log. `service.enqueue` already learned that a whole-session rollback takes the
caller's pending work with it, which is why it uses a SAVEPOINT. A comment cannot stop the next
dependency from being added in the wrong place; this can.
"""
from app.db import release_read_connection
db.add(SystemConfig(key="probe_pending_write", value="1"))
with pytest.raises(RuntimeError, match="pending work"):
release_read_connection(db)
db.rollback() # the staged row is this test's own
def test_release_read_connection_refuses_to_discard_a_FLUSHED_write(db):
"""The half the unit-of-work check cannot see.
`db.new`/`dirty`/`deleted` empty out the moment a helper flushes, and CLAUDE.md's canonical
split is "the handler commits; helpers flush" — so the single most likely future violator is
the one shape the obvious check is blind to. Measured before the fix: all three sets empty, the
guard silent, the row gone after the rollback.
"""
from app.db import release_read_connection
db.add(SystemConfig(key="probe_flushed_write", value="1"))
db.flush()
assert not (db.new or db.dirty or db.deleted), (
"the flush did not clear the unit-of-work, so this test would pass for the wrong reason"
)
with pytest.raises(RuntimeError, match="already WRITTEN"):
release_read_connection(db)
db.rollback()
def test_release_read_connection_refuses_to_discard_a_core_dml_write(db):
"""The other half: `db.execute(update(...))` never enters the unit-of-work at all, and
`downloads/service.py` writes that way on purpose (a conditional `UPDATE … WHERE status=…` is
the honest-states pattern)."""
from sqlalchemy import insert
from app.db import release_read_connection
db.execute(insert(SystemConfig).values(key="probe_core_dml", value="1"))
assert not (db.new or db.dirty or db.deleted), (
"Core DML entered the unit-of-work, so this test would pass for the wrong reason"
)
with pytest.raises(RuntimeError, match="already WRITTEN"):
release_read_connection(db)
db.rollback()
def test_every_body_reading_site_hands_its_pooled_connection_back_first():
"""Reading the body yourself is what makes a request SLOW inside its own database session.
The auth (or ownership) dependency that must resolve first is a SELECT, so SQLAlchemy has
autobegun and the session is holding one of the pool's connections; awaiting the body then pins
it for as long as the client keeps sending — one connection per in-flight upload, `idle in
transaction`, with nothing to time it out. `db.release_read_connection` ends that transaction,
and every one of these sites needs it.
Held generically because the three sites arrived one at a time and only the newest was fixed:
a fourth body-reading route gets the same requirement automatically.
BEFORE the read, not merely somewhere in the function. A release that runs after the await
satisfies the letter of the rule and none of its purpose — the connection was held for exactly
the span the rule exists to protect — so `releases_late` is reported as its own, louder case.
"""
sites = _body_reading_functions()
late = [f"{module}:{fn}" for (module, fn), site in sites.items() if site["releases_late"]]
missing = [
f"{module}:{fn}"
for (module, fn), site in sites.items()
if not site["releases"] and not site["releases_late"]
]
assert not late, (
"a route calls db.release_read_connection AFTER it reads the body, which holds the pooled "
f"connection for exactly the transfer it was supposed to free: {late}. Move the call above "
"the await."
)
assert not missing, (
"a route reads its own request body while still inside its read transaction, pinning a "
f"pooled connection for the whole transfer: {missing}. Call "
"db.release_read_connection(db) before the await — see routes/downloads.py:spool_upload."
)
def test_the_upload_ceiling_and_the_admin_knob_cannot_drift_apart():
@@ -251,14 +476,72 @@ def test_the_upload_ceiling_and_the_admin_knob_cannot_drift_apart():
constant. If the knob could be set above the edge, an admin raising it would silently start
getting 413s from a layer the admin page never mentions.
The relation is `<=`, not `==`, and the gap is the point: the edge counts RAW body bytes while
the route counts the DECODED file, so the edge has to allow for the multipart envelope or a file
of exactly the advertised maximum is refused one layer before the route that would take it.
Since P1 the two count the SAME bytes — the body is the file, with no multipart envelope
wrapped round it — so the old allowance is gone and the edge is the knob's own maximum,
exactly. A gap reappearing means someone re-wrapped the upload in an envelope without saying so.
Equality also means the EDGE is what answers on a default install, where no override has lowered
the knob: the declared-length check refuses before a byte is read. That is not a gap to close
with headroom (measured: headroom only moves the boundary by its own size, and the route's drain
pushes the count back over it) — it is a refusal the client has to be able to read, which is why
`errors.tooLarge` exists and why the dialog checks the file against `upload_max_bytes` first.
"""
spec = next(s for s in sysconfig.SPECS if s.key == "download_upload_max_bytes")
edge = bodylimit.ROUTE_MAX_BODY_BYTES[("POST", "/api/downloads/upload")]
assert spec.max is not None and spec.max <= edge
assert edge - spec.max == bodylimit.MULTIPART_ENVELOPE_ALLOWANCE
assert spec.max == edge
def _route_objects(root):
"""`_declared_routes`' sibling, keeping the route OBJECT so its FastAPI dependant can be read."""
found: dict[tuple[str, str], object] = {}
stack = [root]
while stack:
node = stack.pop()
for route in getattr(node, "routes", ()) or ():
inner = getattr(route, "original_router", None)
if inner is not None:
stack.append(inner)
continue
for method in getattr(route, "methods", None) or ():
found[(method, route.path)] = route
return found
@pytest.mark.parametrize(("method", "template"), sorted(bodylimit.ROUTE_MAX_BODY_BYTES))
def test_no_route_with_a_raised_ceiling_declares_a_body_parameter(method, template):
"""P1's invariant, and the reason this is a test rather than a comment.
A raised ceiling is only honest when the route's own cap applies to the bytes AS THEY ARRIVE.
FastAPI reads and spools a DECLARED body (`= File(...)`, `= Form(...)`, a Pydantic model)
BEFORE the first dependency resolves — so on such a route this middleware's ceiling is the only
thing between an anonymous caller and the whole allowance, `require_human` never gets a say, and
the route's in-handler cap measures bytes that have already landed. That is exactly what P1
closed on the upload route, which sat in `ROUTE_MAX_BODY_BYTES` with a 500 MiB ceiling and a
`file: UploadFile = File(...)` body for one release.
Asked of FastAPI itself rather than parsed or grepped: the router already knows which parameters
it will read the body for, and a shape a regex or an AST walk guesses wrong is precisely how the
next route slips through.
`route.body_field`, NOT `route.dependant.body_params`, and the difference is the whole test.
`body_params` holds only the parameters of the route's OWN callable; `body_field` is built from
`_get_flat_body_params`, which recurses into sub-dependencies — and that is also what FastAPI
itself consults when it decides to read the body. Measured on the pinned FastAPI: a body declared
inside a sub-dependency leaves `body_params` EMPTY while the body is still read in full before
auth, so the first version of this test was green over a reproduction of the very defect it
exists to forbid (deep-review round 1). The upload route's own idiom — a body read from a
`yield` dependency — is exactly the shape that would have slipped through."""
from app.main import app
route = _route_objects(app).get((method, template))
assert route is not None, f"{method} {template} is not a route any more"
assert route.body_field is None, (
f"{method} {template} declares a body ({route.body_field.name}) on the route or in one of "
"its dependencies, so FastAPI reads it before the first dependency runs and the raised "
"ceiling is all that stands between an anonymous caller and the whole allowance. Read the "
"body yourself, from a dependency declared AFTER the auth one — see "
"routes/downloads.py:spool_upload."
)
def test_a_stored_override_outside_the_bounds_falls_back_on_READ(db):
@@ -0,0 +1,143 @@
"""A channel's metadata is fetched again once it is stale, not only the first time it is seen.
Banners, avatars, descriptions and counts change on YouTube, and an old banner URL stops resolving:
a channel that kept the metadata of the day it was first imported showed a dead banner forever. The
subscription import and the channel page both refresh what is older than `DETAILS_MAX_AGE`, and
leave fresh metadata alone (each refresh spends quota). DB lane.
"""
from datetime import datetime, timedelta, timezone
import pytest
from app.models import Channel, Subscription
from app.routes import channels
from app.sync import subscriptions
from app.sync.subscriptions import DETAILS_MAX_AGE, import_subscriptions
NOW = datetime.now(timezone.utc)
FRESH = NOW - timedelta(days=1)
STALE = NOW - DETAILS_MAX_AGE - timedelta(hours=1)
def _item(cid: str) -> dict:
return {
"id": cid,
"snippet": {"title": f"{cid} renamed", "publishedAt": "2010-01-01T00:00:00Z"},
"brandingSettings": {"image": {"bannerExternalUrl": f"https://yt3.example/{cid}-new"}},
}
def _youtube(asked: list[list[str]], subscribed: list[str], gone: tuple[str, ...] = ()):
class _Client:
def __init__(self, db, user):
pass
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def iter_subscriptions(self):
for cid in subscribed:
yield {"channel_id": cid, "title": cid, "yt_subscription_id": f"sub-{cid}"}
def get_channels(self, ids):
asked.append(sorted(ids))
# A terminated / deleted channel is simply absent from `channels.list`'s answer.
return [_item(cid) for cid in ids if cid not in gone]
return _Client
def _channel(db, cid: str, synced: datetime | None) -> None:
db.add(
Channel(
id=cid,
title=cid,
banner_url=f"https://yt3.example/{cid}-old",
published_at=datetime(2010, 1, 1, tzinfo=timezone.utc),
details_synced_at=synced,
)
)
def test_the_import_refreshes_stale_and_missing_details_and_leaves_fresh_ones(
db, make_user, monkeypatch
):
user = make_user()
_channel(db, "UCfresh", FRESH)
_channel(db, "UCstale", STALE)
_channel(db, "UCnever", None)
db.flush()
for cid in ("UCfresh", "UCstale", "UCnever"):
db.add(Subscription(user_id=user.id, channel_id=cid))
db.commit()
asked: list[list[str]] = []
monkeypatch.setattr(
subscriptions, "YouTubeClient", _youtube(asked, ["UCfresh", "UCstale", "UCnever"])
)
result = import_subscriptions(db, user)
assert asked == [["UCnever", "UCstale"]]
assert result["channels_detailed"] == 2
stale = db.get(Channel, "UCstale")
assert stale is not None
assert stale.banner_url == "https://yt3.example/UCstale-new"
assert stale.details_synced_at is not None and stale.details_synced_at > STALE
fresh = db.get(Channel, "UCfresh")
assert fresh is not None and fresh.banner_url == "https://yt3.example/UCfresh-old"
@pytest.mark.parametrize(("synced", "refreshed"), [(STALE, True), (FRESH, False)])
def test_the_channel_page_refreshes_a_stale_channel_only(
db, make_user, monkeypatch, synced, refreshed
):
user = make_user()
_channel(db, "UCpage", synced)
db.commit()
asked: list[list[str]] = []
monkeypatch.setattr(channels, "YouTubeClient", _youtube(asked, []))
body = channels.channel_detail("UCpage", user=user, db=db)
assert asked == ([["UCpage"]] if refreshed else [])
suffix = "new" if refreshed else "old"
assert body["banner_url"] == f"https://yt3.example/UCpage-{suffix}"
def test_a_channel_youtube_no_longer_returns_is_not_asked_for_on_every_view(
db, make_user, monkeypatch
):
"""A terminated channel is missing from `channels.list`'s answer, so nothing was stamped and the
channel page asked for it again (1 quota unit) on every single view. The miss is stamped like a
fetch, so the next ask waits for `DETAILS_MAX_AGE` like any other channel."""
user = make_user()
db.add(Channel(id="UCgone", title="UCgone"))
db.commit()
asked: list[list[str]] = []
monkeypatch.setattr(channels, "YouTubeClient", _youtube(asked, [], gone=("UCgone",)))
channels.channel_detail("UCgone", user=user, db=db)
channels.channel_detail("UCgone", user=user, db=db)
assert asked == [["UCgone"]]
def test_the_import_stamps_a_channel_youtube_no_longer_returns(db, make_user, monkeypatch):
user = make_user()
_channel(db, "UCgone", STALE)
db.flush()
db.add(Subscription(user_id=user.id, channel_id="UCgone"))
db.commit()
asked: list[list[str]] = []
monkeypatch.setattr(
subscriptions, "YouTubeClient", _youtube(asked, ["UCgone"], gone=("UCgone",))
)
import_subscriptions(db, user)
import_subscriptions(db, user)
assert asked == [["UCgone"]]
+105
View File
@@ -0,0 +1,105 @@
"""E28 S3e2 — adding a video AT a position: the list page's "Undo" of a removal.
The row has to come back where it was in ONE request. The two-call shape (append, then send the
whole order) leaves it at the end of the list whenever the second call fails, and the reorder would
also overwrite any change another tab made in between. Positions can have gaps after removals, so
the position is an index over the ORDERED rows, and the list is renumbered around it.
"""
from datetime import datetime, timezone
from app.models import Channel, Playlist, PlaylistItem, Video
def _seed(db, user, ids, *, positions=None, **kw):
db.add(Channel(id="UCtest", title="Test"))
now = datetime.now(timezone.utc)
for v in [*ids, "vX"]:
db.add(Video(id=v, channel_id="UCtest", published_at=now))
pl = Playlist(user_id=user.id, name="L", **kw)
db.add(pl)
db.flush()
for i, vid in enumerate(ids):
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=(positions or range(99))[i]))
db.commit()
return pl
def _ids(db, pl_id):
db.expire_all()
return [
r.video_id
for r in db.execute(
PlaylistItem.__table__.select()
.where(PlaylistItem.playlist_id == pl_id)
.order_by(PlaylistItem.position)
).all()
]
def _add(client, pl_id, body):
return client.post(f"/api/playlists/{pl_id}/items", json=body)
def test_a_position_puts_the_video_back_in_its_place(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a", "b", "c"])
r = _add(as_user(user), pl.id, {"video_id": "vX", "position": 1})
assert r.status_code == 200
assert _ids(db, pl.id) == ["a", "vX", "b", "c"]
def test_the_position_counts_rows_not_stored_numbers(db, make_user, as_user):
# A removal leaves a gap (0, 5, 9): index 2 is before the THIRD row, not "position 2".
user = make_user()
pl = _seed(db, user, ["a", "b", "c"], positions=[0, 5, 9])
_add(as_user(user), pl.id, {"video_id": "vX", "position": 2})
assert _ids(db, pl.id) == ["a", "b", "vX", "c"]
def test_a_position_past_the_end_appends(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a", "b"])
_add(as_user(user), pl.id, {"video_id": "vX", "position": 40})
assert _ids(db, pl.id) == ["a", "b", "vX"]
def test_no_position_still_appends(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a", "b"])
_add(as_user(user), pl.id, {"video_id": "vX"})
assert _ids(db, pl.id) == ["a", "b", "vX"]
def test_a_video_already_in_the_list_does_not_move(db, make_user, as_user):
# The add is idempotent; a position must not turn it into a reorder.
user = make_user()
pl = _seed(db, user, ["a", "b", "c"])
_add(as_user(user), pl.id, {"video_id": "c", "position": 0})
assert _ids(db, pl.id) == ["a", "b", "c"]
def test_a_negative_position_is_refused(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a"])
r = _add(as_user(user), pl.id, {"video_id": "vX", "position": -1})
assert r.status_code == 422
assert _ids(db, pl.id) == ["a"]
def test_putting_a_removed_video_back_leaves_a_linked_list_clean(db, make_user, as_user):
# Remove + undo is no change at all: a YouTube-linked list must not stay "unsynced" after it.
user = make_user()
pl = _seed(db, user, ["a", "b", "c"], yt_playlist_id="PLx")
client = as_user(user)
from app.sync.playlists import current_fingerprint
pl.synced_fingerprint = current_fingerprint(db, pl)
pl.dirty = False
db.commit()
client.delete(f"/api/playlists/{pl.id}/items/b")
db.expire_all()
assert db.get(Playlist, pl.id).dirty is True
_add(client, pl.id, {"video_id": "b", "position": 1})
db.expire_all()
assert db.get(Playlist, pl.id).dirty is False
+16 -19
View File
@@ -13,6 +13,9 @@ Two properties are load-bearing and neither is visible from the happy path alone
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy.exc import OperationalError
from app.models import Channel, Playlist, PlaylistItem, Video
@@ -269,12 +272,12 @@ def test_a_target_that_already_had_it_is_not_recently_changed(db, make_user, as_
assert db.get(Playlist, dst.id).updated_at == old
def test_a_concurrent_add_to_the_target_lands_as_the_already_present_move(
db, make_user, as_user, monkeypatch
):
"""The already-in-target check is a read, not a lock: another tab's add (or the scheduler's
YouTube merge) can commit the same video to the target in between, and the re-parent then
hits uq_playlist_video. That is the idempotent outcome arriving late — not a raw 500."""
def test_no_add_can_land_in_the_target_while_a_move_holds_it(db, make_user):
"""The already-in-target check is a read; it holds because the move locks both lists first and
an insert into the target — another tab's add, a YouTube merge — needs the target row for its
foreign key, so it waits for the move's commit instead of hitting uq_playlist_video under it."""
from sqlalchemy import text
from app.db import SessionLocal
from app.routes import playlists as plroutes
@@ -282,16 +285,10 @@ def test_a_concurrent_add_to_the_target_lands_as_the_already_present_move(
_seed_videos(db, ["v1"])
src = _playlist(db, user, "Source", ["v1"])
dst = _playlist(db, user, "Target", [])
real = plroutes._next_item_position
def add_first(session, playlist_id):
with SessionLocal() as other: # the other tab, committing between check and re-parent
other.add(PlaylistItem(playlist_id=dst.id, video_id="v1", position=0))
other.commit()
return real(session, playlist_id)
monkeypatch.setattr(plroutes, "_next_item_position", add_first)
assert _move(as_user(user), src.id, "v1", dst.id).status_code == 200
db.expire_all()
assert _ids(db, src.id) == []
assert _ids(db, dst.id) == ["v1"]
plroutes.lock_playlists(db, src.id, dst.id) # what the move holds until its commit
with SessionLocal() as other:
other.execute(text("SET LOCAL lock_timeout = '300ms'"))
other.add(PlaylistItem(playlist_id=dst.id, video_id="v1", position=0))
with pytest.raises(OperationalError, match="lock timeout"):
other.flush()
db.rollback()
@@ -0,0 +1,262 @@
"""E28 review — every request that reads a playlist's item positions and writes new ones locks the
playlist row first.
Without it two adds (two tabs; the rail and the page) both read the same `max(position)` and land
on one position, and a reorder racing an add collides with it — the list's order between the two
rows is then whatever the planner returns. A true two-connection race is not reproducible on
demand, so the test holds the mechanism: the parent row is taken `FOR UPDATE` by each such route.
A Plex reorder that omits a row (added in another tab meanwhile) must not collide with it either.
Round 2: the lock also comes FIRST — before the request reads or writes a single item row. A remove
that deleted an item and then touched the playlist beside a reorder that went the other way was a
deadlock (a 500 for one of them), and an add that checked "already there?" before the lock let two
adds of one video both insert (a 500 on the unique key).
"""
import re
from datetime import datetime, timezone
import pytest
from sqlalchemy import event
from app.models import (
Channel,
Playlist,
PlaylistItem,
PlexItem,
PlexLibrary,
PlexPlaylist,
PlexPlaylistItem,
Video,
)
@pytest.fixture
def locks(db):
"""The tables each request took a row lock on (`SELECT … FROM <t> … FOR UPDATE`)."""
seen: list[str] = []
def _record(conn, cursor, statement, *_):
if "FOR UPDATE" in statement:
seen.extend(re.findall(r"FROM\s+(\w+)", statement)[:1])
engine = db.get_bind()
event.listen(engine, "before_cursor_execute", _record)
yield seen
event.remove(engine, "before_cursor_execute", _record)
@pytest.fixture
def statements(db):
"""Every statement a request ran, in order."""
seen: list[str] = []
def _record(conn, cursor, statement, *_):
seen.append(" ".join(statement.split()))
engine = db.get_bind()
event.listen(engine, "before_cursor_execute", _record)
yield seen
event.remove(engine, "before_cursor_execute", _record)
def _locked_before_items(seen: list[str], parent: str, items: str) -> str | None:
"""The first statement touching `items` must come after a `FOR UPDATE` of `parent`; returns
that lock statement (None when it came too late or never)."""
lock = None
for st in seen:
if lock is None and "FOR UPDATE" in st and re.search(rf"FROM {parent}\b", st):
lock = st
if re.search(rf"\b{items}\b", st):
return lock
return lock
def _yt(db, user):
db.add(Channel(id="UCtest", title="Test"))
now = datetime.now(timezone.utc)
for v in ("v1", "v2", "v3"):
db.add(Video(id=v, channel_id="UCtest", published_at=now))
pl = Playlist(user_id=user.id, name="L")
other = Playlist(user_id=user.id, name="M")
db.add_all([pl, other])
db.flush()
db.add(PlaylistItem(playlist_id=pl.id, video_id="v1", position=0))
db.commit()
return pl.id, other.id
def _plex(db, user):
lib = PlexLibrary(plex_key="p1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
items = [PlexItem(rating_key=rk, library_id=lib.id, kind="movie", title=rk) for rk in "abcd"]
db.add_all(items)
pl = PlexPlaylist(user_id=user.id, title="L")
db.add(pl)
db.flush()
for i, it in enumerate(items[:3]):
db.add(PlexPlaylistItem(playlist_id=pl.id, item_id=it.id, position=i))
db.commit()
return pl.id
YT = Playlist.__table__.name
PLEX = PlexPlaylist.__table__.name
@pytest.mark.parametrize(
"call",
[
lambda c, pid, other: c.post(f"/api/playlists/{pid}/items", json={"video_id": "v2"}),
lambda c, pid, other: c.post(
f"/api/playlists/{pid}/items", json={"video_id": "v2", "position": 0}
),
lambda c, pid, other: c.post("/api/playlists/watch-later", json={"video_id": "v2"}),
lambda c, pid, other: c.post(
f"/api/playlists/{pid}/items/v1/move", json={"target_playlist_id": other}
),
lambda c, pid, other: c.put(f"/api/playlists/{pid}/order", json={"video_ids": ["v1"]}),
],
ids=["add", "add-at", "watch-later", "move", "reorder"],
)
def test_a_youtube_playlist_write_locks_the_playlist(db, make_user, as_user, locks, call):
user = make_user()
pid, other = _yt(db, user)
r = call(as_user(user), pid, other)
assert r.status_code == 200, r.text
assert YT in locks
@pytest.mark.parametrize(
"call",
[
lambda c, pid: c.post(f"/api/plex/playlists/{pid}/items", json={"item_rating_key": "d"}),
lambda c, pid: c.post(
f"/api/plex/playlists/{pid}/items/bulk", json={"item_rating_keys": ["d"]}
),
lambda c, pid: c.post(
f"/api/plex/playlists/{pid}/items/bulk",
json={"item_rating_keys": ["d"], "positions": [0]},
),
lambda c, pid: c.put(
f"/api/plex/playlists/{pid}/order", json={"item_rating_keys": ["c", "b", "a"]}
),
],
ids=["add", "bulk", "bulk-at", "reorder"],
)
def test_a_plex_playlist_write_locks_the_playlist(db, make_user, as_user, locks, call):
user = make_user()
pid = _plex(db, user)
r = call(as_user(user), pid)
assert r.status_code == 200, r.text
assert PLEX in locks
def test_a_plex_reorder_that_omits_a_row_does_not_collide_with_it(db, make_user, as_user):
"""The row "a" was added in another tab after this page loaded its order ["c", "b"]: it keeps
its place after the given rows instead of sharing position 0 with "c"."""
user = make_user()
pid = _plex(db, user)
r = as_user(user).put(f"/api/plex/playlists/{pid}/order", json={"item_rating_keys": ["c", "b"]})
assert r.status_code == 200
db.expire_all()
rows = db.query(PlexPlaylistItem).filter_by(playlist_id=pid).all()
by_id = {it.id: it.rating_key for it in db.query(PlexItem).all()}
got = sorted((r.position, by_id[r.item_id]) for r in rows)
assert got == [(0, "c"), (1, "b"), (2, "a")]
YT_ITEMS = PlaylistItem.__table__.name
PLEX_ITEMS = PlexPlaylistItem.__table__.name
@pytest.mark.parametrize(
"call",
[
lambda c, pid, other: c.post(f"/api/playlists/{pid}/items", json={"video_id": "v1"}),
lambda c, pid, other: c.post(
f"/api/playlists/{pid}/items", json={"video_id": "v2", "position": 0}
),
lambda c, pid, other: c.delete(f"/api/playlists/{pid}/items/v1"),
lambda c, pid, other: c.post(
f"/api/playlists/{pid}/items/v1/move", json={"target_playlist_id": other}
),
lambda c, pid, other: c.put(f"/api/playlists/{pid}/order", json={"video_ids": ["v1"]}),
# the ORM cascade deletes the items before the list row
lambda c, pid, other: c.delete(f"/api/playlists/{pid}"),
],
ids=["add-present", "add-at", "remove", "move", "reorder", "delete"],
)
def test_a_youtube_item_write_locks_the_playlist_before_any_item(
db, make_user, as_user, statements, call
):
user = make_user()
pid, other = _yt(db, user)
c = as_user(user)
statements.clear()
r = call(c, pid, other)
assert r.status_code == 200, r.text
assert _locked_before_items(statements, YT, YT_ITEMS), statements
def test_a_move_locks_both_lists_in_one_ordered_statement(db, make_user, as_user, statements):
"""Two moves A→B and B→A each locking "its" list first would lock crosswise."""
user = make_user()
pid, other = _yt(db, user)
statements.clear()
r = as_user(user).post(
f"/api/playlists/{pid}/items/v1/move", json={"target_playlist_id": other}
)
assert r.status_code == 200, r.text
lock = _locked_before_items(statements, YT, YT_ITEMS)
assert lock and "ORDER BY" in lock, lock
# BOTH lists in that one statement: one bound id per list in its IN (…) (a single-id lock
# renders the same IN and ORDER BY)
listed = re.search(r" IN \((.*?)\) ORDER BY", lock)
assert listed and len(re.findall(r"%\(\w+\)s", listed.group(1))) == 2, lock
def test_watch_later_add_and_remove_lock_it_before_any_item(db, make_user, as_user, statements):
user = make_user()
_yt(db, user)
c = as_user(user)
assert c.post("/api/playlists/watch-later", json={"video_id": "v2"}).status_code == 200
statements.clear()
assert c.post("/api/playlists/watch-later", json={"video_id": "v2"}).status_code == 200
assert _locked_before_items(statements, YT, YT_ITEMS), statements
statements.clear()
assert c.delete("/api/playlists/watch-later/v2").status_code == 200
assert _locked_before_items(statements, YT, YT_ITEMS), statements
def test_a_rename_locks_the_playlist(db, make_user, as_user, locks):
"""A rename moves `dirty`, which the scheduled mirror refresh re-reads under this lock."""
user = make_user()
pid, _ = _yt(db, user)
r = as_user(user).patch(f"/api/playlists/{pid}", json={"name": "New"})
assert r.status_code == 200, r.text
assert YT in locks
@pytest.mark.parametrize(
"call",
[
lambda c, pid: c.delete(f"/api/plex/playlists/{pid}/items/a"),
lambda c, pid: c.post(
f"/api/plex/playlists/{pid}/items/remove-bulk", json={"item_rating_keys": ["a", "b"]}
),
lambda c, pid: c.post(f"/api/plex/playlists/{pid}/items", json={"item_rating_key": "a"}),
],
ids=["remove", "remove-bulk", "add-present"],
)
def test_a_plex_item_write_locks_the_playlist_before_any_item(
db, make_user, as_user, statements, call
):
user = make_user()
pid = _plex(db, user)
c = as_user(user)
statements.clear()
r = call(c, pid)
assert r.status_code == 200, r.text
assert _locked_before_items(statements, PLEX, PLEX_ITEMS), statements
+210
View File
@@ -0,0 +1,210 @@
"""E28 review C1 — one YouTube sync of a playlist at a time, whoever starts it.
A push reads YouTube and then writes both sides for seconds, committing step by step, so the
client's in-flight flag (one page's menus) and a row lock (gone at the first commit) both leave two
tabs, or a push and the scheduled mirror refresh, free to interleave. The claim is an advisory lock
on a connection of its own; the test holds it from outside, the way a second request would.
"""
from sqlalchemy import text
from app.auth import require_write_scope
from app.db import SessionLocal
from app.models import Playlist
from app.routes import playlists as plroutes
from app.sync import playlists as plsync
def _linked(db, user, *, source="local"):
pl = Playlist(user_id=user.id, name="L", source=source, yt_playlist_id="PL123")
db.add(pl)
db.commit()
return pl
class FakeYT:
"""YouTube as far as these routes read it: two playlists, none with items."""
def __init__(self, *_):
pass
def __enter__(self):
return self
def __exit__(self, *_):
return False
def iter_my_playlists(self):
return [{"id": "PL123", "title": "Renamed"}, {"id": "PL999", "title": "New"}]
def iter_my_playlist_video_ids(self, _ytid):
return []
def iter_playlist_items_with_ids(self, _ytid):
return []
def get_playlist_snippet(self, _ytid):
return {}
def test_push_and_revert_refuse_while_the_list_is_claimed(
db, as_user, make_user, override_dep, monkeypatch
):
user = make_user()
pl = _linked(db, user)
override_dep(require_write_scope, lambda: user)
monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True)
ran: list[str] = []
monkeypatch.setattr(plroutes, "YouTubeClient", FakeYT)
monkeypatch.setattr(plroutes, "plan_merge", lambda d, u, p, live: {"units_estimate": 0})
monkeypatch.setattr(plroutes, "merge_playlist", lambda d, u, p, live: ran.append("push") or {})
monkeypatch.setattr(plroutes, "repull_playlist", lambda d, u, p: ran.append("revert") or {})
c = as_user(user)
with plsync.sync_claim(db, pl.id) as free:
assert free
for path in ("push", "revert-youtube"):
r = c.post(f"/api/playlists/{pl.id}/{path}")
assert r.status_code == 409
assert r.json()["detail"] == {"code": "playlist", "reason": "sync_busy"}
# a delete too: a push mid-way would plan the deleted list as emptied (E28 round 4)
r = c.delete(f"/api/playlists/{pl.id}")
assert r.status_code == 409
assert r.json()["detail"] == {"code": "playlist", "reason": "sync_busy"}
assert ran == []
assert db.get(Playlist, pl.id) is not None
# Released on the way out: the same list syncs again, and a claim is per list.
assert c.post(f"/api/playlists/{pl.id}/push").status_code == 200
assert c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code == 200
assert ran == ["push", "revert"]
def test_a_claim_is_released_when_its_block_raises(db, make_user):
pl = _linked(db, make_user())
try:
with plsync.sync_claim(db, pl.id) as free:
assert free
raise RuntimeError("boom")
except RuntimeError:
pass
# Asked of pg_locks, not by claiming again: a lock leaked on a pooled connection would be granted
# again to that same connection (advisory locks are re-entrant per session).
held = db.scalar(
text("SELECT count(*) FROM pg_locks WHERE locktype = 'advisory' AND objid = :id"),
{"id": pl.id},
)
assert held == 0
def test_a_claim_draws_no_connection_from_the_app_pool(db, make_user):
"""A claim is held for a whole push beside the request's pooled session; drawn from the pool
too, a burst of pushes ran it dry and stalled every other request for pool_timeout."""
from app.db import engine
pl = _linked(db, make_user())
before = engine.pool.checkedout()
with plsync.sync_claim(db, pl.id) as free:
assert free
assert engine.pool.checkedout() == before
def test_the_scheduled_refresh_skips_a_mirror_being_synced(db, make_user, monkeypatch):
user = make_user()
busy = _linked(db, user, source="youtube")
replaced: list[int] = []
monkeypatch.setattr(plsync, "has_read_scope", lambda u: True)
monkeypatch.setattr(plsync, "YouTubeClient", FakeYT)
monkeypatch.setattr(
plsync, "_replace_items_from_youtube", lambda d, p, ids: replaced.append(p.id)
)
with plsync.sync_claim(db, busy.id):
out = plsync.sync_user_playlists(db, user)
assert out == {"synced": 1}
assert busy.id not in replaced and len(replaced) == 1
db.refresh(busy)
assert busy.name == "L"
def test_refused_reverts_do_not_spend_the_pull_cooldown(
db, as_user, make_user, override_dep, monkeypatch
):
"""Round 2: the cooldown (three pulls per ten minutes) is checked after the claim, so clicking
Revert while a push runs is refused as busy without using up a pull."""
user = make_user()
pl = _linked(db, user)
monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True)
monkeypatch.setattr(plroutes, "repull_playlist", lambda d, u, p: {})
c = as_user(user)
with plsync.sync_claim(db, pl.id):
for _ in range(4):
assert c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code == 409
assert c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code == 200
def test_a_bulk_pull_runs_once_per_user_at_a_time(db, as_user, make_user, monkeypatch):
"""Two bulk pulls at once both saw a YouTube list unmirrored and each created its mirror."""
user = make_user()
monkeypatch.setattr(plsync, "has_read_scope", lambda u: True)
monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True)
monkeypatch.setattr(plsync, "YouTubeClient", FakeYT)
monkeypatch.setattr(plsync, "_replace_items_from_youtube", lambda d, p, ids: None)
c = as_user(user)
with plsync.user_sync_claim(db, user.id):
assert plsync.sync_user_playlists(db, user) == {"synced": 0, "reason": "busy"}
for _ in range(4):
r = c.post("/api/playlists/sync-youtube")
assert r.status_code == 409
assert r.json()["detail"] == {"code": "playlist", "reason": "sync_all_busy"}
assert c.post("/api/playlists/sync-youtube").json() == {"ok": True, "synced": 2}
def test_a_gone_mirror_is_deleted_even_when_no_list_refreshes(db, make_user, monkeypatch):
"""Every remaining mirror has unpushed edits, so nothing below the deletion commits."""
user = make_user()
gone = Playlist(user_id=user.id, name="Gone", source="youtube", yt_playlist_id="PLgone")
kept = Playlist(user_id=user.id, name="K", source="youtube", yt_playlist_id="PL123", dirty=True)
other = Playlist(
user_id=user.id, name="O", source="youtube", yt_playlist_id="PL999", dirty=True
)
db.add_all([gone, kept, other])
db.commit()
gone_id = gone.id
monkeypatch.setattr(plsync, "has_read_scope", lambda u: True)
monkeypatch.setattr(plsync, "YouTubeClient", FakeYT)
assert plsync.sync_user_playlists(db, user) == {"synced": 0}
db.rollback() # what the request's session close does to an uncommitted deletion
assert db.get(Playlist, gone_id) is None
def test_a_mirror_edited_during_the_pull_is_not_replaced(db, make_user, monkeypatch):
"""The loop's `dirty` is a snapshot from before the YouTube calls; a remove committed by another
request meanwhile must win over YouTube's order."""
user = make_user()
pl = _linked(db, user, source="youtube")
replaced: list[int] = []
class EditedMeanwhile(FakeYT):
def iter_my_playlists(self):
return [{"id": "PL123", "title": "Renamed"}]
def iter_my_playlist_video_ids(self, _ytid):
with SessionLocal() as other:
other.get(Playlist, pl.id).dirty = True
other.commit()
return []
monkeypatch.setattr(plsync, "has_read_scope", lambda u: True)
monkeypatch.setattr(plsync, "YouTubeClient", EditedMeanwhile)
monkeypatch.setattr(
plsync, "_replace_items_from_youtube", lambda d, p, ids: replaced.append(p.id)
)
assert plsync.sync_user_playlists(db, user) == {"synced": 0}
assert replaced == []
db.expire_all()
assert db.get(Playlist, pl.id).name == "L"
@@ -0,0 +1,292 @@
"""E28 review round 3 — the YouTube sync paths take the playlist row BEFORE they touch an item, and
hold it to the commit that writes them.
Round 2 made every route edit lock the playlist first. The sync paths were left item-first (a push's
and a re-pull's item replace, a delete's cascade), so a push beside a remove could deadlock (a 500),
and the scheduled mirror refresh re-ingested inside its lock — its commit let go of the lock before
the replace, so a remove made then was undone and the list read as synced. A push also replaced the
items from a snapshot read before its YouTube calls, and marked the list clean over an edit made
during its YouTube writes. The "other tab" here is a second session that commits while the push is
talking to YouTube — when the push holds no lock, as a real one does not.
"""
import json
import re
from datetime import datetime, timezone
import pytest
from sqlalchemy import delete, event, text
from app.db import SessionLocal
from app.models import Channel, Playlist, PlaylistItem, Video
from app.sync import playlists as pl_mod
ITEMS = PlaylistItem.__table__.name
LISTS = Playlist.__table__.name
@pytest.fixture
def trace(db):
"""Every statement and every COMMIT on the test engine, in order."""
seen: list[str] = []
def _stmt(conn, cursor, statement, *_):
seen.append(" ".join(statement.split()))
def _commit(conn):
seen.append("COMMIT")
engine = db.get_bind()
event.listen(engine, "before_cursor_execute", _stmt)
event.listen(engine, "commit", _commit)
yield seen
event.remove(engine, "before_cursor_execute", _stmt)
event.remove(engine, "commit", _commit)
def _held_to_the_item_write(seen: list[str], verb: str) -> bool:
"""The first `<verb> … playlist_items` comes after a `FOR UPDATE` of playlists, with no COMMIT
in between (a commit ends the lock)."""
locked = False
for st in seen:
if "FOR UPDATE" in st and re.search(rf"FROM {LISTS}\b", st):
locked = True
elif st == "COMMIT":
locked = False
elif st.startswith(verb) and re.search(rf"\b{ITEMS}\b", st):
return locked
return False
class FakeYT:
"""YouTube for the sync paths: one playlist, its items, and ids the Data API will not return."""
def __init__(self, items, *, unavailable=(), title="Mix", on_get=None, on_add=None):
self.items = [(f"it{i}", v) for i, v in enumerate(items)]
self.unavailable = set(unavailable)
self.title = title
self.get_calls = 0
self._on_get = on_get
self._on_add = on_add
def __enter__(self):
return self
def __exit__(self, *_):
return False
def iter_my_playlists(self):
return [{"id": "PLyt", "title": self.title}]
def iter_my_playlist_video_ids(self, _pid):
return [v for _, v in self.items]
def iter_playlist_items_with_ids(self, _pid):
return [{"item_id": i, "video_id": v} for i, v in self.items]
def get_playlist_snippet(self, _pid):
return {"title": self.title}
def get_videos(self, ids):
self.get_calls += 1
if self._on_get:
self._on_get()
return [
{"id": v, "snippet": {"channelId": "UCtest", "publishedAt": "2020-01-01T00:00:00Z"}}
for v in ids
if v not in self.unavailable
]
def create_playlist(self, title, *_, **__):
return "PLyt"
def update_playlist_title(self, _pid, title):
self.title = title
def add_playlist_item(self, _pid, vid, position=None):
if self._on_add:
self._on_add()
iid = f"it{len(self.items) + 100}"
self.items.append((iid, vid))
return iid
def delete_playlist_item(self, item_id):
self.items = [r for r in self.items if r[0] != item_id]
def move_playlist_item(self, item_id, _pid, _vid, position):
row = next(r for r in self.items if r[0] == item_id)
self.items.remove(row)
self.items.insert(position, row)
@pytest.fixture
def fake_yt(monkeypatch):
def install(fake):
monkeypatch.setattr(pl_mod, "YouTubeClient", lambda *_: fake)
return fake
return install
def _seed(db, ids):
db.add(Channel(id="UCtest", title="Test"))
now = datetime.now(timezone.utc)
for v in ids:
db.add(Video(id=v, channel_id="UCtest", published_at=now))
db.commit()
def _playlist(db, user, local, *, source="local", baseline=None):
pl = Playlist(
user_id=user.id,
name="Mix",
source=source,
yt_playlist_id="PLyt",
synced_video_ids=None if baseline is None else pl_mod._dump_ids(baseline),
)
db.add(pl)
db.flush()
for pos, v in enumerate(local):
db.add(PlaylistItem(playlist_id=pl.id, video_id=v, position=pos))
db.commit()
return pl
def _other_tab(pl_id, *, add=None, remove=None, rename=None, drop=False):
"""A second session's edit, committed at once — what another tab's request does. It runs on
the push's own thread: a lock the push wrongly held here would be waited on forever, so the
wait is bounded and a regression fails instead of hanging the gate."""
with SessionLocal() as s:
s.execute(text("SET LOCAL lock_timeout = '2s'"))
if drop:
s.execute(delete(Playlist).where(Playlist.id == pl_id))
if rename:
s.get(Playlist, pl_id).name = rename
if add:
pos = len(s.query(PlaylistItem).filter_by(playlist_id=pl_id).all())
s.add(PlaylistItem(playlist_id=pl_id, video_id=add, position=pos))
if remove:
s.query(PlaylistItem).filter_by(playlist_id=pl_id, video_id=remove).delete()
s.commit()
def test_the_mirror_refresh_holds_its_lock_through_a_list_with_a_private_video(
db, make_user, fake_yt, trace
):
user = make_user()
_seed(db, ["a"])
pl = _playlist(db, user, ["a"], source="youtube")
fake = fake_yt(FakeYT(["a", "ghost"], unavailable={"ghost"}))
trace.clear()
pl_mod.pull_user_playlists(db, user)
assert _held_to_the_item_write(trace, "DELETE"), trace
# the private video is asked for once, not again under the lock
assert fake.get_calls == 1
assert pl_mod._local_video_ids(db, pl) == ["a"]
def test_the_mirror_drop_locks_the_list_before_its_items(db, make_user, fake_yt, trace):
user = make_user()
_seed(db, ["a"])
pl = _playlist(db, user, ["a"], source="youtube")
pl.yt_playlist_id = "PLgone"
db.commit()
fake_yt(FakeYT([]))
trace.clear()
pl_mod.pull_user_playlists(db, user)
assert _held_to_the_item_write(trace, "DELETE"), trace
def test_a_repull_ingests_before_its_lock_and_holds_it_to_the_replace(
db, make_user, fake_yt, trace
):
user = make_user()
_seed(db, ["a"])
pl = _playlist(db, user, ["a"])
fake = fake_yt(FakeYT(["b", "a", "ghost"], unavailable={"ghost"}, title="Renamed"))
trace.clear()
pl_mod.repull_playlist(db, user, pl)
assert _held_to_the_item_write(trace, "DELETE"), trace
assert fake.get_calls == 1
db.refresh(pl)
assert (pl.name, pl.dirty) == ("Renamed", False)
assert pl_mod._local_video_ids(db, pl) == ["b", "a"]
def test_a_push_locks_the_list_before_it_replaces_the_items(db, make_user, fake_yt, trace):
user = make_user()
_seed(db, ["a", "b"])
pl = _playlist(db, user, ["a", "b"], baseline=["a"])
fake_yt(FakeYT(["a", "y"]))
trace.clear()
pl_mod.merge_playlist(db, user, pl)
assert _held_to_the_item_write(trace, "DELETE"), trace
def test_an_add_made_while_a_push_reads_youtube_survives_it(db, make_user, fake_yt):
user = make_user()
_seed(db, ["a", "x"])
pl = _playlist(db, user, ["a"], baseline=["a"])
# 'y' is new on YouTube, so the push ingests it — and the other tab adds 'x' meanwhile.
fake = fake_yt(FakeYT(["a", "y"], on_get=lambda: _other_tab(pl.id, add="x")))
pl_mod.merge_playlist(db, user, pl)
local = pl_mod._local_video_ids(db, pl)
assert set(local) == {"a", "x", "y"}
assert "x" in [v for _, v in fake.items] # and it reached YouTube as a local add
@pytest.mark.parametrize("linked", [True, False], ids=["linked", "first-push"])
def test_a_rename_made_during_a_push_s_youtube_writes_stays_dirty(db, make_user, fake_yt, linked):
"""The closing baseline is YouTube's title, not the one the lock re-reads: over the new name it
matched the rename, and the list read synced with YouTube still on the old title."""
user = make_user()
_seed(db, ["a", "b"])
pl = _playlist(db, user, ["a", "b"], baseline=["a"] if linked else None)
if not linked:
pl.yt_playlist_id = None
db.commit()
fake = fake_yt(
FakeYT(["a"] if linked else [], on_add=lambda: _other_tab(pl.id, rename="Mix 2"))
)
pl_mod.merge_playlist(db, user, pl)
db.refresh(pl)
assert (pl.name, fake.title) == ("Mix 2", "Mix")
assert pl.dirty is True # the rename has not reached YouTube; the list must say so
def test_a_push_stops_at_a_list_deleted_while_it_read_youtube(db, make_user, fake_yt):
"""A delete landing mid-push left a plan over no items: every item "removed here", so the push
emptied the YouTube playlist the delete meant to keep."""
user = make_user()
_seed(db, ["a", "b"])
pl = _playlist(db, user, ["a", "b"])
# a video YouTube would not return last time, retried now — the read the delete lands in
pl.synced_video_ids = json.dumps({"ids": ["a", "b"], "unavailable": ["ghost"]})
db.commit()
fake = fake_yt(
FakeYT(
["a", "b", "ghost"], unavailable={"ghost"}, on_get=lambda: _other_tab(pl.id, drop=True)
)
)
raised = None
try:
pl_mod.merge_playlist(db, user, pl)
except Exception as exc: # noqa: BLE001 — the regression raises too, AFTER its YouTube writes
raised = exc
assert [v for _, v in fake.items] == ["a", "b", "ghost"] # YouTube untouched
assert isinstance(raised, pl_mod.PlaylistGone) # which the route answers with a 404
@pytest.mark.parametrize("linked", [True, False], ids=["linked", "first-push"])
def test_an_edit_made_during_a_push_s_youtube_writes_stays_dirty(db, make_user, fake_yt, linked):
user = make_user()
_seed(db, ["a", "b"])
pl = _playlist(db, user, ["a", "b"], baseline=["a"] if linked else None)
if not linked:
pl.yt_playlist_id = None
db.commit()
fake_yt(FakeYT(["a"] if linked else [], on_add=lambda: _other_tab(pl.id, remove="a")))
pl_mod.merge_playlist(db, user, pl)
db.refresh(pl)
assert pl_mod._local_video_ids(db, pl) == ["b"]
assert pl.dirty is True # the removal has not reached YouTube; the list must say so
@@ -0,0 +1,113 @@
"""E28 S3e2 — putting removed Plex playlist rows back WHERE THEY WERE: the list page's "Undo".
A removal can take one row, a season or a whole show, and a season's episodes need not sit together
(a reorder inside the show interleaves them), so each key carries its own index — over the ORDERED
rows, since positions have gaps after a removal. One request: an append plus a second reorder call
leaves the rows at the end whenever that call fails, and would overwrite another tab's changes.
"""
from app.models import PlexItem, PlexLibrary, PlexPlaylist, PlexPlaylistItem
def _seed(db, user, rks, *, positions=None, extra=("x", "y")):
lib = PlexLibrary(plex_key="p1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
items = {}
for rk in [*rks, *extra]:
it = PlexItem(rating_key=rk, library_id=lib.id, kind="movie", title=rk.upper())
db.add(it)
items[rk] = it
pl = PlexPlaylist(user_id=user.id, title="L")
db.add(pl)
db.flush()
for i, rk in enumerate(rks):
pos = (positions or range(99))[i]
db.add(PlexPlaylistItem(playlist_id=pl.id, item_id=items[rk].id, position=pos))
db.commit()
return pl
def _order(db, pid):
db.expire_all()
rows = db.execute(
PlexPlaylistItem.__table__.select()
.where(PlexPlaylistItem.playlist_id == pid)
.order_by(PlexPlaylistItem.position, PlexPlaylistItem.id)
).all()
by_id = {it.id: it.rating_key for it in db.query(PlexItem).all()}
return [by_id[r.item_id] for r in rows], [r.position for r in rows]
def _add(client, pid, keys, positions=None):
body = {"item_rating_keys": keys}
if positions is not None:
body["positions"] = positions
return client.post(f"/api/plex/playlists/{pid}/items/bulk", json=body)
def test_one_row_goes_back_to_its_index(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a", "b", "c"])
r = _add(as_user(user), pl.id, ["x"], [1])
assert r.status_code == 200
assert r.json()["added"] == 1
assert _order(db, pl.id) == (["a", "x", "b", "c"], [0, 1, 2, 3])
def test_scattered_rows_go_back_to_each_of_their_indices(db, make_user, as_user):
# A season whose episodes were interleaved with another season's: removed rows at 1 and 3 of
# a, x, b, y, c. Given in any order, each lands where it stood.
user = make_user()
pl = _seed(db, user, ["a", "b", "c"])
_add(as_user(user), pl.id, ["y", "x"], [3, 1])
assert _order(db, pl.id)[0] == ["a", "x", "b", "y", "c"]
def test_indices_count_rows_not_stored_numbers(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a", "b", "c"], positions=[0, 7, 20])
_add(as_user(user), pl.id, ["x"], [2])
assert _order(db, pl.id) == (["a", "b", "x", "c"], [0, 1, 2, 3])
def test_an_index_past_the_end_appends(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a"])
_add(as_user(user), pl.id, ["x"], [50])
assert _order(db, pl.id)[0] == ["a", "x"]
def test_a_row_already_in_the_list_does_not_move(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a", "b", "c"])
r = _add(as_user(user), pl.id, ["c"], [0])
assert r.json()["added"] == 0
assert _order(db, pl.id)[0] == ["a", "b", "c"]
def test_without_positions_it_still_appends(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a", "b"])
_add(as_user(user), pl.id, ["y", "x"])
assert _order(db, pl.id)[0] == ["a", "b", "y", "x"]
def test_positions_must_pair_with_the_keys(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a"])
assert _add(as_user(user), pl.id, ["x", "y"], [0]).status_code == 422
assert _add(as_user(user), pl.id, ["x"], [-1]).status_code == 422
assert _order(db, pl.id)[0] == ["a"]
def test_remove_then_put_back_is_no_change(db, make_user, as_user):
user = make_user()
pl = _seed(db, user, ["a", "b", "c", "d"], extra=())
client = as_user(user)
client.post(
f"/api/plex/playlists/{pl.id}/items/remove-bulk", json={"item_rating_keys": ["b", "d"]}
)
assert _order(db, pl.id)[0] == ["a", "c"]
_add(client, pl.id, ["b", "d"], [1, 3])
assert _order(db, pl.id)[0] == ["a", "b", "c", "d"]
@@ -0,0 +1,115 @@
"""A sidecar subtitle in a legacy code page must render its accents, not U+FFFD.
Found live on Arrow S03E15 (a Hungarian .srt beside the .mp4): the served WebVTT held 1391 U+FFFD and
not one accented letter, because `_sidecar_vtt` decoded the bytes as UTF-8 with `errors="replace"`
while the file was Windows-1250 (ő = 0xF5, ű = 0xFB). Legacy-code-page .srt files are the norm for
Hungarian releases, and SubRip carries no charset of its own, so the stream's language is the only hint.
No Postgres: Plex and ffmpeg are stubbed.
"""
from pathlib import Path
from typing import cast
import pytest
from sqlalchemy.orm import Session
from app.plex import subtitles
HU = "1\r\n00:00:01,000 --> 00:00:02,000\r\nMiért nem szóltál? Ősz, űr.\r\n"
FR = "1\r\n00:00:01,000 --> 00:00:02,000\r\nDéjà vu, garçon.\r\n"
RU = "1\r\n00:00:01,000 --> 00:00:02,000\r\nПривет, мир.\r\n"
class _Plex:
def __init__(self, raw: bytes):
self.raw = raw
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def raw_get(self, key: str) -> bytes:
return self.raw
def _sidecar(monkeypatch, raw: bytes, lang: str | None, codec: str = "srt") -> str:
monkeypatch.setattr(subtitles, "PlexClient", lambda db: _Plex(raw))
stream = {"key": "/library/streams/1", "codec": codec, "languageCode": lang}
return subtitles._sidecar_vtt(cast(Session, None), stream)
@pytest.mark.parametrize(
("text", "encoding", "lang"),
[
(HU, "cp1250", "hun"), # the file that was found broken
(FR, "cp1252", "fre"), # a Western legacy file
(RU, "cp1251", "rus"), # a Cyrillic one
(HU, "utf-8", "hun"), # already UTF-8: untouched
(HU, "utf-8-sig", "hun"), # UTF-8 with a BOM
(HU, "utf-16", "hun"), # UTF-16 (carries its BOM)
],
ids=["cp1250-hu", "cp1252-fr", "cp1251-ru", "utf8", "utf8-bom", "utf16"],
)
def test_a_sidecar_srt_is_decoded_in_its_own_encoding(monkeypatch, text, encoding, lang):
vtt = _sidecar(monkeypatch, text.encode(encoding), lang)
assert "\ufffd" not in vtt
assert vtt.startswith("WEBVTT\n\n")
assert text.splitlines()[2] in vtt
def test_a_non_srt_sidecar_reaches_ffmpeg_as_utf8(monkeypatch):
seen: dict[str, bytes] = {}
def fake_ffmpeg(src: str, sub_ord):
seen["bytes"] = Path(src).read_bytes()
return "WEBVTT\n"
monkeypatch.setattr(subtitles, "ffmpeg_to_vtt", fake_ffmpeg)
_sidecar(monkeypatch, "[Events]\nDialogue: Ősz\n".encode("cp1250"), "hun", codec="ass")
assert seen["bytes"].decode("utf-8") == "[Events]\nDialogue: Ősz\n"
def test_a_track_cached_before_the_encoding_fix_is_not_served(tmp_path, monkeypatch):
"""The cache has no eviction, so a mis-decoded track would have stayed broken for good: the key
is versioned, and a track cached under the old one is read again (once) instead of served."""
monkeypatch.setattr(subtitles, "CACHE_DIR", tmp_path)
(tmp_path / "sub_14881_1.vtt").write_text(
"WEBVTT\n\n1\n00:00:01.000 --> 00:00:02.000\nAz�rt\n", "utf-8"
)
assert subtitles.cached_track("14881", 1)[0] is None
def test_one_stray_byte_does_not_send_a_utf8_file_to_a_legacy_code_page(monkeypatch):
"""A UTF-8 release with ONE bad byte (a cut-and-paste Windows-1252 quote) failed strict UTF-8 as a
whole, and the whole file was then read as Windows-1250: every correct ő/é became mojibake. Nearly
all of it is valid UTF-8, so it stays UTF-8 and only the one byte is replaced."""
raw = (HU * 20).encode("utf-8") + b"\x93quoted\x94\r\n"
vtt = _sidecar(monkeypatch, raw, "hun")
assert "Miért nem szóltál? Ősz, űr." in vtt
assert "Miért" not in vtt
def test_an_untagged_legacy_sidecar_is_read_as_central_european(monkeypatch):
"""No language tag: the fallback was Windows-1252, which turns a Hungarian ő/ű into õ/û."""
vtt = _sidecar(monkeypatch, HU.encode("cp1250"), None)
assert "Ősz, űr." in vtt
def test_a_latin_serbian_sidecar_is_read_as_central_european(monkeypatch):
"""Serbian releases are overwhelmingly Latin script in Windows-1250 (like hrv/bos); read as
Windows-1251 every č/ć/š/ž/đ turned into Cyrillic noise."""
sr = "1\r\n00:00:01,000 --> 00:00:02,000\r\nŠta ćeš, đače? Čekaj.\r\n"
vtt = _sidecar(monkeypatch, sr.encode("cp1250"), "srp")
assert "Šta ćeš, đače? Čekaj." in vtt
def test_a_track_cached_under_the_previous_decoder_is_not_served(tmp_path, monkeypatch):
"""The decoder changed again (stray-byte UTF-8, cp1250 fallbacks): the key is versioned once more
so a track the old rules mis-decoded is read again instead of served for good."""
monkeypatch.setattr(subtitles, "CACHE_DIR", tmp_path)
(tmp_path / "sub2_14881_1.vtt").write_text(
"WEBVTT\n\n1\n00:00:01.000 --> 00:00:02.000\nAzõrt\n", "utf-8"
)
assert subtitles.cached_track("14881", 1)[0] is None
+3 -2
View File
@@ -311,8 +311,9 @@ def test_covered_ranges_coalesce_and_answer_membership():
def test_a_cached_vtt_from_an_older_build_counts_as_complete(tmp_path):
"""Before this change the cache only ever held whole tracks, and none of them has a sidecar.
Reading one as "covers nothing" would re-extract every already-cached track after the upgrade."""
(tmp_path / "sub_98614_0.vtt").write_text(_vtt(_cue("00:01.000", "00:02.000", "x")), "utf-8")
assert subtitles.read_meta("sub_98614_0") == ([[0.0, 1_000_000_000.0]], True)
key = subtitles.cache_key("98614", 0)
(tmp_path / f"{key}.vtt").write_text(_vtt(_cue("00:01.000", "00:02.000", "x")), "utf-8")
assert subtitles.read_meta(key) == ([[0.0, 1_000_000_000.0]], True)
# --- the deep-review round: the seams between the window and the full pass ----------------------
+2 -2
View File
@@ -18,12 +18,12 @@ _PW = "open sesame"
@pytest.fixture
def unlock_url(as_user, make_user, tmp_path, monkeypatch):
def unlock_url(as_user, make_user, tmp_path, monkeypatch, upload_headers):
monkeypatch.setattr(settings, "download_root", str(tmp_path))
monkeypatch.setattr(probemod, "probe_media", lambda _p: ("h264", "aac", 320, 240, 5))
owner = as_user(make_user())
job = owner.post(
"/api/downloads/upload", files={"file": ("clip.mp4", b"fake-video", "video/mp4")}
"/api/downloads/upload", content=b"fake-video", headers=upload_headers("clip.mp4")
)
assert job.status_code == 200, job.text
link = owner.post(f"/api/downloads/{job.json()['id']}/links", json={"password": _PW})
+513 -26
View File
@@ -12,15 +12,23 @@ parsing (image → file, cover-art skip, dims, duration) is covered directly by
below, which stubs one level down (`run_ffprobe_json`) and exercises the real `probe_media`.
"""
import ast
import asyncio
import io
import pathlib
from pathlib import Path
import pytest
from fastapi import HTTPException
from app.config import settings
from app.routes._common import capped_stream_to
from sqlalchemy import select
from app.downloads import mediakind, quota, storage
from app.downloads import probe as probemod
from app.downloads import upload as uploadmod
from app.models import DownloadQuota, MediaAsset
from app.models import DISPLAY_TEXT_MAX, DownloadJob, DownloadQuota, MediaAsset
# --- pure: the kind invariant ---------------------------------------------------------------
@@ -123,15 +131,143 @@ def test_ingest_rejects_over_footprint_cap_before_writing(db, make_user, _root,
assert db.query(MediaAsset).count() == 0 # and no orphan asset row
# --- route: the auth-first body (P1) --------------------------------------------------------
def _counting_body(chunks: int = 4, size: int = 1024):
"""A request body that records how much of itself the server actually pulled.
httpx's ASGI transport feeds the app lazily, one `http.request` message per yielded chunk, so a
chunk that was never yielded is a chunk the app never asked for. That is the whole assertion
below: it is not "the upload was refused", it is "the refusal happened before the bytes moved".
"""
pulled: list[int] = []
def body():
for _ in range(chunks):
pulled.append(size)
yield b"x" * size
return body(), pulled
_BOUNDARY = "p1boundary"
def _counting_multipart(chunks: int = 4, size: int = 1024):
"""The same counter, wrapped in a real multipart envelope.
The content-type is the load-bearing part. Starlette's `request.form()` returns an EMPTY
FormData without touching the stream when the content-type is not multipart/urlencoded — so a
raw-body probe cannot tell "the route stopped reading strangers" apart from "there was nothing
here to parse". Only a well-formed multipart body makes the old shape actually spool.
"""
pulled: list[int] = []
head = (
f"--{_BOUNDARY}\r\n"
'Content-Disposition: form-data; name="file"; filename="clip.mp4"\r\n'
"Content-Type: application/octet-stream\r\n\r\n"
).encode()
tail = f"\r\n--{_BOUNDARY}--\r\n".encode()
def body():
pulled.append(len(head))
yield head
for _ in range(chunks):
pulled.append(size)
yield b"x" * size
pulled.append(len(tail))
yield tail
return body(), pulled
def test_an_anonymous_upload_is_refused_before_a_byte_of_it_is_read(client):
"""P1 — the defect E27 bounded but did not close.
While the route declared `file: UploadFile = File(...)`, FastAPI parsed and spooled the body
BEFORE the first dependency ran, so `require_human` could not gate it: an unauthenticated
caller could make the server write the whole per-file allowance (500 MiB by default) to temp,
per request. SEC-20's ceiling capped the number; it could not stop the writing.
The route now reads its own body from a dependency declared AFTER the auth one, the shape
`POST /api/debug-sessions/{sid}/screenshots` already uses. The claim under test is the strong
one — a stranger's bytes are never read WHATEVER they claim to send, so the probe sends the
multipart the old route would have spooled, not the raw body the new one expects."""
body, pulled = _counting_multipart()
r = client.post(
"/api/downloads/upload",
content=body,
headers={"content-type": f"multipart/form-data; boundary={_BOUNDARY}"},
)
assert r.status_code == 401
assert pulled == [], f"the server read {sum(pulled)} bytes from an anonymous caller"
def test_an_authenticated_upload_still_has_its_body_read(as_user, make_user, _root, monkeypatch):
"""The mutation check for the test above: a route that read NOBODY's body would pass it just as
well as the fixed one. The same counting body, signed in, must be pulled in full."""
_probe(monkeypatch, (None, None, None, None, None))
body, pulled = _counting_body()
c = as_user(make_user())
r = c.post(
"/api/downloads/upload",
content=body,
headers={"content-type": "application/octet-stream", "x-upload-filename": "clip.bin"},
)
assert r.status_code == 200, r.text
assert sum(pulled) == 4096
def test_capped_stream_to_drains_the_rest_before_refusing():
"""Measured in a real browser, then pinned HERE rather than at the route.
The cap now fires while the client is still sending, and answering + closing mid-upload is what
a browser reports as a NETWORK failure: the dialog said "the server couldn't be reached"
instead of naming the limit, because XHR never got to read the 422. `capped_stream_to` reads
the remainder to nowhere before raising, so the client finishes sending and reads the real
answer.
It is asserted against the helper and not through TestClient on purpose — httpx's ASGI
transport pumps the request body whether or not the app reads it, so a route-level version of
this test passes just as happily with the drain deleted. It was written that way first and
caught by its own mutation check.
"""
pulled = []
async def stream():
for i in range(8):
pulled.append(i)
yield b"x" * 1024
class _Req:
def stream(self):
return stream()
out = io.BytesIO()
refusal = HTTPException(status_code=422, detail={"code": "upload_too_large", "limit": 2048})
# `asyncio.run` rather than an async test: the suite has no async plugin, and adding one to
# pin four lines of helper would mean a new dev dependency and a lock regeneration.
with pytest.raises(HTTPException) as exc:
asyncio.run(capped_stream_to(_Req(), out, 2048, refusal))
assert exc.value is refusal
assert len(pulled) == 8, "the body was abandoned mid-stream, not drained"
# Drained means DISCARDED: nothing past the limit is written.
assert len(out.getvalue()) <= 2048
# --- route ----------------------------------------------------------------------------------
def test_upload_route_generic_forces_download(as_user, make_user, _root, monkeypatch):
def test_upload_route_generic_forces_download(
as_user, make_user, _root, monkeypatch, upload_headers
):
_probe(monkeypatch, (None, None, None, None, None))
c = as_user(make_user())
r = c.post(
"/api/downloads/upload",
files={"file": ("report.html", b"<script>alert(1)</script>", "text/html")},
content=b"<script>alert(1)</script>",
headers=upload_headers("report.html"),
)
assert r.status_code == 200, r.text
job = r.json()
@@ -145,7 +281,7 @@ def test_upload_route_generic_forces_download(as_user, make_user, _root, monkeyp
def test_upload_route_media_html_extension_not_served_as_html(
as_user, make_user, _root, monkeypatch
as_user, make_user, _root, monkeypatch, upload_headers
):
"""The XSS guard: a MEDIA-classified upload (real codecs) named with an executable extension must
NOT be served with a guessed text/html type. The served Content-Type comes from the verified
@@ -156,7 +292,8 @@ def test_upload_route_media_html_extension_not_served_as_html(
c = as_user(make_user())
r = c.post(
"/api/downloads/upload",
files={"file": ("evil.html", b"<script>alert(1)</script>", "text/html")},
content=b"<script>alert(1)</script>",
headers=upload_headers("evil.html"),
)
assert r.status_code == 200, r.text
job = r.json()
@@ -177,7 +314,9 @@ def test_upload_route_media_html_extension_not_served_as_html(
assert "attachment" in pr.headers["content-disposition"].lower()
def test_public_watch_file_forces_download_for_generic_file(as_user, make_user, _root, monkeypatch):
def test_public_watch_file_forces_download_for_generic_file(
as_user, make_user, _root, monkeypatch, upload_headers
):
"""The login-free public serve of a generic upload (media_kind='file') must be a neutral
force-download attachment — the branch that stops uploaded HTML/SVG executing on the public
origin. This is a SEPARATE code path from the authenticated /file route, so it needs its own
@@ -186,7 +325,8 @@ def test_public_watch_file_forces_download_for_generic_file(as_user, make_user,
c = as_user(make_user())
job = c.post(
"/api/downloads/upload",
files={"file": ("notes.html", b"<script>alert(1)</script>", "text/html")},
content=b"<script>alert(1)</script>",
headers=upload_headers("notes.html"),
).json()
assert job["asset"]["media_kind"] == "file"
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": True}).json()
@@ -197,7 +337,7 @@ def test_public_watch_file_forces_download_for_generic_file(as_user, make_user,
def test_public_watch_file_serves_media_inline_with_real_type(
as_user, make_user, _root, monkeypatch
as_user, make_user, _root, monkeypatch, upload_headers
):
"""A recognised media container IS still served inline with its real audio/video type (so it
plays in the watch page) — the fix must not break legitimate playback. A stream-only link →
@@ -206,7 +346,8 @@ def test_public_watch_file_serves_media_inline_with_real_type(
c = as_user(make_user())
job = c.post(
"/api/downloads/upload",
files={"file": ("clip.mp4", b"fake-mp4-bytes", "video/mp4")},
content=b"fake-mp4-bytes",
headers=upload_headers("clip.mp4"),
).json()
link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": False}).json()
pr = c.get(f"/api/public/watch/{link['token']}/file")
@@ -215,7 +356,9 @@ def test_public_watch_file_serves_media_inline_with_real_type(
assert "inline" in pr.headers["content-disposition"].lower()
def test_upload_route_enforces_per_file_cap(as_user, make_user, db, _root, monkeypatch):
def test_upload_route_enforces_per_file_cap(
as_user, make_user, db, _root, monkeypatch, upload_headers
):
from app import sysconfig
sysconfig.set_value(db, "download_upload_max_bytes", 5)
@@ -223,47 +366,391 @@ def test_upload_route_enforces_per_file_cap(as_user, make_user, db, _root, monke
c = as_user(make_user())
r = c.post(
"/api/downloads/upload",
files={"file": ("big.bin", b"way over the cap", "application/octet-stream")},
content=b"way over the cap",
headers=upload_headers("big.bin"),
)
assert r.status_code == 422
assert r.json()["detail"]["code"] == "upload_too_large"
def test_the_cap_admits_a_body_of_EXACTLY_the_cap_and_refuses_one_byte_more(
as_user, make_user, db, _root, monkeypatch, upload_headers
):
"""The boundary, which the test above cannot see: it sends 16 bytes against a cap of 5, so a
`>` silently loosened to `>=` refuses it just the same and the mutation survives the whole
suite. The cap is `size > limit`, so a file of exactly the cap must be ACCEPTED.
"""
from app import sysconfig
_probe(monkeypatch, (None, None, None, None, None))
payload = b"x" * 64
sysconfig.set_value(db, "download_upload_max_bytes", len(payload))
db.commit()
c = as_user(make_user())
at_the_cap = c.post(
"/api/downloads/upload", content=payload, headers=upload_headers("exact.bin")
)
assert at_the_cap.status_code == 200, at_the_cap.text
over = c.post(
"/api/downloads/upload", content=payload + b"x", headers=upload_headers("over.bin")
)
assert over.status_code == 422
assert over.json()["detail"] == {"code": "upload_too_large", "limit": len(payload)}
def test_a_non_ascii_filename_survives_the_header_round_trip(
as_user, make_user, db, _root, monkeypatch, upload_headers
):
"""The percent-decoding, which every other test in this file leaves vacuous.
Headers are latin-1 on the wire, so the client `encodeURIComponent`s the name and the route
`unquote`s it. Every filename the suite sends is pure ASCII — and `quote()` is the IDENTITY
function on ASCII — so deleting the `unquote` call changes nothing anywhere else. A Hungarian
name is the only input that can tell the two apart: without the decode the sanitizer would be
handed the literal `nyaral%C3%A1s.bin` and would keep the percent-escapes in the stored name.
"""
_probe(monkeypatch, (None, None, None, None, None))
c = as_user(make_user())
r = c.post(
"/api/downloads/upload", content=b"data", headers=upload_headers("nyaralás ősszel.bin")
)
assert r.status_code == 200, r.text
asset = db.execute(select(MediaAsset).order_by(MediaAsset.id.desc())).scalars().first()
assert asset is not None
stored = Path(asset.rel_path).name
assert "%" not in stored and "c3" not in stored.lower(), stored
assert stored.startswith("nyaralas_osszel"), stored
def test_a_long_display_name_is_truncated_on_EVERY_route_that_takes_one(
db, as_user, make_user, _root, monkeypatch, upload_headers
):
"""The sweep, held end to end rather than at one site.
`display_text` was introduced for the upload path and applied to three of the five writers of
this varchar(255) column; `POST /api/downloads` and `POST /api/downloads/edit` kept the old
`(x or "").strip() or None` and would still have answered a bare 500 (a psycopg DataError,
which main.py re-raises for everything but the NUL case) to a name a user simply pasted. Found
by four independent readers in deep-review round 2 — a sweep is exactly the kind of thing that
gets done four-fifths of the way, so this holds all of it at once.
"""
_probe(monkeypatch, ("h264", "aac", 640, 480, 60))
monkeypatch.setattr(uploadmod.editmod, "ensure_poster", lambda _a, _root: None)
long_name = "n" * 300
bound = "n" * DISPLAY_TEXT_MAX
c = as_user(make_user())
# 1. the upload route — the name arrives in a request header.
up = c.post(
"/api/downloads/upload", content=b"video", headers=upload_headers("a.mp4", long_name)
)
assert up.status_code == 200, up.text
assert up.json()["display_name"] == bound
# 2. the enqueue route — a plain JSON field, from the Add-download dialog. An inline `spec`
# rather than a profile id, so the test does not have to seed the builtin profile table.
enq = c.post(
"/api/downloads",
json={"source": "dQw4w9WgXcQ", "spec": {"kind": "video"}, "display_name": long_name},
)
assert enq.status_code == 200, enq.text
assert enq.json()["display_name"] == bound
# 3. the edit route — the same field, from the editor.
edit = c.post(
"/api/downloads/edit",
json={
"source_job_id": up.json()["id"],
"edit_spec": {"trim": {"start_s": 0, "end_s": 1}},
"display_name": long_name,
},
)
assert edit.status_code == 200, edit.text
assert edit.json()["display_name"] == bound
# 4 + 5. the metadata PATCH's two fields.
patched = c.patch(
f"/api/downloads/{up.json()['id']}",
json={"display_name": long_name, "display_uploader": long_name},
)
assert patched.status_code == 200, patched.text
assert patched.json()["display_name"] == bound
assert patched.json()["display_uploader"] == bound
def _display_write_sites() -> dict[tuple[str, str], set[str]]:
"""Every place under `backend/app/` that puts a value into a display field, as
(module relative to `app/`, enclosing function) -> the set of value expressions written there.
TWO SHAPES, because the first version of this guard walked only `ast.Assign` and was therefore
blind to the shape the upload route itself uses — `SpooledUpload(..., display_name=...)` is an
`ast.keyword`, not an assignment, so the one site with an unbounded REQUEST HEADER behind it was
the site the guard could not see (deep-review round 3).
ANNOTATED assignments are excluded on purpose: `display_name: Mapped[str | None] =
mapped_column(...)` in `models.py` and `display_name: str | None = None` in a Pydantic model are
declarations of the field, not writes into it.
The whole of `app/`, not just `app/routes/`: a job row is constructed in `downloads/service.py`
and `downloads/upload.py`, so a walk stopping at the HTTP boundary misses the writers that
actually reach the column.
"""
app_dir = pathlib.Path(__file__).resolve().parents[1] / "app"
found: dict[tuple[str, str], set[str]] = {}
for py in sorted(app_dir.rglob("*.py")):
module = py.relative_to(app_dir).as_posix()
tree = ast.parse(py.read_text("utf-8"), filename=str(py))
enclosing: dict[int, str] = {}
for fn in ast.walk(tree):
if isinstance(fn, (ast.AsyncFunctionDef, ast.FunctionDef)):
for node in ast.walk(fn):
enclosing.setdefault(id(node), fn.name)
for node in ast.walk(tree):
if isinstance(node, ast.Assign):
names = {
t.id if isinstance(t, ast.Name) else t.attr
for t in node.targets
if isinstance(t, (ast.Name, ast.Attribute))
}
value = node.value
elif isinstance(node, ast.keyword):
names = {node.arg} if node.arg else set()
value = node.value
else:
continue
if not names & {"display_name", "display_uploader"}:
continue
key = (module, enclosing.get(id(node), "<module>"))
found.setdefault(key, set()).add(ast.unparse(value))
return found
# Every write into a display field, with the expression each one writes. A varchar(255) column with
# a request header behind it turns a too-long name into a psycopg DataError — a 500 AFTER the whole
# file has transferred — so each of these has to be bounded, and each one here has been read.
#
# Bounded by `display_text` unless noted:
# auth.get_or_create_demo_user a literal.
# service.redownload CLEARS the field so the fresh metadata wins — `None` is the
# one value that needs no bound, and a clear is still a write.
# service.enqueue / enqueue_edit `display_name` is already bounded by its caller; the fallback
# slices the YouTube title to the same width.
# downloads.upload_download passes through `spool_upload`'s value, bounded there.
#
# Held as EXACT EQUALITY rather than as a list of offenders. The offender form could only fail
# upward: refactoring the writers into a shape the walker did not recognise left it asserting
# `not []` and enforcing nothing, which is how a drift guard dies quietly (round 3). This one fails
# when a site appears, when one disappears, and when one changes what it writes.
DISPLAY_WRITE_SITES = {
("auth.py", "get_or_create_demo_user"): {"'Demo'"},
("downloads/service.py", "redownload"): {"None"},
("downloads/service.py", "enqueue"): {
"display_name or (asset.title[:DISPLAY_TEXT_MAX] "
"if asset.status == 'ready' and asset.title else None)"
},
("downloads/service.py", "enqueue_edit"): {"display_name or asset.title[:DISPLAY_TEXT_MAX]"},
("downloads/upload.py", "ingest_upload"): {"display_text(display_name)"},
("routes/auth.py", "callback"): {"display_text(userinfo.get('name'))"},
("routes/auth.py", "_complete_link"): {"display_text(userinfo.get('name'))"},
("routes/downloads.py", "enqueue_download"): {"display_text(body.display_name)"},
("routes/downloads.py", "enqueue_edit"): {"display_text(body.display_name)"},
("routes/downloads.py", "update_download_meta"): {
"display_text(body.display_name)",
"display_text(body.display_uploader)",
},
("routes/downloads.py", "upload_download"): {"upload.display_name"},
("routes/downloads.py", "spool_upload"): {
"display_text(_header_text(request, UPLOAD_DISPLAY_NAME_HEADER))"
},
}
def test_no_code_path_assigns_a_display_field_without_the_shared_helper():
"""The drift guard for the test above: it holds five routes, this holds the rule.
Parsed, not grepped. Every value that reaches a display field takes a string a user typed (or
YouTube supplied) and puts it on its way to a varchar(255) column, so every one of them must be
bounded. A writer added tomorrow fails here instead of 500ing in production.
"""
found = _display_write_sites()
moved = set(found) ^ set(DISPLAY_WRITE_SITES)
changed = {
key: (found[key], DISPLAY_WRITE_SITES[key])
for key in set(found) & set(DISPLAY_WRITE_SITES)
if found[key] != DISPLAY_WRITE_SITES[key]
}
assert found == DISPLAY_WRITE_SITES, (
"a display-field write appeared, moved or changed shape. Every value reaching "
"display_name/display_uploader must be bounded to models.DISPLAY_TEXT_MAX — normally by "
"models.display_text — or a name longer than the column is a 500 rather than a truncation. "
f"Sites that appeared or vanished: {moved}. Sites that changed: {changed}."
)
def test_the_display_text_bound_matches_the_columns_it_guards():
"""A number living in two places is how the `cancelled`/`canceled` bug happened. `display_text`
truncates to `DISPLAY_TEXT_MAX`; the columns it feeds are `String(255)`. Held against the model
itself, so widening one without the other fails here rather than as a DataError in production."""
for name in ("display_name", "display_uploader"):
assert DownloadJob.__table__.c[name].type.length == DISPLAY_TEXT_MAX
def test_the_display_name_header_reaches_the_job_and_is_bounded(
as_user, make_user, _root, monkeypatch, upload_headers
):
"""The second header, which had no backend test at all — and the length nothing bounded.
Since P1 the display name arrives in a REQUEST HEADER rather than as a form field, so the only
limit on it was the header size. The column is varchar(255), so a longer one reached Postgres as
a DataError: a 500 AFTER the whole file had been transferred. It is truncated now, by the same
helper the metadata PATCH uses.
"""
_probe(monkeypatch, (None, None, None, None, None))
c = as_user(make_user())
ok = c.post(
"/api/downloads/upload",
content=b"data",
headers=upload_headers("clip.bin", "Nyári vakáció"),
)
assert ok.status_code == 200, ok.text
assert ok.json()["display_name"] == "Nyári vakáció"
long_name = "n" * 300
bounded = c.post(
"/api/downloads/upload", content=b"data2", headers=upload_headers("clip2.bin", long_name)
)
assert bounded.status_code == 200, bounded.text
assert bounded.json()["display_name"] == "n" * DISPLAY_TEXT_MAX
def test_the_upload_holds_no_db_transaction_while_the_body_streams(
as_user, make_user, db, _root, monkeypatch, upload_headers
):
"""P1 moved the body read behind the auth dependency, which put the whole transfer INSIDE the
request's database session: resolving the caller and reading the cap are SELECTs, SQLAlchemy 2.0
autobegins on the first one, and the session then holds one of the twenty pooled connections —
`idle in transaction` to Postgres, with no `idle_in_transaction_session_timeout` to end it — for
as long as the client keeps sending. `db.py` sizes the pool on that not happening and
`plex/playback.py` records the production incident behind the rule.
Asserted on the session rather than on `engine.pool.checkedout()`: the transaction is the cause,
the checkout is its consequence (measured), and the pool counter is shared with whatever else
the test process is holding.
"""
from app.routes import downloads as downloads_routes
_probe(monkeypatch, (None, None, None, None, None))
seen: dict[str, bool] = {}
real_stream = downloads_routes.capped_stream_to
real_release = downloads_routes.release_read_connection
def spy_release(session):
# The PRECONDITION, so this test cannot pass vacuously. Asserting only "no transaction
# while streaming" would stay green if the auth/cap reads stopped opening one at all —
# and under `as_user` the auth SELECT is already overridden away, so that is a live risk
# rather than a theoretical one.
seen["before"] = bool(session.in_transaction())
real_release(session)
seen["after_release"] = bool(session.in_transaction())
async def spy_stream(request, out, limit, refusal):
seen["while_streaming"] = bool(db.in_transaction())
return await real_stream(request, out, limit, refusal)
monkeypatch.setattr(downloads_routes, "release_read_connection", spy_release)
monkeypatch.setattr(downloads_routes, "capped_stream_to", spy_stream)
c = as_user(make_user())
r = c.post("/api/downloads/upload", content=b"data", headers=upload_headers("held.bin"))
assert r.status_code == 200, r.text
assert seen["before"] is True, (
"nothing had opened a transaction before the release, so this test proves nothing — the "
"reads it is meant to follow must have moved or been stubbed away"
)
assert seen["after_release"] is False
assert seen["while_streaming"] is False, (
"the request's session was inside a transaction when the transfer began, so a pooled "
"connection is pinned for the whole upload"
)
def test_the_usage_route_reports_the_per_file_upload_cap(as_user, make_user, db):
"""The dialog refuses an over-cap file off this number, and it is the only layer that produces
a refusal carrying the limit on a default install — so the field is load-bearing, not
decorative. Nothing held it: the route had no test at all, and the frontend's `DownloadUsage`
is an unvalidated cast, so dropping the key would have been green everywhere (round 2).
"""
from app import sysconfig
c = as_user(make_user())
r = c.get("/api/downloads/usage")
assert r.status_code == 200, r.text
assert r.json()["upload_max_bytes"] == sysconfig.get_int(db, "download_upload_max_bytes")
# And it is the KNOB, not a constant: a lowered override must show through.
sysconfig.set_value(db, "download_upload_max_bytes", 4096)
db.commit()
assert c.get("/api/downloads/usage").json()["upload_max_bytes"] == 4096
def test_a_refused_upload_leaves_nothing_in_the_staging_directory(
as_user, make_user, db, _root, monkeypatch, upload_headers
):
"""`spool_upload` writes the body to a staging temp file as it arrives, so a refusal happens
with a partial file already on disk — its `finally` is what removes it. Nothing asserted that:
deleting the unlink left the whole suite green while every refused upload leaked its partial
bytes onto the volume, one file per attempt, with no job to ever clean them up.
"""
from app import sysconfig
sysconfig.set_value(db, "download_upload_max_bytes", 8)
db.commit()
staging = Path(settings.download_root) / ".staging" / "uploads"
c = as_user(make_user())
r = c.post("/api/downloads/upload", content=b"x" * 4096, headers=upload_headers("leaky.bin"))
assert r.status_code == 422
assert staging.exists(), "the dependency did not get as far as creating the staging dir"
assert list(staging.iterdir()) == [], "a refused upload left its partial file behind"
def test_upload_cap_cannot_be_disabled_with_zero(db):
"""download_upload_max_bytes floors at 1: a stored 0 would read falsy in the route's
`if cap and size > cap` guard and SILENTLY disable the per-file cap, so set_value must reject it.
Pins the min=1 invariant so a revert to min=0 can't pass the gate."""
"""download_upload_max_bytes floors at 1, and set_value must enforce it.
The falsy-0 footgun this originally guarded is gone — the route's old `if cap and size > cap`
read a stored 0 as "no cap at all", while `capped_stream_to` compares unconditionally and a 0
would refuse everything instead. Refusing everything is the safe direction, but it is still
wrong, and the knob is also the number the admin page and the 422 `limit` advertise. Pins the
min=1 invariant so a revert to min=0 can't pass the gate."""
from app import sysconfig
with pytest.raises(ValueError):
sysconfig.set_value(db, "download_upload_max_bytes", 0)
def test_upload_route_rejects_empty_file(as_user, make_user, _root, monkeypatch):
def test_upload_route_rejects_empty_file(as_user, make_user, _root, monkeypatch, upload_headers):
c = as_user(make_user())
r = c.post(
"/api/downloads/upload", files={"file": ("empty.bin", b"", "application/octet-stream")}
)
r = c.post("/api/downloads/upload", content=b"", headers=upload_headers("empty.bin"))
assert r.status_code == 400
assert r.json()["detail"]["code"] == "upload_empty"
def test_redownload_rejects_an_upload(as_user, make_user, _root, monkeypatch):
def test_redownload_rejects_an_upload(as_user, make_user, _root, monkeypatch, upload_headers):
_probe(monkeypatch, (None, None, None, None, None))
c = as_user(make_user())
job = c.post(
"/api/downloads/upload", files={"file": ("a.bin", b"data", "application/octet-stream")}
).json()
job = c.post("/api/downloads/upload", content=b"data", headers=upload_headers("a.bin")).json()
r = c.post(f"/api/downloads/{job['id']}/redownload")
assert r.status_code == 409
def test_edit_rejects_a_generic_upload_source(as_user, make_user, _root, monkeypatch):
def test_edit_rejects_a_generic_upload_source(
as_user, make_user, _root, monkeypatch, upload_headers
):
_probe(monkeypatch, (None, None, None, None, None))
c = as_user(make_user())
job = c.post(
"/api/downloads/upload", files={"file": ("a.bin", b"data", "application/octet-stream")}
).json()
job = c.post("/api/downloads/upload", content=b"data", headers=upload_headers("a.bin")).json()
r = c.post(
"/api/downloads/edit",
json={"source_job_id": job["id"], "edit_spec": {"trim": {"start_s": 0, "end_s": 1}}},
+1 -1
View File
@@ -37,7 +37,7 @@ def test_a_failure_is_sorted_by_what_the_user_can_do(exc, reason):
("route", "target"),
[
("/api/sync/subscriptions", "app.routes.sync.import_subscriptions"),
("/api/playlists/sync-youtube", "app.routes.playlists.sync_user_playlists"),
("/api/playlists/sync-youtube", "app.routes.playlists.pull_user_playlists"),
],
)
def test_a_manual_sync_youtube_refuses_is_a_422_not_a_500(
+638
View File
@@ -0,0 +1,638 @@
# DESIGN.md — how Siftlode looks and behaves
This is the one written source of truth for the UI: principles, tokens, the canonical solution per pattern,
and the interaction rules. Screens are measured against it. A pattern that has more than one look in the
code is either a decision still open here (marked **Pending**) or a violation to fix.
**Status:** written in E28; every chapter is _Decided_ and in force. The design lab (below) renders each rule's
chosen form. A new question opens a **Pending** item here, with its variants in the lab, until the product
owner settles it.
## How this document changes
- A rule changes only through an explicit decision recorded in the [decision log](#decision-log): what was
decided, the options it was chosen from, and why. No mid-sprint taste changes. A new idea goes on the
list for the next design round.
- Every rule rests on a named standard: Nielsen's heuristics, WCAG 2.2 AA, WAI-ARIA APG, Apple HIG or
Material. The tag is in brackets after the rule.
- A rule stated here should also be enforced in code (an eslint rule, a primitive's type, a test). A rule
that lives only in prose drifts. That is the finding that started this document.
## Conformance — the check before a UI change is done
A screen is measured against this document, not against taste. Every change that renders something new
runs this check before it is called done (E28 S3g):
1. **Primitives at a named rung.** Each control goes through its primitive (lint refuses the raw element,
and a `<div role="button">` with it), at a rung or role this document names: a `Button` size, an
`IconButton` size, a `Chip` size, the `SegmentedToggle` rung, a `SURFACE` role. The lint holds the
element, not the rung, so the rung is checked by eye and by measure.
2. **The pattern's chapter, re-read.** The change matches the chapter of every pattern it touches, or it
opens a **Pending** item and a log row. It never quietly adds a second look.
3. **Measured.** On the changed pages, the rendered elements are grouped by kind (button, icon button,
chip, field, tab, segment, menu row, surface) and by their computed look (radius, height, type, fill,
edge). Each distinct look maps to a rung or role named here; a look that maps to none is a violation
or a new decision. The S3g baseline across the 15 pages at 1920 × 1080: every button, icon button,
chip, field, tab, segment and menu row is drawn by its primitive. What the measure still counts as
hand-drawn is by design: text links (no primitive), surfaces painted by their `SURFACE` role (a class,
which the measure cannot trace to a component) and the playlist table's own rows.
4. **Seen.** A screenshot of every changed state, thin edges at 1:1; the tablet widths (1024 × 768, a
narrow split screen) when the layout can reflow. Phones are the E29 epic's, not this check's.
## The design lab
`npm run dev`, then open **`http://localhost:5173/design-lab`**. The lab renders each open decision's variants
with the app's real CSS, tokens, glass and themes, and has its own switches for theme, scheme, text size and
backdrop. It is dev-only: `main.tsx` reaches it behind `import.meta.env.DEV`, and the build plugin
`frontend/devOnlyGuard.ts` fails a production build that would contain it. Once a decision is taken, the
losing variants are deleted and the chosen one stays as the reference rendering: the lab is the living
style guide.
## Principles
1. **Glass over a calm background is the look.** Surfaces are translucent layers (`.glass`,
`.glass-card`, `.glass-menu`, `.glass-media`) over a per-scheme backdrop. Depth comes from the
**header + fade**: a crisp sticky header with a short gradient just below it, so content dissolves as it
slides under. The reference is `DataTable`'s sticky header. A flat opaque box where a glass surface
belongs is a violation.
2. **Built for one known, daily user, on a desktop and a tablet.** Density is welcome where a mouse is
the pointer; on touch every target grows. Nothing is designed for phones.
3. **One primitive per pattern.** Where a `components/ui/` primitive exists and is used, the looks don't
drift. The drift lives in hand-rolled markup. A new generic control goes into `ui/`, and a screen
composes primitives rather than restyling them.
4. **Measured, not eyeballed.** Contrast, target sizes and layout at the contract sizes are measured in a
real browser before a UI change is called done.
5. **Honest states.** An error never renders as an empty state; loading, empty and error are three
different screens (`QueryState` / `StateMessage`).
## Device adaptation — _Decided_
- **Supported:** tablet portrait and landscape and every size above them (from 768px wide). Phones are not a
target. Measure a UI change at 1024×768, 820×1180 (portrait) and a desktop size.
- **Adapt by capability, never by user agent:** `@media (pointer: coarse)` and `(hover: none)` decide touch
behaviour; container queries decide how a panel lays itself out. Breakpoints are named tokens, never
literal pixel values scattered in call sites. [HIG, WCAG 1.4.10]
- **Touch never loses a function.** Anything revealed on hover is visible at rest on `hover: none` (the
`.reveal` class), or it is removed on touch because the same action is reachable another way
(`.reveal-hover`). [WCAG 1.4.13, Nielsen #6]
- **Targets:** at least 2.75rem (44px) on a coarse pointer, as a real box, not an invisible hit area:
overlapping hit areas fire the wrong control. It covers every control primitive, not only `Button`
(A3, under Controls). [HIG, WCAG 2.5.8]
- **Text inputs** use at least 1rem text on a coarse pointer, so a tablet browser doesn't zoom the page
when an input gets focus.
- **Side panels (C2):** on touch, and at any width below 768 (a narrow window, split screen), the rail tab
or the band's "Filters · n" button slides the panel **over** the content with a scrim; a tap outside, Esc
or Back closes it, and the content never reflows. Pinning is its own button in the panel header, offered
only where a pinned panel leaves the content room (≥ 1024 wide). The mouse keeps hover-to-peek and
click-to-pin. A filter is never unreachable at any supported width. Built in S3f3: `ui/SidePanel`
opens `ui/Modal`'s `drawer-start` placement (the dialog's own Esc, Back, scrim, focus trap and scroll
lock); the tab shows at every width and is 44 wide on touch; a pin saved on a desktop is left alone
below 1024, not honoured. [Nielsen #7, WCAG 1.4.10]
- **A row answers to its own width.** A list row that must fit a narrow column (the download row, the
playlist table, the Channels tag row) stacks by a container query on its own box, not by the
window: the same list sits beside a rail, in a split-screen tablet and in a zoomed-in browser. A
line of facts (the download row's "Ready · MP4 · 18.9 MB · 1:11 · 1w ago") is one line cut at its
end, never a flex row that breaks its pieces apart.
- **A table gives its secondary columns back.** By the table's own width, as a row does: below 42rem
a column marked `narrowHidden` steps aside, below 64rem (a 1024 window) one marked `tabletHidden`
does, so the name keeps its room. The Channels table drops Prio, About and Length there (at 1024 the
name had 60 px); the row's "…" still sets the priority.
- **Players on touch (C3):** see [Players](#players).
## Tokens
- **Colour:** only semantic tokens (`--bg --surface --card --fg --muted --accent --accent-fg --danger
--warn --success --info --border`, each with a `-fg` partner where text sits on it), and no palette class
(`text-amber-500`: lint-refused). A score's gold is `--rating` (theme-scoped, 4.5:1 on the page and on its
own chip), never `--warn`: a score is not a warning. What sits on a picture looks the same in both themes,
because the picture does: the broadcast red `--live` under `--live-fg` (never `--danger`: "live" is not an
error), `--rating-media` on a poster's scrim, and `--media-chrome` for the always-dark player's menus
(owner decision F16). Black and white are the media scrims and their text. Five schemes × dark
and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (moss) each earn their
place with their own hue family; **Fjord** (Midnight's navy with a calm steel-blue accent) is the one kept
exception, the calm twin of the vivid Midnight. A new scheme still needs its own hue family. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode.
[WCAG 1.4.3, 1.4.11] The input edge token is set under Controls (A4). The ≥ 3:1 edge is the field
family's (a field, the form's dropdown trigger), whose edge is the only thing that says "type here". A
labelled pill (a chip, a toolbar trigger, a tab, a segment) keeps the faint `--border`: its text names it,
and 1.4.11 does not ask an edge of a control its label already identifies (owner decision F14).
- **The accent never looks like a status.** A scheme's accent is at least 30° of OKLCH hue away from
`--danger`, `--warn` and `--success`, and passes 4.5:1 both as text on `--bg` and under `--accent-fg` on
the fill, in dark and light. A primary button must never read as a destructive one. A new scheme is
measured against this before it ships (the lab's B2 tiles do the arithmetic). [WCAG 1.4.1, 1.4.3]
- **Held in code:** `frontend/src/lib/theme.contract.test.ts` measures every scheme × mode in `index.css`
against the two rules above (text 4.5:1, a control edge 3:1, the status and rating colours as text, the accent's
hue gap), and the media colours against what they sit on. A scheme or a retuned colour that breaks them does not pass the gate.
- **No per-user colour tuning.** The schemes are the only colour choice; the old floating glass tuner was
retired (E28), because free tuning is exactly the drift these rules exist to stop.
- **Backdrops:** one per scheme and mode (`public/backdrops/`), soft pools in the accent's family, with a
grey `overlay` grain that breaks the gradient banding (measured by pixel-scanning a canvas, never a
screenshot, which rescales).
- **State layers (B3):** hover and pressed are a layer, not a colour swap: the text colour mixed at **10%**
(hover) / **16%** (pressed) over whatever the control sits on, so it reads on the page, a card and glass
alike. A filled control mixes its own text colour into its fill at 14% / 24%. Opacity dimming and
`bg-card` hovers are violations (invisible on a light card). Selected stays the solid accent. The one
exception is an edged pill (a chip, a toolbar or form trigger, a segment): its hover is the accent edge
(`.pill:hover`, `glass-hover`), which reads on every surface too (owner decision F14).
A card that is one row with actions answers as a whole: the layer is the card's, under its badges and
buttons, and the row's open button spans the card beneath the actions (a hover that stopped at the first
badge read as broken; owner, E28 — `AdminUsers` user rows).
- **Tokens as utilities:** the decided values live once in `index.css` — `--hover` / `--press`,
`--edge-strong`, `--touch-target`, and the utilities `state-layer`, `state-filled`, `state-danger`,
`edge-strong`, `fade-below`, `fade-below-panel`, `fade-above-panel`, `fade-edges-x`. A primitive or a lab
rendering uses them; restating one as an arbitrary class is a violation.
- **Type and size:** everything is rem-derived off `--font-scale` (the Settings text-size slider), so a
px font size or a px-frozen box is an eslint error.
- **Radius:** chips and segmented controls `rounded-full`; tabs `rounded-lg`. Buttons, icon buttons and
form fields `rounded-lg`; toolbar triggers and search a pill (Controls). Surfaces: see Surfaces.
- **Layers:** the fixed z-index scale in `index.css` (`base < menu < chrome < paneltab < panel < rail <
overlay < popover < tooltip`), plus `under`, a surface's own layer under its content inside its `isolate`.
No ad-hoc z-index. [maintainability]
## Controls — _Decided_ (batches A and B)
The chosen rendering of each rule is in the lab under "Decided". The primitives are built to it in S3.
- **Buttons (A1):** `rounded-lg` (r8): a control's corner is smaller than the surface it sits on (card r12,
dialog r16). The rung follows the context: `md` in forms and dialog footers, `sm` in toolbars and panel
headers, `xs` inside a table row. Glass is a `Button` variant, not a hand-built glass button. [HIG, Material]
- **Density (B1): compact, by explicit height.** `md` 2rem (32), `sm` 1.75rem (28), `xs` 1.5rem (24); icon
buttons 1.75rem / 2rem. A toolbar's search pill, dropdown trigger, `sm` button and small icon button are
the same 28, so the row lines up. Nothing goes under 24 (WCAG 2.5.8); touch grows it to 44 (A3).
- **Icon buttons (A2):** two sizes (28 / 32), r8: the smaller in rows and dense toolbars, the larger on its own. On a
picture, a round, dark translucent button, readable on any thumbnail. Round means "floats over something":
on media, or the one floating button over the page (back to top, a 48 glass circle, the FAB); a button
inside a pill field is round too, so the radii nest. A text
button on a picture is a pill (the card's Continue in the accent, Restart dark translucent, C5), and
the one 48px glyph is the Play in the middle of a not-started card (C5).
- **Touch (A3):** on a coarse pointer every control primitive (`Button`, `SegmentedToggle`, `FilterSelect`,
`Tabs`, `Switch`, chips, the nav rail) grows to 2.75rem (44px) through one token, as a real box. Mouse
sizes don't change. [HIG, WCAG 2.5.8]
- **Input (A4):** one bordered input primitive, `ui/Input` (`Textarea` for several lines; a frame that also
holds an icon or chips is painted with `fieldClass` around an `InnerInput`). The edge is
`color-mix(in srgb, var(--fg) 55%, var(--bg))`, ≥ 3:1 against the page in every scheme and mode; focus
turns it into a 2px accent edge, `invalid` into the danger edge. A form field is r8 at the `md` height
(32); a toolbar search, a field in a popover or an inline rename is `sm` (28), the toolbar search a pill;
the page's one hero field is `lg` (40): the header search, a glass pill floating over the page (the
YouTube/Gmail shape), and the command palette's field;
a field in a table row or the pager `xs` (24); an inline rename of a page title keeps the heading's type
(`heading`). Text is 1rem and the box 44 on a coarse pointer. [WCAG 1.4.11]
- **Dropdown trigger (A5):** `ui/SelectTrigger`. A pill in a toolbar (where the pill means "a filter or a
choice"), r8 and full width in a form, where it wears the field's frame (see-through, the ≥ 3:1 edge) and
lines up with the inputs. Open is the accent edge and the chevron turned over.
- **Checkbox, switch, slider (A6):** the browser's own controls tinted with `accent-color` (`Checkbox`,
`CheckRow`, `Slider`); `ColorInput` is the browser's colour well, which `accent-color` does not reach (it
tints only checkboxes, radios, ranges and progress bars). The whole labelled row is the 44px target. Keyboard, screen reader and
forced-colours support come from the browser. The switch stays `form`'s `Switch`: no browser has a native
one yet. A raw `<input>` or `<textarea>` outside those primitives is a lint error (a hidden file picker
passes). A checkable row that cannot hold an `<input>` — a menu row, a whole-row button whose role says
checkbox — draws the ONE `ui/MenuCheck` in its leading icon slot: a box filled with the accent for a set
(tags, playlists, a filter's values; a minus for a group only partly in), a bare tick for one choice (a
language, a sort, a priority step), an unticked choice keeping the slot empty so the labels line up.
- **In-place editing:** a field that replaces its trigger (a title rename, the priority number) hands the
focus back to that trigger when Enter or Escape closes it (`useReturnFocus`), never to `<body>`.
[WCAG 2.4.3]
Also in force:
- **One primitive per button family** (E28 S3b): a labelled action is `Button` (a rung from the ladder,
plus `lg` 40 for a page's single hero call to action, and nothing else); a glyph is `IconButton` (a
required `label`); a card, row, tile or menu row whose content is the control is `Pressable`; a filter or
tag chip is `Chip`; a sortable row's grip is `DragHandle`. On the always-dark player chrome both
`Button` and `IconButton` use the `overlay` variant (white, a white layer), whatever the theme; the Plex
control row is IconButton's `player` rung (36, a 20px glyph, round, 44 on touch). A pressed toggle is
`pressed`: the solid accent on a `Button`, the accent glyph on an `IconButton`. A raw `<button>` (or a bare
`FocusSafeButton`) outside the control primitives (eslint `BUTTON_PRIMITIVES`) is a lint error. A text-less button must have an accessible name, and the type enforces
it. [WCAG 4.1.2]
- **The chip family** (`Chip`, E28 S3b): rounded-full, never under 24 (A3 grows it to 44 on touch).
`filter` is a selectable option, solid accent when `pressed`; the "+N" and "More…" triggers are filter
chips too, and while their popover is open they show it as an open trigger does, the accent edge and
text (not the solid fill, which would say "selected"); `token` is an applied narrowing, the accent tint, the whole chip removing
it; `tag` is a user's tag, the solid accent; `suggest` is a suggestion not yet taken (dashed accent);
`ghost` the quiet "add a tag" (dashed neutral). A chip whose label is not the control but carries its
own × is `DismissibleChip`: two sibling controls at most, never a button in a button. An overflowing chip
lane measures a hidden mirror painted with `chipClass`, so the mirror cannot drift from the chips.
- **Unavailable with a reason** stays focusable: `aria-disabled` plus the reason in the label or tooltip,
never native `disabled`, which drops it from the tab order with its explanation. [WCAG 2.1.1]
- A choice between a few values is `SegmentedToggle`; a long list or a user's own list is `FilterSelect`.
Never a native `<select>` (lint-enforced). Its rung is the toolbar's, 28 with `text-xs`, so it lines up
with the triggers and the pager's buttons; the 24 rung with the smallest type is only a side panel's
captioned match toggle (S3g: the old small default had put a pager and a toolbar count on 10px type).
- **A multi-value filter's any/all switch** (tags, topics, languages, genres, collections, actors) is
`ui/MatchToggle`, on the panel right above its picker, from two picks on — never in the picker's
popover, where a closed picker hid that "all" was on. One wording: Match · Any / All. [Nielsen #1]
- **The pager** is four glyphs — first, previous, next, last — round the page box (`IconButton`s, their
names as the tooltip); no words, at any width.
- A single-select row of filter chips goes through `FilterChipGroup` (its options are `Chip`s).
- A button that starts slow work shows `loading` (a spinner, `aria-busy`) and can't be pressed twice.
[Nielsen #1]
- **A glyph means one thing.** Play (▷) starts playback, or resumes a paused transfer; a retry is the
refresh arrow (`RefreshCw`); a reset or revert is the counter-clockwise arrow (`RotateCcw`). A failed
download's Retry had worn the Play glyph, which promised a video. [Nielsen #4]
## Surfaces and page chrome — _Decided_ (batch B)
- **Surface per role (B5):** a dialog or panel is `.glass` r16; a card or picker `.glass-card` r12; a menu
`.glass-menu` r12 with 4px padding and r8 items (12 − 4, so the corners nest); a tooltip `.glass-menu` r8.
Translucency comes only from the tiers (the app backdrop, Plex media, light); a per-site alpha override is a
violation. A call site names the ROLE (`SURFACE.panel` / `drawer` / `card` / `menu` / `popover` / `tooltip`,
`ui/surfaces.ts`), which brings the tier AND the radius; a page section is a panel, and what floats over
undimmed content (a menu, a hover card, a status popover, a toast, a tooltip) is `.glass-menu`. A bare glass
class is a lint error. A role's depth is its own shadow (the top highlight + the drop); a `shadow-*` size beside
a role would replace both and is a lint error too — a field that floats takes `shadow-float`, the same shadow.
- **Static vs pressable (owner, S3d):** a label that does nothing (a topic, a keyword, a status, a count) is a
flat, tonal `Badge` with no edge; everything that does something has an edge (a chip, a trigger, a field).
A genre that filters the library is a chip; the same genre where it can't is a badge. A badge's `tone`
(neutral / accent / warn) is its own colour at 6%, measured at 4.5:1 in every scheme; a `<span>` drawn with a
round edge is a lint error (F29). A toggle button states itself through `pressed` (the solid accent), never a
bare `aria-pressed`, which the type refuses (F28).
- **A link that looks like a button** (a download, a sign-in that leaves the app) is `LinkButton` /
`IconLink`: still an `<a>`, wearing the button's own face.
- **Page chrome (B4):** a page's tabs and filters sit in ONE sticky band of the page colour, with the fade
under it (below). Below ~600px of height the filter row folds into one "Filters · n" button. Every page has
a visually hidden `h1`; the nav rail and the browser tab title are the visible identity. [WCAG 2.4.6]
Built as: the band is `PageShell`'s toolbar slot (`PageToolbar`), above the one scroller; a console
whose toolbar lives inside its frame (the server log) makes that toolbar `sticky` with `fade-below`
instead. The band holds controls only: an intro paragraph scrolls with the content (in the band it
ate three lines of a short window). The fold is `useFilterFold` + `FilterFoldButton`: the same row
shown or hidden in place, never copied into a popover. The hidden `h1` is `PageShell`'s `title`; a
view with its own visible title (a channel, a Plex show, a playlist) renders it as `PageHeading`
and the hidden one steps aside, so a page has exactly one `h1` (a hand `<h1>` inside the shell is
refused by a contract test). A section's caption is `SectionLabel`, an `h2` (an `h3` in a dialog),
never a styled `div`. The first Tab stop is "Skip to content" (`SkipLink`), which focuses the
scroller's `<main>`. [WCAG 2.4.1, 1.3.1]
- **The header + fade edge (B9):** a crisp band and a 1rem gradient of the colour it sits on, so content
dissolves as it slides under; it shows only once something is under it, never over a list at rest. It goes on sticky toolbars and table headers, tabs over a scrolling panel, a
long dialog's header and footer, and the clipped ends of an overflowing chip row. Not on thumbnails (they
are the content) and not on buttons (it becomes a dated gloss). Built as: a sticky element takes the
`fade-below` utility (`fade-below-panel` / `fade-above-panel` on a panel); the page band and a
scrolling panel under its header fade by the scroller's own edge mask (`useScrollFade`), because what
they sit on is the backdrop, not a colour. The chip rows fold their overflow into "+N" rather than
scroll, so no row has clipped ends; the carousels fade theirs. The dialog edges come with C4's pinned
footer (S3f).
- **Viewport height:** `dvh`, never `vh` / `h-screen`: `100vh` is the viewport with a mobile browser's
toolbar retracted, so an `h-screen` shell runs its last rows under the toolbar (contract-tested).
- **Nav rail (B7):** the `ui/NavRail` primitive, slim: 3.75rem wide. "You are here" is a soft accent tint plus a
3px edge bar. Labels under the icons are optional, a per-user switch in Settings (off by default); they add
height, never width, so each module has a short rail label that fits, and the full name stays in the
tooltip. No wide padding around the icons. The switch is Settings → Appearance → "Labels in the side
menu"; with the labels on, a row's accessible name is its visible short label (WCAG 2.5.3), and an
unread count is part of the name, not only drawn.
- **Loading (B8):** a first load of a grid, list or table shows its skeleton (`LoadingState shape="grid" |
"poster" | "list" | "table"`, built from the `Skeleton` primitive), so
nothing jumps when the data lands; a control's own work shows its spinner (`loading`). Neither appears
before ~300ms. [Nielsen #1]
- **The public watch page (B6)** follows these same rules (glass, `Button`, the pressed-pill toggle, the
scheme's backdrop); only the video keeps the browser's own controls, because a stranger's device is
unknown.
Already in force:
- The glass role is load-bearing: a dropdown or menu is `.glass-menu` (it floats over undimmed content, so a
card alpha would bleed through), a dialog is `.glass`, and a grid card is `.glass-card`.
- Every overlay is a `Modal`/`Overlay` or an anchored popover from the one popover engine. Escape closes the
topmost layer only, and focus returns to the control that opened it. [WCAG 2.4.3, 2.1.2]
## Action placement — _Decided_
Which actions are visible and which live in a menu.
- **One menu, several triggers.** An object's actions are ONE list. The visible "…" button, a right-click,
a long-press (touch) and Shift+F10 / the Menu key all open the same `ContextMenu` built from that list.
A second, hand-made menu for the same object is a violation.
- **Nothing is reachable only by a gesture.** Every menu action also has a visible path: the "…" button,
the page toolbar, or the object's detail view. A right-click is a shortcut, never the only door.
[Nielsen #6, #7]
- **What stays visible:** the object's primary action (play or open) and any state the user needs to see
(watched, saved, downloading). **What goes into the menu:** rare, secondary and destructive actions.
Destructive ones keep their confirmation. [Nielsen #8, #5]
- **On touch, a card shows fewer actions and a "…" button.** The rest are in the menu.
- **The classification (C1)** says, per object, what is visible at rest, on hover and on touch; its full
table is the lab's C1 reference, and S3 turns it into each object's one action list in code. In short:
- A video or Plex card: play and its state (watched, progress) at rest; the everyday toggles (watched,
save, add to playlist) and "…" on hover; on touch the state and a 44px "…" beside the title. Details,
download, hide and reset live in the menu.
- A row (playlist item, channel, download): its primary action and its values (priority, tags, error)
stay visible; destructive and rare row buttons (unsubscribe, delete, cancel, remove) move into the menu.
A playlist item's X column goes: Delete key plus an undo toast.
- Downloads, Notifications and the Scheduler get a menu (they have none today); a page's destructive
header action ("Clear everything", "Purge discovery") goes into the header's "…".
- **Built as:** one `useContextMenu` per list; the row spreads `triggerProps` (right-click, long-press,
Shift+F10) and its "…" is `ui/MoreButton` with `buttonProps` (the same menu, under the button; a second
press closes it). An object's list is one builder (`useVideoActions` for a video, `usePlexCardActions`
for a Plex title, composed by their lists and by Home's rails); a row that opens a panel ("Add to playlist…", "Details") anchors it to
`menu.anchor`, the element the menu came from. "Copy title" / "Copy link" come from `useCopyItems` and
run inside the click (`inGesture`, Safari's clipboard rule). The card's hover row sits on its picture
(the duration steps aside while it shows); on touch the toggles are gone and a 44px "…" sits beside
the title (`.touch-only`). The hover chrome and what steps aside for it are one pair of class strings
(`ui/reveal.ts`), shared by the video and the Plex cards. A table or rail ROW shows its "…" at the
row's end on hover or focus and always on touch (`.reveal`). A playlist's rows take Remove from the
Delete key too (the `listDelete` key scope, registered only by a list that can remove) and answer it
with an undo toast whose Undo puts the row back at its index in one request; a playlist itself has
one list (`usePlaylistActions.items`) that its rail row and its page header's "…" both open. A
subscribed channel's row ends in its "…" (`useChannelActions`: open, pin, Tags, Priority, YouTube,
copy, full history, hide with an undo toast, unsubscribe behind its confirm), docked beside the name
in the card layout. Its Tags submenu lists the reader's tags A–Z, each a check that toggles in place
(the menu stays open for the next); its Priority submenu offers the fixed steps 10 · 5 · 3 · 2 · 1 · 0
(an off-scale current value shows ticked in its place), and a pick re-sorts the list at once — only
that row moves — with the pager and the focus (its "…") following it. The row's stepper still sets any
value and leaves the order until the next load. A Plex playlist's title, show group and season each open the page's one row menu
(a title's is its `usePlexCardActions` list plus the removal); removals take the same undo toast,
`j`/`k` walk the visible rows and Delete removes the walked one; its rename and delete sit in the
header's "…". A toast that carries an action lives at least 8 s, whatever the toast setting. A
download row keeps play (pause / resume / retry in the queue) and the save link, a library row also
Share (the link copy, ahead of save), and ends in its "…" (`useDownloadActions`: the editors,
managing the shares, re-download, the copies, cancel / delete / remove
behind their confirms); a link inside a row keeps the browser's own right-click menu. A notification
row keeps its link and its unread mark, shows its dismiss on hover (in the "…" on touch), and its
"…" holds find / undo for an activity notice. The notification center's band keeps Mark all read
and puts Clear everything, now behind a confirm, in its "…"; the Scheduler's strip keeps Run all
and Pause and puts Purge discovery in its "…". A list whose row objects are rebuilt each render or replaced by a
poll passes `useContextMenu({ keyOf })` (its id), so the "…" still closes its own menu. A menu's
groups are `ui/menuGroups`: a rule between
two non-empty groups only.
- **Background menu:** a right-click on EMPTY content area (not over text, inputs, links or media) may open
a page menu. It only mirrors page-scope actions that already exist in the toolbar (sort, view, quick
filters, select all); it adds no new function. Shift+right-click falls through to the browser's own menu.
Desktop only: on touch a long-press on the background clashes with scrolling and text selection, so the
toolbar stays the touch path. Built (`ui/PageMenu`; PageShell listens, `lib/backgroundTarget` decides
"empty") on five pages: the **feed** (sort, reshuffle, view, watch state, content types, source, clear
filters), the **Plex library grid** (sort, watch state, clear filters), the **channel manager** (layout,
the cards' sort, sync status, edit tags, clear the tag filter, read subscriptions from YouTube), a
**playlist** (play all, group by channel, reset view, save order, undo / redo, and the list's own "…")
and the **notification center** (mark all read). Never a destructive row (delete, revert, clear
everything) and never a long quota run (backfill): a stray right-click must not be one click from those.
The tab-only pages keep the browser's menu; `pageMenuScope.contract.test.ts` holds the list.
### Text stays selectable — _Decided_
Wherever there is text, the user can select it and copy it, and no click action overrides that.
- A title or a name inside a click surface stays selectable: the card, row, person, channel or log line
that opens is `selectable` (`ui/FocusSafeButton`, under `Pressable` and `Button`). Chrome selects no
text inside a `<button>` unless it is `user-select: text` (measured), so the flag sets that and the
click guard below. A control's own label (a menu row, an option, a tab, a nav item, a switch) is not
a title and stays plain; `pressableSelectable.contract.test.ts` makes every new `Pressable` choose.
- A click that ends a text selection does nothing: if the selection inside the element is non-empty, or
the pointer moved past the drag slop, no play or open fires.
- A right-click on selected words opens the browser's own menu (Copy, Search, Translate), not the
object's; a right-click beside the selection still opens the object's menu (`ui/useContextMenu`).
- On touch the long-press belongs to the context menu, so the object's menu offers **Copy title** and
**Copy link**.
## Media card — _Decided_ (S3h)
A film, a show, an episode, a video or a pinned channel is ONE card wherever it appears: the Plex grid,
the feed, Home's shelves and hero, the Plex rails, the media-info page's collection strip. A place may
pick a density step; it never changes the anatomy or loses an action. [Nielsen #4]
- **Anatomy (the owner's pick C).** One frame: the picture, the title, the meta line pinned to the
frame's bottom edge, so a one-line title leaves no hole above the meta. What is ON the picture: the
length (a film's runtime, a show's season count, a video's duration) as a dark badge in the corner,
the watched mark, the progress bar along the bottom. Built as `ui/MediaCard` + the classes in
`ui/mediaCardStyles` (`MEDIA_PICTURE`, `MEDIA_IMG`, `MEDIA_BADGE`, `MEDIA_HOVER_LAYER`, `MEDIA_BAR`,
`mediaTitleClass`, `mediaPictureBox`).
- **Play only where the click plays.** The hover layer dims the picture and shows `MediaPlayGlyph`, the
one round media Play (the button family's `media` variant), only on a card whose click starts
playback; a show without a next episode opens instead and shows none. The hover row (watched, save or
add, "…") docks at the picture's bottom-right; the length badge steps aside while it shows.
- **Density.** A grid card: a two-line title and the full meta (Plex: year · ★ score; a video: the
channel, then views · age). On a shelf (any horizontal strip) the title is one line, cut with an
ellipsis, over ONE meta line (Plex: year · ★; a video: the channel; a tile: its count), so every card on
the strip is the same height. A Home shelf slot is 232px: at 200 the Play and the hover row
overlapped by 2px, at 232 they stand 6px apart; a landscape shelf picture takes the smaller Play.
- **A mixed shelf is landscape.** Beside videos a Plex title shows its wide art, not its poster; a
rail's slot is a grid cell, so each card fills the height the strip stretches to. A carousel turns
the cards' content-visibility off (`mediaRail.contract.test.ts`): an off-screen card's reserved height
had become the strip's.
- **The preview (the owner's pick C).** Resting the mouse on a Plex card for ~0.9 s opens a panel beside
it: the whole title, year · length · ★, and the synopsis in short lines (`ui/MediaPreview` +
`useMediaPreview`). Mouse only (a keyboard walk or a tap never opens it; "…" → Media info has the same),
passive (it never takes a click), and any press on the card closes it. Long text never goes in a
native `title` tooltip.
- **Home's hero is a card** (the owner's pick A): the same hover row and Play on its picture, the same
whole menu on right-click, long-press and a touch "…" in its button row. In a wide window it stands
three quarters wide and centred, with even padding; its text is one centred stack — kicker, title,
subtitle (dropped when it is only the year), one fact line, a summary only when there is one.
- **Home's shelf tiles act like their object.** A pinned channel or playlist: Play when it has a next
video, ↗ to open it, "…" with an Open row. A show carries its next episode: the Play, the watched
toggle and add act on that episode ("Next: S6·E16" is its meta line), and an ⓘ opens the show. A
channel's Home menu is play, open and the shelf's own rows; its tags, priority, hide and unsubscribe
stay on the Channels page.
- **The compact list row (the owner's pick B).** The title leads; a faint "…" is always at the row's end;
on hover or focus the quick toggles float in beside it over the date, which steps aside (the Gmail
row). On touch only the "…".
- **The media-info page (layout B).** Under the hero a main column — the collection strips (the same
card), then the crew, then the cast — beside a 20rem sidebar of facts (details, keywords). Every
section is the reader's to hide; a column with nothing in it is not drawn and the other takes the
width. A show's page uses the same meta row: a value that filters is a chip, a plain fact is flat
text, spaced by the gap, no "·".
- **Browse tiles** (genres, playlists, collections) answer the pointer as a card does: the edge takes
the accent, the shadow lifts and the art zooms.
- **A missing picture says what the item is.** No picture at all (a link that failed before its
details, a poster that never came) shows a quiet glyph — a film, a note, a file — never an empty box,
which reads as "still loading". A download with no name yet is titled by its short link, once: no
channel line, no second link line.
- **Reading measure.** Prose (a synopsis, a biography, a channel's description) is capped at ~65
characters a line (the `measure` utility) wherever its container can be wider. [WCAG 1.4.8]
## Interaction rules — _Decided_
- **Keyboard:** everything works by keyboard, in visual order, with no traps. A shortcut is a row in
`lib/keymap.ts`; list and grid navigation is `NavGrid`/`keyboardNav`. [WCAG 2.1.1, 2.4.3, 2.1.2]
- **Focus:** every control has a visible focus ring (the base ring in `index.css`; don't add a second). A
sticky or fixed layer never covers the focused element. A list item the keyboard lands on rings 2 px
outside itself where it has a gap round it (a card); a table row, a log line or a row flush in a
clipping card rings inside itself, following its corners, since outside the ring ran under the table
head or was clipped away. [WCAG 2.4.7, 2.4.11]
- **Menus and tabs follow WAI-ARIA APG:** arrow keys move, Home/End jump, Escape closes and returns focus;
tabs carry `tablist`/`tab`/`tabpanel` roles.
- **Feedback:** a finished action is confirmed briefly (a toast that leaves by itself); anything that must
be read or acted on is not only in a toast. Status changes are announced through a live region.
[Nielsen #1, WCAG 2.2.1, 4.1.3]
- **Errors:** a failed write goes through `meta.failTitle` / `notifyActionError`, never a silent `catch`.
An error sits next to what failed and says how to recover; a field error is under its field and linked
with `aria-describedby`. Colour is never the only signal. [Nielsen #9, WCAG 3.3.1, 1.4.1]
- **Loading:** anything slower than ~300ms shows a loading state; a near-instant load doesn't flash one.
Space is reserved for late content so the layout doesn't jump; the form is under Surfaces (B8).
- **Reordering:** anything you can drag also has a button or menu way to move it (Top / Up / Down / Bottom),
and a drag handle carries `touch-action: none` so a finger drags instead of scrolling. [WCAG 2.5.7]
A list whose rows have a "…" (a YouTube playlist, a Plex playlist: a block among the blocks, an
episode within its show) puts the four moves there (`ui/useOrderItems`); the saved views' editor
shows them as four buttons with the item's place beside the selected view; a side panel in edit mode
gives each group an Up / Down pair (`ui/OrderButtons`, `compact`). A move that would go nowhere is
shown off, not hidden. A grip also picks up from the keyboard (space, the arrows, space).
- **Motion:** 150–300ms micro-interactions, transform/opacity only; `prefers-reduced-motion` is respected.
[WCAG 2.3.3]
## Players — _Decided_ (C3, C5, built in S3f)
- **A tap on the Plex video only shows or hides the controls** (C3); play/pause is the button. The mouse keeps
click-to-pause. Measured live: before this, a tap with the bar hidden both revealed it and paused. [Nielsen #3]
- **The hide timer starts with playback,** not with the first touch (before, on touch, the bar stayed up until
the first tap however long the video played).
- **The bars stay up while the pointer rests on them** (F22, owner); leaving starts a fresh 3 s. YouTube and
Plex Web do the same: before, the row faded out from under a mouse parked on Next.
- **Volume is the native `Slider`** (A6); the hand-drawn bar had no hover, focus, keyboard or screen-reader
state. A pointer drag gives the focus back, so Space and the arrows keep driving the player.
- **The seek bar:** the track stays thin inside a 2.75rem (44px) hit area with `touch-action: none`, so a finger
drag scrubs instead of panning; the scrub head is always visible on touch. Controls are 44 on touch. [WCAG 2.5.8]
- **The Plex control set (C5, the owner's model):** one row that fits a portrait tablet.
- No Stop (closing the player is stopping), no Restart, no ±10 s buttons.
- **Back:** more than 3 s in → back to the start; within the first 3 s → the previous item, at its resume
point if it has one, else its start. The first item has only the restart.
- **Next** shows only when there is a next item (an episode, a playlist queue). It starts that item at its
resume point and leaves one behind on the current item, which does not count as watched unless it is
already inside the finish margin (the credits).
- **±10 s:** the keyboard keeps ← / →; on touch a double-tap on the left or right third of the picture skips
∓10 s, while a single tap still only shows the controls. Touch never loses a function. [WCAG 2.5.1]
- The row: play/pause, Back, Next, mute, the time, audio & subtitles, a fixed **⋯** (player settings, media
info, stats, keyboard shortcuts, download the original file), fullscreen. The volume slider is
mouse only: a tablet's volume is its buttons.
- **Restarting lives on the media card** (C5): not started → Play; started → **Continue** (primary) +
**Restart**, the video card's own pair and words (`MediaResumeButtons`, one component for every card and
Home's hero). A poster stacks the pair; a landscape picture sets it side by side, icon-only on a small one.
On touch a tap continues and the card's "…" has "Play from the beginning" under its Play row. The restart
applies to the item the player opened on (`fromStart`); every item it moves on to resumes.
- **The YouTube modal:** its chrome uses the `Button` rungs (44 on touch), the side arrows stay inside the
screen, and the description opens from an ⓘ button, because a hover popover never opens on a tap (measured
live). [WCAG 1.4.13]
## Dialogs — _Decided_ (C4, built in S3f)
- **Initial focus by purpose** (`Modal`'s required `initialFocus`, so no dialog inherits "the first
focusable", which is always the ✕): `field` — a form opens on the body's first control, and one that
loads after opening moves the focus there when it arrives, as long as the focus still rests on the ✕;
`action` — a confirm, where the least destructive button takes `autoFocus`; `close` — anything to read,
browse or manage. [WAI-ARIA APG]
- **The header and the footer are fixed; only the body scrolls,** with the B9 fade at each edge, so the
primary action stays on screen when the on-screen keyboard takes half of it (the viewport's
`interactive-widget=resizes-content` makes the keyboard shrink the layout, where Chrome would otherwise
slide it over a fixed dialog). A fade shows only while content is under it (the body's scroll timeline):
a dialog that fits draws none. [Nielsen #3, WCAG 2.4.11]
- **The footer is the dialog's commit row** — Cancel / Save, Start, Retry / Close, a confirm's choices —
placed with `ModalFooter` from any depth of the body. A section's own buttons (a sub-form inside a
manager, a list row's Delete) stay in the body; a manager with no commit row has no footer.
## Copy — _Decided_ (S3g)
- Every user-facing string is in both Hungarian and English with zero key drift, and a user-facing error is
a structured `{code, reason}` translated on the client. Hungarian speaks to the user as "te".
- **One name per feature.** The nav rail's label is the feature's name; page titles, menus, shortcuts,
settings tabs and onboarding use the same words. The rail may shorten them ("Config" for
"Configuration", "Listák" for "Lejátszási listák"), never swap in another word. [Nielsen #4]
- **One verb per meaning.** Delete / Törlés: gone for good. Remove / Eltávolítás: out of a list, the
thing itself survives. Dismiss / Elrejtés: a notice leaves the view. Discard / Elvetés: unsaved edits
are thrown away. A confirm uses its action's verb. [Nielsen #4]
- **Sentence case.** Capitals only for brand names (YouTube, Plex, Google) and the app's places used as
places ("Hide from Feed", "Megnézés a Könyvtárban"). [Material, HIG]
- **"…" when the command asks first.** A label ends in "…" when it opens a dialog, form or picker before
anything happens ("Edit tags…"); not on a submenu (its chevron says it) or an immediate action. Progress
text ("Loading…") keeps its own. [HIG, Windows]
- **Messages.** A toast or error of one sentence has no full stop and no "✓": "Couldn't sync
subscriptions" / "Nem sikerült szinkronizálni". Two sentences keep their stops. Help text is prose.
- **Quotes** around a name are curly: “abc” in English, „abc” in Hungarian.
- **Dates**, in the UI language, only from `lib/format.ts` (lint: `local/date-format`): recency in a list
is relative ("3d ago" / "3 napja") through `ui/RelativeTime`, which carries the exact minute on hover;
a date is "Sep 27, 2026" / "2026. szept. 27."; a moment "Sep 27, 2026, 08:30 AM" /
"2026. szept. 27. 08:30"; seconds only in the server log, the audit and the recorder.
- **Units.** A figure with a unit (a length, a countdown, a range of days) is the browser's short form
(`Intl`), through `formatUnit` / `formatRuntime`: "90 min", "2 hr 13 min" / "90 p", "2 ó 13 p"; a fixed
label holding one is written in that same form ("Under 90 min"). Never a bare letter ("90m", "24h",
"7d"; test-held). In a sentence, and in a list of time spans named in words ("24 hours", "1 week"), the
unit is a word. A clock-style length on a picture stays "2:34:11".
- A label says what happens ("Delete playlist"), not "OK"; a destructive confirm names the object.
## Anti-patterns
- A flat `bg-card` / `bg-surface` box where a glass surface belongs.
- A hand-rolled control that restyles what a primitive already does.
- Information or actions that exist only on hover, or only behind a right-click.
- A click handler on text that swallows the user's text selection.
- A colour literal, a px font size, a px-frozen box.
- An empty state shown for an error.
## Decision log
| Date | Decision | Chosen | Over | Why |
| ---------- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 2026-09-24 | Floating button | Back to top: a round 48 glass FAB; "round" = floats over something (media or the page) | A Material 3 rounded square; a labelled pill; the old 64 circle | The common web convention for back to top; round tells it apart from every toolbar button; 64 was oversized |
| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows |
| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" |
| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags |
| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers |
| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass |
| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context |
| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites |
| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 |
| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 |
| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card |
| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content |
| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift |
| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device |
| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost |
| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck |
| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls |
| 2026-09-24 | C1 Action classification | The table (lab C1): state + primary at rest, everyday toggles on hover, "…" on touch, destructive into the menu | Visible buttons per site, as today | One action list per object, nothing gesture-only, fewer buttons without losing a function |
| 2026-09-24 | C2 Side panels | A tap opens an overlay drawer; pin is a separate button ≥ 1024 | A tap pins and the content reflows | Below 768 the filters were unreachable; on touch a tap pinned and pushed the grid |
| 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap |
| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up |
| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip |
| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) |
| 2026-09-24 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 |
| 2026-09-24 | Field primitives | `Input` / `Textarea` / `InnerInput` + `fieldClass`, `SelectTrigger`, the native controls; the lint rule "no raw `<input>`" | `inputCls` and ~40 hand-styled boxes | Six edges, four radii and two focus looks for one control; the ≥ 3:1 edge held nowhere |
| 2026-09-25 | Surface roles | `SURFACE` roles in `ui/surfaces.ts`; the lint rule "no bare glass class"; menus p-1 with r8 rows | Three tiers paired ad hoc with five radii and three menu paddings | ~140 sites drifted; nested corners only hold if the radius comes with the role |
| 2026-09-25 | Static labels | Flat tonal `Badge`, no edge; a filtering tag is an edged chip | A glass pill with an edge, like the chips | Edged = pressable at a glance; a static tag that looks like a chip gets clicked and does nothing |
| 2026-09-25 | Header search | A 40 glass pill with the field edge (`lg`); what sits inside it is round | 44, r16, a glass box with a faint edge | The one rounded box among pills, and its edge under 3:1; the hero search is the YouTube/Gmail pill |
| 2026-09-25 | Switch row (F12) | The whole settings row toggles (`SwitchRow`; a hand-laid row points a label at the switch) | Only the 36×24 switch toggles | The iOS / Material settings row; the text was a dead click beside the control it names |
| 2026-09-25 | Edges and hovers (F14) | The ≥ 3:1 edge on the field family (fields, the form trigger); labelled pills keep the faint edge and the accent-edge hover | The ≥ 3:1 edge and a state-layer hover on every control | 1.4.11 asks an edge only where nothing else identifies the control; a heavy edge on every chip and tab weighs the whole UI down |
| 2026-09-25 | Rating and live (F16) | Own tokens: `--rating` (theme-scoped gold), `--live`/`--live-fg`, `--rating-media`, `--media-chrome`; palette classes lint-refused | Map them to `--warn` / `--danger` | A gold star and a red LIVE are what the eye expects; as warn/danger they would read as a status. The old amber score was 2:1 on light |
| 2026-09-25 | Fjord trial (B2 exception) | A fifth scheme, temporary: Midnight's neutrals, Playnite's steel accent (#337599 lifted to #5aa1cb) and steel rules; exit = fold into Midnight or remove | Retune Midnight straight away; borrow only the qualities | The owner wants to live with the calmer palette app-wide before it replaces the default; it breaks "own hue family" on purpose, and only until that call |
| 2026-09-25 | Whole-card row (owner) | A card row's B3 layer is the card's `::before` under its content; the open button's hit layer spans the card, the actions sit above it | The name button's own hover, ending at the first badge or button | A row that half-answers the pointer reads as broken; a badge click did nothing |
| 2026-09-25 | Badge tones (F29) | `Badge tone` neutral / accent / warn at 6%; light `--warn` #b45309 → #a14a07; edged label spans lint-refused | Keep edged status pills (a private Badge in Users, Settings "Granted", the suggestions count) | Edged = pressable at a glance; amber-700 was 4.58:1 as text and fell under 4.5 on its own tint |
| 2026-09-26 | Page band built (B4) | Controls only in the band (intros scroll); the fold shows the same row in place; one `h1` per page (`PageHeading` steps the hidden one aside); `SectionLabel` headings; Skip to content | A popover of the folded filters; the page title kept visible | A folded row is still the same controls, nothing duplicated; the rail and the tab title already name the page |
| 2026-09-26 | Fade built (B9) | `fade-below` on sticky toolbars and table headers; the scroller mask where the band sits on the backdrop | A painted band over the backdrop | The backdrop is the colour under the band, so fading the content into it is the same edge |
| 2026-09-26 | Viewport units | `dvh` everywhere (the Plex player stage with S3f) | `vh` / `h-screen` | `100vh` hides the last rows under a tablet browser's toolbar |
| 2026-09-26 | Video actions built (C1) | One list per video (menu, right-click, long-press, "…"); the card keeps watched / save / add to playlist on its picture on hover; download, details, copy, hide, reset in the menu | The six-button row under the card and the hover ⓘ | The menu is the one door every device has; the card loses a line of height and the touch card its row of 28px buttons |
| 2026-09-26 | Plex actions built (C1) | One list per Plex title (play/open, watched, add to playlist, media info, copy title, pin); the poster keeps watched / add / "…" on its picture on hover; seasons get open / watched / add / copy | The hover ⓘ, the pin-only menu, an episode's add button at rest, a collection's "Remove" X on the card | The same door as a video's; the collection grid goes to two columns on a phone so the touch "…" leaves the title its line |
| 2026-09-26 | Playlist actions built (C1) | A playlist row's "…" (play, pin, copy title / link, move, order, remove), Delete + an undo toast; one list per playlist (rename, pin, YouTube open / push / revert, delete) from its rail row and its header's "…" | The row's X column and its confirm; the header's pencil, YouTube link, push, revert and trash buttons | Remove is reversible, so it needs no confirm; the header keeps Play all; a narrow table drops "#" and Channel so the title keeps a width |
| 2026-09-26 | Channel + Plex list actions (C1) | A channel row's "…" (open, pin, YouTube, copy, full history, hide + undo, unsubscribe); a Plex playlist's title / show / season "…", Delete + undo; its header's "…" (rename, delete); an action toast lasts ≥ 8 s | The channel row's full-history, hide and unsubscribe buttons; the Plex list's red X on every row, show and season; the header's pencil and trash | Hiding and removing are reversible, so they need no confirm and the undo needs time; unsubscribe and deleting a playlist keep theirs |
| 2026-09-26 | Download / notification / Scheduler actions (C1) | A download row's "…" (play, share, save, edit, details, re-download, manage sharing, copies, cancel / delete / remove); a library row's Share stays in sight, ahead of save; a notification row's "…" and hover dismiss; the inbox band's and the Scheduler strip's "…" | The download row's five rare buttons and its cancel / delete / remove; the source link's copy button; an activity notice's Find / Undo links; the band's Clear everything; the strip's Purge discovery | The destructive rows keep their confirm, and Clear everything gains one; a row link keeps the browser's own menu; Find in feed drops the tag / search / age filters that could hide its video |
| 2026-09-26 | Channel menu: Tags + Priority (C1) | Tags A–Z as in-place checks; Priority as fixed steps 10 · 5 · 3 · 2 · 1 · 0, the current one ticked; a pick re-sorts at once and the page + focus follow the row; one drawn `MenuCheck` for every checkable row that cannot be an `<input>` | Relative steps (+1 / +5); straight to the top; the stepper only; seven hand-drawn check marks (three box sizes, a tinted box, a × for "on", a trailing tick) | Bubbling a channel up is one pick; fixed levels keep the scale the reader already uses; the list moving where the reader can see it is the feedback |
| 2026-09-26 | Reorder without a drag (WCAG 2.5.7) | Row menus: Top / Up / Down / Bottom (YouTube + Plex playlists); saved views: four buttons with the place beside the selected view (owner: A); side panels in edit mode: an Up / Down pair per group (owner: A), the name wrapping there | A "…" on every saved-view row; a "…" on every panel group; drag only | A visible, one-click path where the list is edited; the edge moves shown off so the buttons never shift |
| 2026-09-26 | Titles in click surfaces | The surface stays a button, flagged `selectable`: its text selects, and the click that ends a selection does nothing; every plain `Pressable` pinned with its reason | Rebuilding each card and row so the title sits outside the button; exceptions for master-detail lists | Chrome selects inside a button once it is `user-select: text` (measured), so one flag on the primitive keeps the keyboard, focus and list walking every surface already has |
| 2026-09-26 | Right-click on a selection | The browser's own menu opens on selected words; beside them the object's menu | A "Copy selection" row in the app menu | The reader selected the words to do something with them; the native menu copies, searches and translates, and changes nothing else |
| 2026-09-26 | Keyboard ring on a flush row | Inside the row (a table row by its tag, a log line, a show header in its clipping card, with the card's corners) | The ring outside, as on a card | Outside it ran under the table head and past the table, and a clipping card cut it away entirely (invisible focus) |
| 2026-09-26 | Fade at rest | The sticky header's fade appears with the first 1rem of scroll (a scroll-driven animation; no fade where unsupported) | A fade that is always drawn | At rest it dimmed the first row and hid its ring's top edge; nothing is under the header yet |
| 2026-09-26 | Narrow playlist table | "#" and Channel hide by the table's own width (under 42rem), not the window's | Hiding below the `sm` window width | Beside the playlist rail a desktop window left the table 432 px and the title 0 px |
| 2026-09-26 | Background menu built | Five list pages (feed, Plex grid, channels, playlist, notifications); the toolbar's sort / view / filters and the band's actions; no destructive row | Every page with a toolbar; the "…" menus copied whole, delete included | The tab-only pages have nothing a menu would add; a blank-area right-click must not be one click from delete |
| 2026-09-26 | Media card anatomy (C) | One frame; the meta line pinned to its bottom; the length a badge on the picture; the Plex card on the same primitive as the video card | A: the year as an overline over the title; B: the year inline after it; a fixed two-line title slot | A one-line title left a hole the eye reads as a missing line; C puts nothing under a variable-height title |
| 2026-09-26 | One Play | `MediaPlayGlyph` (the media button variant) and only where the click plays | The Plex card's hand-painted accent circle; a playable dot on every card | One Play for every card; a dot that only a colour and a tooltip explain says nothing |
| 2026-09-26 | Filtering values on the info page | A value that filters (year, certificate, studio, director) is an edged chip; a fact is flat text | Everything flat, the clickable ones only hover-tinted | The S3d rule: edged = pressable, so the reader sees which values do something |
| 2026-09-26 | Shelf density | On any strip: one title line over one meta line | The grid's two lines and full meta on a strip | The tallest card set the strip's height and left holes in the cards with less to say |
| 2026-09-26 | Compact list row (B) | The title leads; a faint "…" at the end; the toggles float over the date on hover | A leading 128px action column | That column sat empty at rest, a hole at the start of every row |
| 2026-09-26 | Media-info layout (B) | A main column (collections first, crew, cast) beside a facts sidebar; an empty column is not drawn | Every section full width, stacked | The collection is the most used; the keywords' column left a void beside a short details box |
| 2026-09-26 | Mixed Home shelf (A) | Plex titles as wide art beside videos | Posters, with the videos not stretched | Stretching a video card to a poster's height left an ~80px gap |
| 2026-09-27 | Home hero | A card (hover row + Play on the picture, the whole menu, a touch "…"; owner: A); three quarters wide and centred on a wide window; one fact line; the summary at the measure | The hero's pin row alone; full width; an 18rem picture column | The one answer stands in focus, and it offers what the same card offers anywhere |
| 2026-09-27 | Home shelf tiles | A channel / playlist tile: Play, ↗ open, "…" with Open; a show acts on its next episode, ⓘ opens it; the shelf slot 232px | The tile's pin row alone; 200px | A tile without its object's actions read as broken; at 200 the Play touched the hover row |
| 2026-09-27 | Synopsis preview (C) | A ~0.9 s mouse rest opens a passive panel beside the card | The synopsis as the picture's `title` tooltip; an ⓘ to hover; none | One long tooltip line fired anywhere on the art and clashed with the cut title's own tooltip |
| 2026-09-27 | Reading measure | Prose capped at ~65 characters a line (`measure`) | The container's full width | At 110–130 characters a line the eye loses the next line's start |
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
| 2026-09-27 | C4 as built | `initialFocus` required on every `Modal`; the commit row in `ModalFooter`, portalled under the body from any depth; the fades driven by the body's scroll timeline; `interactive-widget=resizes-content` | An optional prop defaulting to the ✕; a `footer` prop (the issue composer's buttons live three components down); always-on fades | A default is how 21 dialogs came to open on the ✕; lifting the composer's state to reach a prop is the larger change; a resting fade dimmed the first line |
| 2026-09-27 | C3 + C5 as built | A tap toggles the bar through `pointerType` from `pointerdown`; a double-tap takes back its first tap's toggle; the card's pair is the video card's `MediaResumeButtons` (Continue / Restart), a sibling over the picture; the YT description is an ⓘ click popover | A delayed single tap (wait out the double-tap window); new "Resume / From the start" words for Plex; the pair inside the picture's button | A delayed tap feels dead; one media card says one pair of words; a button inside a button is invalid and the hover row already sits beside the picture |
| 2026-09-27 | C2 as built | The side panel's drawer is `ui/Modal` `drawer-start`; drawer = a coarse pointer or below 768; the pin offered in its header ≥ 1024 | A drawer of its own inside `SidePanel` | Esc, Back, the scrim, the trap and the scroll lock are the dialog's; a second implementation is how two shells drift |
| 2026-09-27 | Narrow rows by container | The download row, the playlist table and the Channels tag row stack by a container query; a fact line never wraps | Breakpoints on the window | The iPad zoomed in, split screen and a column beside a rail are narrow while the window is not; the zoomed download row broke "18.9 / MB" apart |
| 2026-09-27 | Phones deferred | A phone-only layout (the hamburger nav, a folded feed band) waits for its own epic; E28 stays desktop + tablet | Fix the phone views inside E28 | Principle 2; the first phone attempts made the feed worse, and the epic's review grows with every sprint |
| 2026-09-27 | Conformance check (S3g) | A four-step check before a UI change is done: primitive at a named rung, the chapter re-read, the rendered page measured by kind and look, every state seen | A reviewer's eye per sprint | The S1 probe found 69 button looks; after S3 every control kind is drawn by its primitive, and the check keeps a new look from arriving unnoticed |
| 2026-09-27 | Segmented rung | `SegmentedToggle` defaults to the toolbar rung (28, `text-xs`); 24 only for a panel's captioned match toggle | The small default | Two toolbars had inherited 10px type from the default beside 28px buttons |
| 2026-09-27 | Copy rule (S3g) | One name per feature (the rail may shorten it); four verbs for four meanings; sentence case with the app's places capitalised; "…" when a command asks first; one-sentence messages without a stop; curly quotes; dates relative in lists with the exact minute on hover, three absolute forms; units from Intl, short | Word-for-word names everywhere; the verbs as they were; all lower case; no "…"; always a stop; always absolute dates; long or compact units | The copy survey found 7 EN and 6 HU double names, 22 Delete against 27 Remove, five date shapes and three spellings of a minute; the owner picked every recommended option |
| 2026-09-27 | One glyph, one meaning (S4) | Play only for playback or resuming a paused transfer; Retry = `RefreshCw`; reset / revert = `RotateCcw` | Retry drawn with Play | The first UI-review run (Downloads) found a failed row whose Retry showed ▷ in the row and its menu |
| 2026-09-27 | Fjord kept | Fjord stays a fifth scheme beside Midnight; the trial wording goes | Fold it into Midnight; remove it | The owner lived with it and likes it; the vivid Midnight stays for those who want it. B2's own-hue rule still binds any new scheme |
| 2026-09-27 | Tablet columns | A table hides `tabletHidden` columns below 64rem of its own width; Channels drops Prio, About, Length | Hide About only; give the name a bigger share | At 1024 the channel name had 60 px; the owner chose to drop all three there |
| 2026-09-27 | Glyph pager | First / previous / next / last as glyph buttons round the page box, names as tooltips | Words beside the arrows (hidden only on a phone) | The owner's call: the words widened the row; four arrows are the shared convention |
| 2026-09-27 | Match switch on the panel | `ui/MatchToggle` above the picker from two picks, everywhere; one wording (Any / All) | The picker's popover footer (Feed, Plex collection) | A closed picker hid an active "all"; six pickers had drawn it two ways |
| 2026-09-27 | Missing picture, bare link | A glyph of the kind for no picture; a download with no name is titled by its short link, once | An empty box; the URL as title, channel and link | The owner's picks 4A and 3A: an empty box read as loading, the URL showed three times |
| 2026-09-27 | Copy on a selection | Right-click on selected words keeps the browser's menu; no app Copy row | An app menu with Copy first | A real browser's menu has copy, search and translate; only the desktop pane's is thin |
+4
View File
@@ -32,6 +32,10 @@ For a fast frontend edit loop, run the Vite dev server with HMR against that API
cd frontend && npm install && npm run dev # http://localhost:5173, proxying the API on :8080
```
The dev server also serves the **design lab** at `http://localhost:5173/design-lab`: the open design
decisions and the chosen patterns, rendered with the app's real CSS. It never reaches a production build
(see [`DESIGN.md`](DESIGN.md)).
## The gate
A change is done when all of this is green. CI runs the same checks server-side
+29
View File
@@ -0,0 +1,29 @@
import type { Plugin } from "vite";
// The design lab (`src/design-lab/`) is a dev-only page: main.tsx reaches it behind
// `import.meta.env.DEV`, which Vite folds to `false` in a production build, so the dynamic import
// is dead code and the chunk is never emitted. That rule used to be a sentence; this makes it a
// build failure. If a future edit imports the lab unguarded (a static import, a shared helper, a
// re-export), the production build refuses instead of shipping the lab to every visitor.
const DEV_ONLY_DIR = "/src/design-lab/";
export function devOnlyGuard(): Plugin {
return {
name: "siftlode-dev-only-guard",
apply: "build",
generateBundle(_options, bundle) {
for (const [fileName, output] of Object.entries(bundle)) {
if (output.type !== "chunk") continue;
const leaked = Object.keys(output.modules).find((id) =>
id.replaceAll("\\", "/").includes(DEV_ONLY_DIR),
);
if (leaked) {
this.error(
`dev-only module ${leaked} reached the production bundle (${fileName}). ` +
"Import src/design-lab only behind `import.meta.env.DEV`.",
);
}
}
},
};
}
+2 -1
View File
@@ -46,7 +46,8 @@ user), reset to a deterministic baseline every run by
[`scripts/e2e_seed.py`](../../scripts/e2e_seed.py) (run
in-container so it reuses the app's models + argon2 hasher): the top-3 organic channels subscribed,
one in-progress + one watched + one saved video, prefs pinned to `language: en`. Credentials come
from `E2E_EMAIL` / `E2E_PASSWORD` (local-only defaults). Override the container name with
from `E2E_EMAIL` (default `e2e@siftlode.local`) and `E2E_PASSWORD` — **required, no default**: run
the suite through the ops wrapper, which exports it from the secret store. Override the container name with
`E2E_API_CONTAINER`; skip the in-container seed with `E2E_SKIP_SEED=1`.
## Selectors
+8 -1
View File
@@ -14,7 +14,9 @@ import { fileURLToPath } from "node:url";
const STORAGE = "e2e/.auth/state.json";
const EMAIL = process.env.E2E_EMAIL || "e2e@siftlode.local";
const PASSWORD = process.env.E2E_PASSWORD || "e2e-golden-master";
// No default: a password committed to a public repo is not a credential. The ops wrapper
// (`siftlode e2e` / `check-full`) exports it from the secret store.
const PASSWORD = process.env.E2E_PASSWORD ?? "";
const CONTAINER = process.env.E2E_API_CONTAINER || "siftlode-api-1";
const BASE_URL = process.env.PW_BASE_URL || "http://127.0.0.1:8080";
@@ -25,6 +27,11 @@ const SEED_SCRIPT = resolve(HERE, "../../scripts/e2e_seed.py");
const isLocalTarget = /\/\/(127\.0\.0\.1|localhost)[:/]/.test(BASE_URL);
setup("seed + authenticate", async ({ page }) => {
expect(
PASSWORD,
"E2E_PASSWORD is not set — run the suite through `siftlode e2e` (it reads the secret store)",
).not.toBe("");
// 1. Preflight: fail fast (with a useful message) if the stack isn't running.
const health = await page.request.get("/healthz");
expect(
+306 -3
View File
@@ -69,7 +69,7 @@ const focusSafeDisabled = {
meta: {
type: "problem",
messages: {
raw: "A raw <button disabled> throws a focused user to <body> when it turns disabled. Use ui/FocusSafeButton (or ui/Button).",
raw: "A raw <button disabled> throws a focused user to <body> when it turns disabled. Build the primitive on ui/FocusSafeButton; anywhere else use ui/Button, IconButton or Pressable.",
},
},
create(context) {
@@ -82,6 +82,199 @@ const focusSafeDisabled = {
};
},
};
// E28 S3g (docs/DESIGN.md "Copy"): the app shows a date in three forms plus the relative one, all
// built in lib/format.ts in the UI language. A bare `toLocaleString()` formats in the BROWSER's locale
// and as numbers with seconds; seven call sites had drifted into five shapes. Outside lib/format.ts
// a `toLocale*String` call or an `Intl.DateTimeFormat` / `Intl.RelativeTimeFormat` is refused (a
// number goes through formatNumber, which the same rule covers).
const dateFormat = {
meta: {
type: "problem",
messages: {
date: "Format dates and numbers through lib/format.ts (formatDate, formatDateTime, formatDateTimeSec, relativeTime, formatNumber), in the UI language.",
},
},
create(context) {
const path = context.filename.split("\\").join("/");
// lib/format.ts is where the forms live; a test computes its expectations; the Plex player's
// on-screen clock (lib/plexPlayerPrefs.ts) is a clock face with its own 12/24h switch, not a date.
if (/\/src\/lib\/(format|plexPlayerPrefs)\.ts$|\.test\.tsx?$/.test(path)) return {};
return {
"CallExpression > MemberExpression.callee[property.name=/^toLocale(Date|Time)?String$/]"(
node,
) {
context.report({ node, messageId: "date" });
},
"MemberExpression[object.name='Intl'][property.name=/^(DateTimeFormat|RelativeTimeFormat)$/]"(
node,
) {
context.report({ node, messageId: "date" });
},
};
},
};
// E28 S3b: one primitive per button family (docs/DESIGN.md). 268 hand-rolled buttons in 79 files had
// drifted into as many looks, heights and hover styles; they were swept onto ui/Button, IconButton,
// Pressable, Chip and DragHandle, and this keeps the next one out. The raw element lives only in the
// files that ARE a control primitive — not in the rest of components/ui/, whose composites (a pager, a
// side panel, a toast) are built from the primitives like any feature component.
const BUTTON_PRIMITIVES = new Set([
"FocusSafeButton.tsx", // the focus-keeping <button> everything stands on
"Button.tsx",
"IconButton.tsx",
"Pressable.tsx",
"Chip.tsx",
"DragHandle.tsx",
"AnchoredPopover.tsx", // the menu row: floating-ui's roving focus reads its native `disabled`
"SegmentedToggle.tsx",
"Tabs.tsx",
"form.tsx", // Switch
"PriorityStepper.tsx",
"SortControl.tsx",
"SelectTrigger.tsx", // the dropdown trigger (A5)
"FilterSelect.tsx", // its option rows
"Combobox.tsx", // the field's chevron (A4)
]);
const buttonPrimitive = {
meta: {
type: "problem",
messages: {
raw: "A raw <{{name}}> outside the control primitives: use ui/Button (a labelled action), IconButton (a glyph), Pressable (a card or row that is the control), Chip or DragHandle.",
role: 'A <{{name}} role="button"> is a hand-built button (its own Enter/Space, no focus-safe disabled, no touch target): use ui/Pressable for a surface that is the control.',
},
},
create(context) {
const path = context.filename.split("\\").join("/");
const file = path.slice(path.lastIndexOf("/") + 1);
if (path.includes("/src/components/ui/") && BUTTON_PRIMITIVES.has(file)) return {};
return {
JSXOpeningElement(node) {
if (node.name.type !== "JSXIdentifier") return;
const name = node.name.name;
if (name === "button" || name === "FocusSafeButton")
context.report({ node, messageId: "raw", data: { name } });
// S3g: the same button spelled as a div with a role slipped past the rule above (my own
// S3h Plex picture). A component (capitalised) may take a role for its own reasons.
else if (
/^[a-z]/.test(name) &&
node.attributes.some(
(a) =>
a.type === "JSXAttribute" &&
a.name.name === "role" &&
a.value?.type === "Literal" &&
a.value.value === "button",
)
)
context.report({ node, messageId: "role", data: { name } });
},
};
},
};
// The same for a text field and the native checkbox / slider / colour well (docs/DESIGN.md A4, A6;
// E28 S3c): ~100 hand-styled boxes carried six edges, four radii and two focus looks between them. A
// file picker has no look (it is hidden behind a Button), so it passes.
const INPUT_PRIMITIVES = new Set([
"Input.tsx",
"NativeControls.tsx",
"PriorityStepper.tsx", // the number box laid over the stepper's own face
]);
const inputPrimitive = {
meta: {
type: "problem",
messages: {
raw: "A raw <{{name}}> outside the field primitives: use ui/Input, Textarea or InnerInput (inside a fieldClass frame), or Checkbox / CheckRow / Slider / ColorInput.",
},
},
create(context) {
const path = context.filename.split("\\").join("/");
const file = path.slice(path.lastIndexOf("/") + 1);
if (path.includes("/src/components/ui/") && INPUT_PRIMITIVES.has(file)) return {};
return {
JSXOpeningElement(node) {
if (node.name.type !== "JSXIdentifier") return;
const name = node.name.name;
if (name !== "input" && name !== "textarea") return;
const type = node.attributes.find(
(a) => a.type === "JSXAttribute" && a.name.name === "type",
);
const v = type?.value?.type === "Literal" ? type.value.value : null;
if (name === "input" && (v === "file" || v === "hidden")) return;
context.report({ node, messageId: "raw", data: { name } });
},
};
},
};
// B5 (docs/DESIGN.md; E28 S3d): a glass surface is a ROLE from ui/surfaces.ts, which brings its radius.
// ~140 sites had paired the three glass tiers with five radii and three menu paddings. A class string
// (two or more tokens, so `variant="glass"` passes) holding a bare glass tier is refused outside the
// role table and the control primitives that paint a glass BUTTON face.
const SURFACE_OWNERS = new Set([
"surfaces.ts",
"buttonStyles.ts", // the `glass` button face
"SelectTrigger.tsx",
"fieldStyles.ts", // the header search floats over the page
]);
const GLASS_TIER = /^glass(-card|-menu)?$/;
// A glass surface's depth is its own box-shadow, whose inset top highlight a `shadow-*` utility would
// replace wholesale (the header search and the debug pill lost theirs). A shadow COLOUR only sets a
// variable, and `shadow-float` IS the surface's shadow, so both pass.
const SHADOW_SIZE = /^shadow-(xs|sm|md|lg|xl|2xl|\[.+\]|\(.+\))$/;
const isShadowSize = (w) => SHADOW_SIZE.test(w.slice(w.lastIndexOf(":") + 1));
const isSurfaceRole = (n) =>
n?.type === "MemberExpression" && n.object.type === "Identifier" && n.object.name === "SURFACE";
const surfaceRole = {
meta: {
type: "problem",
messages: {
raw: "A bare `{{tier}}` class: use a role from ui/surfaces.ts (SURFACE.panel / drawer / card / menu / popover / tooltip), which brings the radius too.",
shadow:
"`{{shadow}}` on a glass surface replaces its own shadow and drops the top highlight; the role's depth is the intended one.",
},
},
create(context) {
const path = context.filename.split("\\").join("/");
const file = path.slice(path.lastIndexOf("/") + 1);
if (path.includes("/src/components/ui/") && SURFACE_OWNERS.has(file)) return {};
if (path.includes("/src/lib/")) return {}; // no classes live there, only prose ("a glass tuner")
const check = (node, text) => {
if (typeof text !== "string") return;
const words = text.split(/\s+/).filter(Boolean);
// A lone word is a value (`variant="glass"`); a class string has company, or is a template
// fragment that a space joins to its neighbour.
if (words.length < 2 && text === text.trim()) return;
const tier = words.find((w) => GLASS_TIER.test(w));
if (tier) context.report({ node, messageId: "raw", data: { tier } });
};
// A role plus a shadow size in one class list: `${SURFACE.x} shadow-lg` or clsx(SURFACE.x, "shadow-lg").
const shadowBeside = (node, parts) => {
if (!parts.some(isSurfaceRole)) return;
for (const part of parts) {
const text =
part.type === "TemplateElement"
? part.value.cooked
: part.type === "Literal" && typeof part.value === "string"
? part.value
: "";
const shadow = text.split(/\s+/).find(isShadowSize);
if (shadow) context.report({ node, messageId: "shadow", data: { shadow } });
}
};
return {
Literal(node) {
check(node, node.value);
},
TemplateElement(node) {
check(node, node.value.cooked);
},
TemplateLiteral(node) {
shadowBeside(node, [...node.quasis, ...node.expressions]);
},
CallExpression(node) {
shadowBeside(node, node.arguments);
},
};
},
};
// The words a screen reader or a tooltip says are user-facing text like any other, and the HU/EN
// parity rule covers them too. Five `aria-label="remove"`-style literals survived every lane (the
// Plex playlist rows, a filter chip, the player's reset) and a whole opt-in panel was English only —
@@ -152,11 +345,46 @@ const sortableA11y = {
};
},
};
// An <a> whose content is only a glyph is a link that looks like a button (DESIGN.md): `IconLink`, with
// a name, a 24+ box and the icon button's layer. The E28 audit found five bare 14px ones. Text or an
// expression anywhere in its CHILDREN (not its attributes: every link has an `href={…}`) is a text
// link or a card, and passes; a self-closing <a> is a `Trans` slot that i18n fills with text.
const glyphLink = {
meta: {
type: "problem",
messages: {
glyph:
"a glyph-only <a> is a link that looks like a button: use ui/LinkButton's IconLink (a name, a 24+ box, the state layer).",
},
},
create(context) {
const hasText = (children) =>
children.some(
(c) =>
(c.type === "JSXText" && /\S/.test(c.value)) ||
(c.type === "JSXExpressionContainer" && c.expression.type !== "JSXEmptyExpression") ||
((c.type === "JSXElement" || c.type === "JSXFragment") && hasText(c.children)),
);
return {
JSXElement(node) {
const open = node.openingElement;
if (open.name.type !== "JSXIdentifier" || open.name.name !== "a" || open.selfClosing)
return;
if (!hasText(node.children)) context.report({ node: open, messageId: "glyph" });
},
};
},
};
const localRules = {
rules: {
"glyph-link": glyphLink,
"sortable-a11y": sortableA11y,
"jsx-comment-text": jsxCommentText,
"focus-safe-disabled": focusSafeDisabled,
"button-primitive": buttonPrimitive,
"date-format": dateFormat,
"input-primitive": inputPrimitive,
"surface-role": surfaceRole,
"literal-a11y-text": literalA11yText,
},
};
@@ -167,6 +395,43 @@ const BOX_PX =
"/(^|[\\s:])(min-|max-)?(w|h|size|left|right|top|bottom|inset|gap|p[xytrbl]?|m[xytrbl]?)-\\[[\\d.]+px\\]/";
const BOX_PX_MESSAGE =
"px-frozen box size doesn't scale with --font-scale: use the spacing scale (w-37.5, max-w-60), not w-[Npx].";
// A stacking order outside the layer scale (DESIGN.md Tokens: `base < menu < chrome < … < tooltip` in
// index.css). A numeric `z-10` equals a level today but doesn't follow it when the scale moves; name it.
const Z_RAW = "/(^|[\\s:])-?z-([1-9]\\d*|\\[[^\\]]+\\])(?![\\w-])/";
const Z_RAW_MESSAGE =
"z-index off the layer scale: use a named level (z-under, z-base, z-menu, z-chrome, z-panel, z-rail, z-overlay, z-popover, z-tooltip), not z-N or z-[…].";
// A hover that swaps a colour instead of laying the B3 state layer (DESIGN.md Tokens): `bg-card`,
// `bg-border` and `bg-surface` vanish on a light card, an opacity dim reads as "disabled", and
// `bg-(--hover)` restates the `state-layer` utility by hand. `group-hover:` (a reveal on a parent's
// hover, C1) and an edged pill's accent hover (F14) are not matched.
const HOVER_SWAP =
"/(^|[\\s:])hover:(bg-(card|border|surface|muted)(?![\\w-])|bg-\\(--(hover|press)\\)|opacity-)/";
const HOVER_SWAP_MESSAGE =
"hover by a colour swap: use the B3 layer (`state-layer`, Pressable `layer`, `state-filled` on a fill), not hover:bg-card / bg-border / opacity-N.";
// A palette colour (DESIGN.md Tokens "only semantic tokens", audit F16): nothing measures `text-amber-500`,
// so a gold that reads on dark ships at 2:1 on light. Black and white are not matched: they are the
// media scrims and the text on them, the same in every theme.
const PALETTE =
"/(^|[\\s:])(text|bg|border|fill|stroke|from|via|to|ring|outline|decoration|divide|caret|shadow)-(red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|zinc|neutral|stone)-\\d/";
const PALETTE_MESSAGE =
"a palette colour: use a semantic token (text-rating, bg-live, bg-media-chrome, text-danger, …; DESIGN.md Tokens), not text-amber-500.";
// A static label with an edge (DESIGN.md "Static vs pressable", audit F29): a <span> drawn round with a
// border reads as a chip, is clicked and does nothing. A label that does nothing is ui/Badge (flat).
// A small `rounded` box (a check mark drawn inside a menu row) is a glyph, not a label: only the label
// radii match.
const EDGED_LABEL = "/^(?=.*(^|\\s)rounded-(full|md|lg|xl|2xl)(\\s|$))(?=.*(^|\\s)border(\\s|$))/";
const EDGED_LABEL_MESSAGE =
"a static label with an edge reads as a pressable chip: use ui/Badge (flat, tonal) for a label that does nothing.";
// A class name split by an interpolation: the text right after `${…}` starts with a variant (`hover:`),
// or the interpolation IS the variant and the text goes on with its class (`${NARROW}:w-24`, E28 S3f3).
// A clock (`${m}:00`) and a URL (`${u}://`) are not classes.
const SPLIT_CLASS = "/^([a-z][\\w-]*:[\\w\\[!-]|:[a-z\\[!])/";
const SPLIT_CLASS_MESSAGE =
"a class split by an interpolation (`${x}hover:…`): Tailwind never sees it whole and emits no CSS; write the class literally (a shared guard is the `act:` variant).";
export default tseslint.config(
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] },
@@ -211,7 +476,12 @@ export default tseslint.config(
"react-hooks/refs": "off",
"local/jsx-comment-text": "error",
"local/focus-safe-disabled": "error",
"local/button-primitive": "error",
"local/date-format": "error",
"local/input-primitive": "error",
"local/surface-role": "error",
"local/literal-a11y-text": "error",
"local/glyph-link": "error",
"local/sortable-a11y": "error",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
// tsc already enforces these under strict + noUnusedLocals/Parameters — let it own them so a
@@ -243,12 +513,19 @@ export default tseslint.config(
{
selector: "Literal[value=/(^|\\s)enabled:hover:/]",
message:
"`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `enabled:not-aria-disabled:hover:`.",
"`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `act:hover:` (index.css).",
},
{
selector: "TemplateElement[value.raw=/(^|\\s)enabled:hover:/]",
message:
"`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `enabled:not-aria-disabled:hover:`.",
"`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `act:hover:` (index.css).",
},
// Tailwind emits CSS only for a class it finds WHOLE in the source. A variant glued onto an
// interpolated prefix (`${ACT}hover:text-fg`) is never found, so it ships with no rule: every
// hover colour of the button and chip primitives was dead that way (E28 audit).
{
selector: `TemplateLiteral > TemplateElement:not(:first-child)[value.raw=${SPLIT_CLASS}]`,
message: SPLIT_CLASS_MESSAGE,
},
// The canonical "FE control" row (CLAUDE.md): a short fixed set is a SegmentedToggle, a
// longer one or a user's own list a FilterSelect. Six native selects had drifted in; the
@@ -277,6 +554,20 @@ export default tseslint.config(
// 150px at the default size) — 32 of these were swept in the 2026-09 debt burn.
{ selector: `Literal[value=${BOX_PX}]`, message: BOX_PX_MESSAGE },
{ selector: `TemplateElement[value.raw=${BOX_PX}]`, message: BOX_PX_MESSAGE },
{ selector: `Literal[value=${Z_RAW}]`, message: Z_RAW_MESSAGE },
{ selector: `TemplateElement[value.raw=${Z_RAW}]`, message: Z_RAW_MESSAGE },
{ selector: `Literal[value=${HOVER_SWAP}]`, message: HOVER_SWAP_MESSAGE },
{ selector: `TemplateElement[value.raw=${HOVER_SWAP}]`, message: HOVER_SWAP_MESSAGE },
{ selector: `Literal[value=${PALETTE}]`, message: PALETTE_MESSAGE },
{ selector: `TemplateElement[value.raw=${PALETTE}]`, message: PALETTE_MESSAGE },
{
selector: `JSXOpeningElement[name.name='span'] JSXAttribute[name.name='className'] Literal[value=${EDGED_LABEL}]`,
message: EDGED_LABEL_MESSAGE,
},
{
selector: `JSXOpeningElement[name.name='span'] JSXAttribute[name.name='className'] TemplateElement[value.raw=${EDGED_LABEL}]`,
message: EDGED_LABEL_MESSAGE,
},
],
},
},
@@ -357,6 +648,13 @@ export default tseslint.config(
files: ["src/components/ui/FocusSafeButton.tsx", "src/components/ui/AnchoredPopover.tsx"],
rules: { "local/focus-safe-disabled": "off" },
},
{
// The design lab (src/design-lab, E28) is a dev-only page that never reaches the production
// bundle (devOnlyGuard fails the build if it does). Its copy is the developer's and the product
// owner's working text, not the product's, so the translate-every-label rule does not apply.
files: ["src/design-lab/**/*.{ts,tsx}"],
rules: { "local/literal-a11y-text": "off" },
},
{
// Tests stand doubles in for the network (`vi.stubGlobal("fetch", …)`) and may reach it directly.
// …and render throwaway markup whose labels no user ever hears.
@@ -365,6 +663,11 @@ export default tseslint.config(
"no-restricted-globals": "off",
"no-restricted-properties": "off",
"local/literal-a11y-text": "off",
// A test renders a raw button as a stand-in trigger or a focus target, not as product UI.
"local/button-primitive": "off",
"local/input-primitive": "off",
"local/surface-role": "off",
"local/glyph-link": "off",
},
},
prettier,
+7 -1
View File
@@ -2,7 +2,13 @@
<html lang="en" data-theme="dark" data-scheme="midnight">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- resizes-content: the on-screen keyboard shrinks the layout viewport, not only the visual one,
so a dialog's pinned footer (DESIGN C4) sits above the keyboard instead of under it. Chrome's
default since 108 only shrinks the visual viewport; Safari ignores the key. -->
<meta
name="viewport"
content="width=device-width, initial-scale=1, interactive-widget=resizes-content"
/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- OG:START — the server replaces this block for /watch/{token} so shared links unfurl with
the video's title/channel/thumbnail; every other route keeps these generic defaults. -->
@@ -1,18 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#7a1620" stop-opacity=".9"/><stop offset="100%" stop-color="#7a1620" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#ff3b46" stop-opacity=".4"/><stop offset="100%" stop-color="#ff3b46" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#3a0d24" stop-opacity=".9"/><stop offset="100%" stop-color="#3a0d24" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#2a1010" stop-opacity=".85"/><stop offset="100%" stop-color="#2a1010" stop-opacity="0"/></radialGradient>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#1d4a66" stop-opacity=".95"/><stop offset="100%" stop-color="#1d4a66" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#337599" stop-opacity=".5"/><stop offset="100%" stop-color="#337599" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#1f2d57" stop-opacity=".9"/><stop offset="100%" stop-color="#1f2d57" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#173c52" stop-opacity=".85"/><stop offset="100%" stop-color="#173c52" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#0d0708"/>
<rect width="1600" height="1000" fill="#080d1a"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+4 -4
View File
@@ -1,11 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#0f5245" stop-opacity=".95"/><stop offset="100%" stop-color="#0f5245" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#2dd4bf" stop-opacity=".5"/><stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/></radialGradient>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#0e4a5a" stop-opacity=".95"/><stop offset="100%" stop-color="#0e4a5a" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#22d3ee" stop-opacity=".5"/><stop offset="100%" stop-color="#22d3ee" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#134a2c" stop-opacity=".9"/><stop offset="100%" stop-color="#134a2c" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#0d3a52" stop-opacity=".85"/><stop offset="100%" stop-color="#0d3a52" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#06120e"/>
<g filter="url(#s)">
@@ -14,5 +14,5 @@
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1,18 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#f0a3aa" stop-opacity=".85"/><stop offset="100%" stop-color="#f0a3aa" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#ff5b64" stop-opacity=".4"/><stop offset="100%" stop-color="#ff5b64" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#e096a8" stop-opacity=".8"/><stop offset="100%" stop-color="#e096a8" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#eec2c2" stop-opacity=".8"/><stop offset="100%" stop-color="#eec2c2" stop-opacity="0"/></radialGradient>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#a8c6dc" stop-opacity=".9"/><stop offset="100%" stop-color="#a8c6dc" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#5aa1cb" stop-opacity=".5"/><stop offset="100%" stop-color="#5aa1cb" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#b9c6e0" stop-opacity=".85"/><stop offset="100%" stop-color="#b9c6e0" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#a9d0dc" stop-opacity=".8"/><stop offset="100%" stop-color="#a9d0dc" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#faeef0"/>
<rect width="1600" height="1000" fill="#eaf0f6"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.04"/>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+4 -4
View File
@@ -1,11 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#7fd0bf" stop-opacity=".9"/><stop offset="100%" stop-color="#7fd0bf" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#2dd4bf" stop-opacity=".5"/><stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/></radialGradient>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#8fd3e0" stop-opacity=".9"/><stop offset="100%" stop-color="#8fd3e0" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#22d3ee" stop-opacity=".5"/><stop offset="100%" stop-color="#22d3ee" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#8fc9a5" stop-opacity=".85"/><stop offset="100%" stop-color="#8fc9a5" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#88c2d6" stop-opacity=".8"/><stop offset="100%" stop-color="#88c2d6" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#e9f5f0"/>
<g filter="url(#s)">
@@ -14,5 +14,5 @@
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.04"/>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+7 -7
View File
@@ -1,18 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#8fd8ae" stop-opacity=".9"/><stop offset="100%" stop-color="#8fd8ae" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#46b87c" stop-opacity=".45"/><stop offset="100%" stop-color="#46b87c" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#7fc9c9" stop-opacity=".85"/><stop offset="100%" stop-color="#7fc9c9" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#96d69c" stop-opacity=".8"/><stop offset="100%" stop-color="#96d69c" stop-opacity="0"/></radialGradient>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#d9e2c4" stop-opacity=".9"/><stop offset="100%" stop-color="#d9e2c4" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#86a05a" stop-opacity=".35"/><stop offset="100%" stop-color="#86a05a" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#cdd6bf" stop-opacity=".85"/><stop offset="100%" stop-color="#cdd6bf" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#c3cfb3" stop-opacity=".8"/><stop offset="100%" stop-color="#c3cfb3" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#eaf5ec"/>
<rect width="1600" height="1000" fill="#eef0e8"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.04"/>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+2 -2
View File
@@ -5,7 +5,7 @@
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#b3a8ec" stop-opacity=".85"/><stop offset="100%" stop-color="#b3a8ec" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#9fd6e0" stop-opacity=".8"/><stop offset="100%" stop-color="#9fd6e0" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#edf1fc"/>
<g filter="url(#s)">
@@ -14,5 +14,5 @@
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.04"/>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+7 -7
View File
@@ -1,18 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#e6c393" stop-opacity=".9"/><stop offset="100%" stop-color="#e6c393" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#e8913c" stop-opacity=".45"/><stop offset="100%" stop-color="#e8913c" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#dba98c" stop-opacity=".85"/><stop offset="100%" stop-color="#dba98c" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#cabf9e" stop-opacity=".8"/><stop offset="100%" stop-color="#cabf9e" stop-opacity="0"/></radialGradient>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#e0c2ea" stop-opacity=".9"/><stop offset="100%" stop-color="#e0c2ea" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#e879f9" stop-opacity=".45"/><stop offset="100%" stop-color="#e879f9" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#d6c0dc" stop-opacity=".85"/><stop offset="100%" stop-color="#d6c0dc" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#c9ccd6" stop-opacity=".8"/><stop offset="100%" stop-color="#c9ccd6" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#f4efe6"/>
<rect width="1600" height="1000" fill="#f3f1f5"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.04"/>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -1,18 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#8fb6e8" stop-opacity=".9"/><stop offset="100%" stop-color="#8fb6e8" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#38b6ff" stop-opacity=".45"/><stop offset="100%" stop-color="#38b6ff" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#94a0e0" stop-opacity=".85"/><stop offset="100%" stop-color="#94a0e0" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#8fc3d2" stop-opacity=".8"/><stop offset="100%" stop-color="#8fc3d2" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#e9f1fc"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.04"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

+7 -7
View File
@@ -1,18 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#0f4a30" stop-opacity=".95"/><stop offset="100%" stop-color="#0f4a30" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#46b87c" stop-opacity=".45"/><stop offset="100%" stop-color="#46b87c" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#0c3a3a" stop-opacity=".9"/><stop offset="100%" stop-color="#0c3a3a" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#164a26" stop-opacity=".85"/><stop offset="100%" stop-color="#164a26" stop-opacity="0"/></radialGradient>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#34431f" stop-opacity=".95"/><stop offset="100%" stop-color="#34431f" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#86a05a" stop-opacity=".35"/><stop offset="100%" stop-color="#86a05a" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#1d2a18" stop-opacity=".9"/><stop offset="100%" stop-color="#1d2a18" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#22301c" stop-opacity=".85"/><stop offset="100%" stop-color="#22301c" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#020805"/>
<rect width="1600" height="1000" fill="#0a0c09"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+2 -2
View File
@@ -5,7 +5,7 @@
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#3b2f80" stop-opacity=".9"/><stop offset="100%" stop-color="#3b2f80" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#145e6b" stop-opacity=".85"/><stop offset="100%" stop-color="#145e6b" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#070b16"/>
<g filter="url(#s)">
@@ -14,5 +14,5 @@
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+7 -7
View File
@@ -1,18 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#7a4d18" stop-opacity=".95"/><stop offset="100%" stop-color="#7a4d18" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#e8913c" stop-opacity=".45"/><stop offset="100%" stop-color="#e8913c" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#5a2f14" stop-opacity=".9"/><stop offset="100%" stop-color="#5a2f14" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#403820" stop-opacity=".85"/><stop offset="100%" stop-color="#403820" stop-opacity="0"/></radialGradient>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#5a2a6a" stop-opacity=".95"/><stop offset="100%" stop-color="#5a2a6a" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#e879f9" stop-opacity=".45"/><stop offset="100%" stop-color="#e879f9" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#3a2050" stop-opacity=".9"/><stop offset="100%" stop-color="#3a2050" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#2c3040" stop-opacity=".85"/><stop offset="100%" stop-color="#2c3040" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#141009"/>
<rect width="1600" height="1000" fill="#111217"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

-18
View File
@@ -1,18 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
<defs>
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#123b6b" stop-opacity=".95"/><stop offset="100%" stop-color="#123b6b" stop-opacity="0"/></radialGradient>
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#38b6ff" stop-opacity=".45"/><stop offset="100%" stop-color="#38b6ff" stop-opacity="0"/></radialGradient>
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#1f2a72" stop-opacity=".9"/><stop offset="100%" stop-color="#1f2a72" stop-opacity="0"/></radialGradient>
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#0f5566" stop-opacity=".85"/><stop offset="100%" stop-color="#0f5566" stop-opacity="0"/></radialGradient>
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
</defs>
<rect width="1600" height="1000" fill="#040914"/>
<g filter="url(#s)">
<rect width="1600" height="1000" fill="url(#a)"/>
<rect width="1600" height="1000" fill="url(#c)"/>
<rect width="1600" height="1000" fill="url(#d)"/>
<rect width="1600" height="1000" fill="url(#b)"/>
</g>
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

+8
View File
@@ -18,9 +18,17 @@ import reactCompiler from "babel-plugin-react-compiler";
// reasons, both about the gate: a string is invisible to knip, which then reports the dependency as
// unused, and — worse — a typo in that string is a silent no-op that ships an uncompiled bundle,
// while a bad import is a resolve error on the tsc lane.
//
// The price of the import: plugin-react finds the compiler only by that string, so it doesn't know the
// compiler is there. Its one compiler-specific switch is Babel's `retainLines`, which it turns on for
// dev (and so for vitest) unless the compiler is present. With the compiler's re-printed JSX that eats
// the space at the end of a text line ("for example <code>" rendered "for example/api/feed"). The
// production build never had it on. `generatorOpts` wins over the top-level option, so it is switched
// off here, for every mode. Pinned by src/jsxWhitespace.dom.test.tsx.
export const reactWithCompiler = () =>
react({
babel: {
plugins: [[reactCompiler, { target: "19" }]],
generatorOpts: { retainLines: false },
},
});
+7 -6
View File
@@ -32,7 +32,8 @@ import Header from "./components/Header";
import NavSidebar from "./components/NavSidebar";
import BackToTop from "./components/ui/BackToTop";
import PageShell from "./components/ui/PageShell";
import GlassTuner from "./components/GlassTuner";
import { SkipLink } from "./components/ui/SkipLink";
import { pageTitleKey } from "./lib/pageMeta";
import ChatDock from "./components/ChatDock";
import DebugRecorder from "./components/DebugRecorder";
import Toaster from "./components/ui/Toaster";
@@ -45,8 +46,8 @@ import DemoBanner from "./components/DemoBanner";
import VerifyBanner from "./components/VerifyBanner";
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
// The built app version, NOT imported from releaseNotes — importing anything from that module
// pulls its whole ~900-line changelog into the eager App chunk and defeats the lazy ReleaseNotes
// split. FRONTEND_VERSION equals RELEASE_NOTES[0].version by release convention (both bump together).
// pulls the release-notes loader into the eager App chunk and defeats the lazy ReleaseNotes
// split. FRONTEND_VERSION equals the newest release note's version by release convention (both bump together).
import { FRONTEND_VERSION } from "./lib/version";
import { applyDiagnostics } from "./lib/diagnostics";
@@ -368,7 +369,8 @@ export default function App() {
const railDef = channelView ? undefined : PAGE_RAIL[activePage];
return (
<div className="h-screen flex text-fg">
<div className="h-dvh flex text-fg">
<SkipLink label={t("nav.skipToContent")} />
<NavSidebar
me={me!}
onOpenAbout={() => setAboutOpen(true)}
@@ -429,6 +431,7 @@ export default function App() {
</PageShell>
) : (
<PageShell
title={t(pageTitleKey(activePage))}
scrollKey={normalScrollKey}
maxWidth={maxWidth}
header={<Header me={me!} onYtSearch={enterYtSearch} />}
@@ -475,8 +478,6 @@ export default function App() {
<ErrorDialog />
{/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
<BackToTop dep={channelView ? `chan:${channelView.id}` : page} />
{/* Opt-in live appearance tuner (Settings → Appearance; off by default). */}
<GlassTuner />
</div>
);
}
+1
View File
@@ -45,6 +45,7 @@ export default function About({
return (
<Modal
initialFocus="close"
title={
<span className="flex items-center gap-2">
<Info className="w-5 h-5 text-accent" /> {t("about.title")}
+2 -1
View File
@@ -5,6 +5,7 @@ import { api } from "../lib/api";
import ChannelAboutContent from "./ChannelAbout";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { SURFACE } from "./ui/surfaces";
/**
* A channel-description cell: one truncated line in the table, revealing the FULL channel "About"
@@ -56,7 +57,7 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
</span>
<PopoverSurface
popover={pop}
className="glass w-120 overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
className={`${SURFACE.popover} w-120 overflow-y-auto px-3.5 py-3 animate-[fadeIn_0.12s_ease]`}
// min(design cap, available space): keeps the 28rem cap on a tall viewport, shrinks to fit on a
// short one (high zoom). --fit-max-h is set by the fitHeight `size` middleware once positioned.
style={{ maxHeight: "min(28rem, var(--fit-max-h, 28rem))" }}
+43 -27
View File
@@ -13,7 +13,9 @@ import { copyText } from "../lib/clipboard";
import { formatDateTime } from "../lib/format";
import { invalidateUserAccess } from "../lib/adminUsers";
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { Button } from "./ui/Button";
import { SURFACE } from "./ui/surfaces";
import { SectionLabel } from "./ui/Headings";
// The admin's answer to "why can this person not get in?", and the place every recovery action for
// one account lives (E12 · S5, design option C).
@@ -103,8 +105,14 @@ export default function AccountPanel({
const a: AdminUserAccess | undefined = q.data;
return (
<Modal title={displayName(user)} onClose={onClose} placement="drawer" maxWidth="max-w-md">
<div className="text-2xs text-muted truncate -mt-2 mb-4">{user.email}</div>
<Modal
initialFocus="close"
title={displayName(user)}
onClose={onClose}
placement="drawer"
maxWidth="max-w-md"
>
<div className="text-2xs text-muted truncate mb-4">{user.email}</div>
{q.isError && !a ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
@@ -114,10 +122,10 @@ export default function AccountPanel({
<LoadingState />
) : (
<>
<div className="text-2xs uppercase tracking-wider text-muted mb-2">
<SectionLabel level={3} className="mb-2">
{t("users.account.access")}
</div>
<div className="glass-card rounded-xl p-3 space-y-2 mb-5">
</SectionLabel>
<div className={`${SURFACE.card} p-3 space-y-2 mb-5`}>
<AccessRow
icon={Mail}
label={t("users.account.email")}
@@ -191,29 +199,33 @@ export default function AccountPanel({
)}
</div>
<div className="text-2xs uppercase tracking-wider text-muted mb-2">
<SectionLabel level={3} className="mb-2">
{t("users.account.recovery")}
</div>
</SectionLabel>
<div className="space-y-2">
{a.can_activate && (
<FocusSafeButton
<Button
variant="glass"
fullWidth
icon={<Check />}
loading={activate.isPending}
onClick={() => activate.mutate()}
disabled={activate.isPending}
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs border border-accent/40 text-accent disabled:opacity-50 transition"
className="border border-accent/40 text-accent"
>
<Check className="w-4 h-4 shrink-0" />
<span className="flex-1 text-left">{t("users.account.activate")}</span>
</FocusSafeButton>
</Button>
)}
<FocusSafeButton
<Button
variant="glass"
fullWidth
icon={<Send />}
loading={resend.isPending}
onClick={() => resend.mutate()}
// The demo account's address is never confirmed, and the server refuses to mail it — as
// it refuses it a set-password link below (deep-review E11 round 3).
disabled={user.is_demo || !a.email_configured || a.email_verified || resend.isPending}
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs disabled:opacity-45 transition"
disabled={user.is_demo || !a.email_configured || a.email_verified}
>
<Send className="w-4 h-4 shrink-0" />
<span className="flex-1 text-left">{t("users.account.resend")}</span>
{/* Says WHY it is unavailable instead of just greying out — the no-mail case is the
whole reason the link below exists, so naming it here is the explanation. */}
@@ -223,18 +235,21 @@ export default function AccountPanel({
{a.email_configured && a.email_verified && (
<span className="text-muted">{t("users.account.alreadyConfirmed")}</span>
)}
</FocusSafeButton>
</Button>
<FocusSafeButton
<Button
variant="glass"
fullWidth
icon={<KeyRound />}
loading={issue.isPending}
disabled={user.is_demo}
onClick={() => issue.mutate()}
disabled={issue.isPending || user.is_demo}
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs border border-accent/40 text-accent disabled:opacity-50 transition"
className="border border-accent/40 text-accent"
>
<KeyRound className="w-4 h-4 shrink-0" />
<span className="flex-1 text-left">
{link ? t("users.account.setPasswordAgain") : t("users.account.setPassword")}
</span>
</FocusSafeButton>
</Button>
</div>
{link && (
@@ -243,13 +258,14 @@ export default function AccountPanel({
<div className="flex-1 min-w-0 bg-surface border border-border rounded-lg px-3 py-2 text-2xs font-mono truncate">
{link.url}
</div>
<button
<Button
variant="glass"
icon={copied ? <Check /> : <Copy />}
onClick={copy}
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs border border-accent/40 text-accent transition"
className="border border-accent/40 text-accent"
>
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
{copied ? t("users.account.copied") : t("users.account.copy")}
</button>
</Button>
</div>
<p className="text-2xs text-muted leading-relaxed">{t("users.account.linkNote")}</p>
</div>
+27 -28
View File
@@ -6,6 +6,10 @@ import { decideChipLayout } from "../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Button } from "./ui/Button";
import { Chip } from "./ui/Chip";
import { chipClass } from "./ui/chipStyles";
import { SURFACE } from "./ui/surfaces";
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
// own. It exists because the filter panel now sits collapsed as a tab — without this you'd have to
@@ -22,23 +26,19 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
// the "fit some + N more" treatment used on wider lanes.
const NARROW_PX = 420;
const chipCls =
"chip group inline-flex items-center gap-1 pl-2.5 pr-1.5 py-1 text-sm whitespace-nowrap hover:border-accent hover:bg-accent/20 transition";
function ChipButton({ f, testId }: { f: ActiveFilter; testId?: string }) {
const { t } = useTranslation();
return (
<button
<Chip
variant="token"
size="md"
data-testid={testId}
data-filter-key={f.key}
onClick={f.remove}
title={t("feed.chips.remove", { name: f.label })}
aria-label={t("feed.chips.remove", { name: f.label })}
className={chipCls}
removeLabel={t("feed.chips.remove", { name: f.label })}
>
<span className="max-w-56 truncate">{f.label}</span>
<X className="w-3 h-3 shrink-0 opacity-60 group-hover:opacity-100" />
</button>
</Chip>
);
}
@@ -69,22 +69,20 @@ function OverflowPopover({
const name = ariaLabel ?? label;
return (
<>
<button
type="button"
<Chip
variant="token"
size="md"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={name}
className={`chip inline-flex shrink-0 items-center gap-1 px-2.5 py-1 text-sm whitespace-nowrap transition ${
open ? "border-accent text-accent" : "hover:border-accent"
}`}
icon={icon ? <Filter className="size-3.5" aria-hidden="true" /> : undefined}
>
{icon && <Filter className="w-3.5 h-3.5 shrink-0" aria-hidden="true" />}
{label}
</button>
</Chip>
<PopoverSurface
popover={pop}
ariaLabel={name}
className="glass-menu min-w-48 max-w-72 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
className={`${SURFACE.menu} min-w-48 max-w-72 animate-[popIn_0.16s_ease]`}
>
<div role="group" aria-label={name} className="max-h-72 overflow-y-auto no-scrollbar">
{chips.map((f) => (
@@ -93,7 +91,7 @@ function OverflowPopover({
role="menuitem"
onSelect={f.remove}
label={f.label}
className="group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-fg transition"
className="group flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm text-fg transition"
>
<span className="flex-1 truncate">{f.label}</span>
<X className="w-3.5 h-3.5 shrink-0 opacity-60 group-hover:opacity-100" />
@@ -101,17 +99,17 @@ function OverflowPopover({
))}
</div>
<div className="mt-1 border-t border-border px-1 pt-1.5">
<button
type="button"
<Button
variant="link-muted"
data-testid="active-filter-clear-all"
onClick={() => {
onClearAll();
setOpen(false);
}}
className="text-xs text-muted hover:text-fg underline underline-offset-2 transition"
className="text-xs underline underline-offset-2"
>
{t("sidebar.clearAll")}
</button>
</Button>
</div>
</PopoverSurface>
</>
@@ -191,13 +189,13 @@ export default function ActiveFilterChips({
ref={(el) => {
chipRefs.current[i] = el;
}}
className={chipCls}
className={chipClass("token", "md") + " pr-1.5"}
>
<span className="max-w-56 truncate">{f.label}</span>
<X className="w-3 h-3 shrink-0" />
<X className="size-3" />
</span>
))}
<span ref={plusRef} className="chip inline-flex items-center gap-1 px-2.5 py-1 text-sm">
<span ref={plusRef} className={chipClass("token", "md")}>
+{n}
</span>
<span ref={clearRef} className="ml-0.5 text-sm underline underline-offset-2">
@@ -230,13 +228,14 @@ export default function ActiveFilterChips({
/>
)}
{layout.mode === "all" && n > 1 && (
<button
<Button
variant="link-muted"
data-testid="active-filter-clear-all"
onClick={onClearAll}
className="ml-0.5 shrink-0 text-sm text-muted hover:text-fg underline underline-offset-2 transition"
className="ml-0.5 text-sm underline underline-offset-2"
>
{t("sidebar.clearAll")}
</button>
</Button>
)}
</>
)}
+78 -38
View File
@@ -1,14 +1,18 @@
import { useState } from "react";
import { useLayoutEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { Check, ListPlus, Plus, Youtube } from "lucide-react";
import { ListPlus, Plus, Youtube } from "lucide-react";
import { MenuCheck } from "./ui/MenuCheck";
import { api, type Playlist } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { useScrollFade } from "../lib/useScrollFade";
import { playlistName } from "../lib/playlistName";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { IconButton } from "./ui/IconButton";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
// "new playlist" creation. Same shape as the channel tag editor (R8 S1's pilot): radio-style
@@ -16,26 +20,82 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
// the input swallowing non-Escape keys, since character and arrow keys belong to the text field.
export default function AddToPlaylist({
videoId,
className,
variant = "ghost",
size = "sm",
}: {
videoId: string;
className?: string;
variant?: "ghost" | "outline" | "overlay" | "media";
size?: "sm" | "md";
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const fade = useScrollFade();
const [open, setOpen] = useState(false);
const [newName, setNewName] = useState("");
const [busy, setBusy] = useState(false);
const pop = useMembershipPopover(open, setOpen);
return (
<>
<IconButton
ref={pop.refs.setReference}
variant={variant}
size={size}
{...pop.getReferenceProps({
// The trigger sits on a feed card that opens the player on click, so the toggle must not
// reach it.
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
},
})}
label={t("playlists.addToPlaylist")}
>
<ListPlus />
</IconButton>
<MembershipPanel videoId={videoId} pop={pop} />
</>
);
}
const pop = useAnchoredPopover({
/** The same panel, opened from an object's menu ("Add to playlist…", E28 C1) rather than its own
* button: anchored to the element the menu came from, and gone when it closes. */
export function AddToPlaylistAt({
videoId,
anchor,
onClose,
}: {
videoId: string;
anchor: Element;
onClose: () => void;
}) {
const pop = useMembershipPopover(true, (next) => {
if (!next) onClose();
});
const { setPositionReference } = pop.refs;
useLayoutEffect(() => setPositionReference(anchor), [anchor, setPositionReference]);
return <MembershipPanel videoId={videoId} pop={pop} />;
}
function useMembershipPopover(open: boolean, onOpenChange: (open: boolean) => void) {
return useAnchoredPopover({
open,
onOpenChange: setOpen,
onOpenChange,
offset: 6,
// The menu holds a "new playlist" text field — keep typed chars and pointer-hover out of the
// roving list so they don't steal the caret.
hostsInput: true,
});
}
function MembershipPanel({
videoId,
pop,
}: {
videoId: string;
pop: ReturnType<typeof useAnchoredPopover>;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const fade = useScrollFade();
const open = pop.open;
const [newName, setNewName] = useState("");
const [busy, setBusy] = useState(false);
const membership = useQuery({
queryKey: qk.playlistMembership(videoId),
@@ -90,28 +150,13 @@ export default function AddToPlaylist({
return (
<>
<button
ref={pop.refs.setReference}
{...pop.getReferenceProps({
// The trigger sits on a feed card that opens the player on click, so the toggle must not
// reach it.
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
},
})}
title={t("playlists.addToPlaylist")}
aria-label={t("playlists.addToPlaylist")}
className={className ?? "p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"}
>
<ListPlus className="w-4 h-4" />
</button>
<PopoverSurface popover={pop} className="glass-menu w-60 rounded-xl p-2">
<PopoverSurface popover={pop} className={`${SURFACE.popover} w-60 p-2`}>
{/* A React portal still propagates events up the REACT tree, so a click inside this panel
would otherwise reach the feed card that hosts the trigger and start playback. */}
<div onClick={(e) => e.stopPropagation()}>
<div className="text-xs text-muted px-1.5 pb-1.5">{t("playlists.addToPlaylist")}</div>
<div className="text-xs text-muted px-1.5 pb-1.5">
{t("playlists.addToPlaylistTitle")}
</div>
<div
ref={fade.ref}
style={fade.style}
@@ -135,13 +180,7 @@ export default function AddToPlaylist({
label={playlistName(pl, t)}
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-hidden transition"
>
<span
className={`grid place-items-center w-4 h-4 rounded border ${
pl.has_video ? "bg-accent border-accent text-accent-fg" : "border-border"
}`}
>
{pl.has_video && <Check className="w-3 h-3" />}
</span>
<MenuCheck kind="many" on={!!pl.has_video} />
<span className="truncate flex-1 text-left">{playlistName(pl, t)}</span>
{(pl.source === "youtube" || pl.yt_playlist_id) && (
<Youtube className="w-3 h-3 shrink-0 text-muted" />
@@ -151,7 +190,9 @@ export default function AddToPlaylist({
</div>
<div className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5">
<Plus className="w-3.5 h-3.5 text-muted shrink-0" />
<input
<Input
className="flex-1"
size="sm"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
@@ -165,7 +206,6 @@ export default function AddToPlaylist({
}
}}
placeholder={t("playlists.newPlaylist")}
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
</div>
</div>
+140 -119
View File
@@ -1,3 +1,4 @@
import clsx from "clsx";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -6,11 +7,11 @@ import {
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { Copy, Radio, Search, X } from "lucide-react";
import { Copy, Radio, Search } from "lucide-react";
import { api, type LogFacet, type LogQuery, type ServerLogRow } from "../lib/api";
import { copyText } from "../lib/clipboard";
import { formatDateTime } from "../lib/format";
import { formatDateTimeSec } from "../lib/format";
import { nextNavId, notifyNav, rememberNavPosition } from "../lib/history";
import { LEVEL_GROUPS, type LevelGroup } from "../lib/log";
import {
@@ -31,8 +32,14 @@ import NavGrid from "./ui/NavGrid";
import { Button } from "./ui/Button";
import FilterChipGroup from "./ui/FilterChipGroup";
import FilterSelect from "./ui/FilterSelect";
import { FilterFoldButton } from "./ui/FilterFold";
import { useFilterFold } from "../lib/useFilterFold";
import { useStickyTop } from "./ui/stickyTop";
import { Section } from "./ui/form";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { DismissibleChip } from "./ui/Chip";
import { InnerInput } from "./ui/Input";
import { fieldClass } from "./ui/fieldStyles";
// Admin → Szervernapló: the server's stored log (`log_events`, E22.6). One list, newest first; a line
// opens in place to its full record, and its request id / job id narrow the list to that request or
@@ -73,6 +80,14 @@ export default function AdminLogs() {
logsParamsToState(new URLSearchParams(window.location.search)),
);
const [live, setLiveState] = useState(false);
// The toolbar sticks at the scroller's top (./ui/stickyTop), and folds its facet row when short.
useStickyTop();
const fold = useFilterFold();
const facetFilters =
state.sources.length +
(state.user ? 1 : 0) +
(state.event ? 1 : 0) +
(state.window !== DEFAULT_LOGS_STATE.window ? 1 : 0);
// Read by `getNextPageParam` when a page is fetched, not when it renders: while live, a refetch
// stops after the newest page, however many pages the cache holds — a "Load older" that lands
// after Live was switched on, or a filter set revisited with older pages still cached.
@@ -241,104 +256,119 @@ export default function AdminLogs() {
<div
data-testid="admin-logs"
className="overflow-hidden rounded-xl border border-border bg-bg"
// overflow-clip, not -hidden: a hidden box is a scroll container, and the toolbar would stick
// to it instead of to the page.
className="overflow-clip rounded-xl border border-border bg-bg"
>
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
<label className="flex min-w-[12rem] flex-1 items-center gap-1.5 rounded-lg border border-border bg-card px-2 py-1 text-sm focus-within:border-accent">
<Search className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden />
<input
type="search"
value={state.q}
onChange={(e) => update({ q: e.target.value.slice(0, 200) })}
placeholder={t("adminLogs.search")}
aria-label={t("adminLogs.search")}
className="min-w-0 flex-1 bg-transparent outline-none"
{/* The console's toolbar is the page's band (B4, B9): it sticks at the top with the fade
under it while the lines scroll, so a filter is in reach at line 500. */}
<div className="sticky top-0 z-base bg-bg fade-below">
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
<label
className={clsx(
fieldClass({ size: "sm", pill: true }),
"flex min-w-[12rem] flex-1 items-center gap-1.5",
)}
>
<Search className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden />
<InnerInput
type="search"
value={state.q}
onChange={(e) => update({ q: e.target.value.slice(0, 200) })}
placeholder={t("adminLogs.search")}
aria-label={t("adminLogs.search")}
/>
</label>
<FilterChipGroup
ariaLabel={t("adminLogs.levelLabel")}
value={state.group}
onChange={(group) => update({ group })}
options={GROUPS.map((g) => ({
value: g,
label: t(`settings.myLog.filters.${g}`),
count: groupCount(g),
}))}
/>
</label>
<FilterChipGroup
ariaLabel={t("adminLogs.levelLabel")}
value={state.group}
onChange={(group) => update({ group })}
options={GROUPS.map((g) => ({
value: g,
label: t(`settings.myLog.filters.${g}`),
count: groupCount(g),
}))}
/>
</div>
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
<FilterSelect
label={t("adminLogs.source")}
options={facetOptions(facets.data?.sources, state.sources[0] ?? null, (v) => v)}
selected={[state.sources[0] ?? ""]}
onChange={([source]) => update({ sources: source ? [source] : [] })}
testId="logs-source"
/>
<FilterSelect
label={t("adminLogs.account")}
options={facetOptions(facets.data?.users, state.user, accountLabel)}
selected={[state.user ?? ""]}
onChange={([user]) => update({ user: user || null })}
testId="logs-user"
/>
<FilterSelect
label={t("adminLogs.event")}
options={facetOptions(facets.data?.events, state.event, (v) =>
v === "none" ? t("adminLogs.noEvent") : v,
)}
selected={[state.event ?? ""]}
onChange={([event]) => update({ event: event || null })}
testId="logs-event"
/>
<FilterSelect
label={t("adminLogs.window")}
options={WINDOWS.map((w) => ({ value: w, label: t(`adminLogs.windows.${w}`) }))}
selected={[state.window]}
onChange={([window]) => update({ window: window as LogWindow })}
testId="logs-window"
/>
<span className="ml-auto flex gap-1.5">
<Button
variant={polling ? "outline-accent" : "outline"}
size="xs"
icon={<Radio className="h-3.5 w-3.5" />}
aria-pressed={polling}
onClick={toggleLive}
>
{t("adminLogs.live")}
</Button>
<Button
variant="outline"
size="xs"
icon={<Copy className="h-3.5 w-3.5" />}
// While a filter change loads, the rows on screen are the previous filter's.
disabled={rows.length === 0 || logs.isPlaceholderData}
onClick={() => void copy()}
>
{t("adminLogs.copy")}
</Button>
</span>
</div>
{(state.requestId || state.jobId !== null) && (
<div className="flex flex-wrap items-center gap-2 border-b border-border px-2 py-1.5 text-xs">
{state.requestId && (
<PivotChip
label={t("adminLogs.pivotRequest", { id: state.requestId })}
clearLabel={t("adminLogs.clearPivot")}
onClear={() => update({ requestId: null })}
/>
)}
{state.jobId !== null && (
<PivotChip
label={t("adminLogs.pivotJob", { id: state.jobId })}
clearLabel={t("adminLogs.clearPivot")}
onClear={() => update({ jobId: null })}
/>
)}
</div>
)}
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
<FilterFoldButton fold={fold} label={t("sidebar.filters")} count={facetFilters} />
{!fold.folded && (
<>
<FilterSelect
label={t("adminLogs.source")}
options={facetOptions(facets.data?.sources, state.sources[0] ?? null, (v) => v)}
selected={[state.sources[0] ?? ""]}
onChange={([source]) => update({ sources: source ? [source] : [] })}
testId="logs-source"
/>
<FilterSelect
label={t("adminLogs.account")}
options={facetOptions(facets.data?.users, state.user, accountLabel)}
selected={[state.user ?? ""]}
onChange={([user]) => update({ user: user || null })}
testId="logs-user"
/>
<FilterSelect
label={t("adminLogs.event")}
options={facetOptions(facets.data?.events, state.event, (v) =>
v === "none" ? t("adminLogs.noEvent") : v,
)}
selected={[state.event ?? ""]}
onChange={([event]) => update({ event: event || null })}
testId="logs-event"
/>
<FilterSelect
label={t("adminLogs.window")}
options={WINDOWS.map((w) => ({ value: w, label: t(`adminLogs.windows.${w}`) }))}
selected={[state.window]}
onChange={([window]) => update({ window: window as LogWindow })}
testId="logs-window"
/>
</>
)}
<span className="ml-auto flex gap-1.5">
<Button
variant="outline"
size="xs"
icon={<Radio className="h-3.5 w-3.5" />}
pressed={polling}
onClick={toggleLive}
>
{t("adminLogs.live")}
</Button>
<Button
variant="outline"
size="xs"
icon={<Copy className="h-3.5 w-3.5" />}
// While a filter change loads, the rows on screen are the previous filter's.
disabled={rows.length === 0 || logs.isPlaceholderData}
onClick={() => void copy()}
>
{t("adminLogs.copy")}
</Button>
</span>
</div>
{(state.requestId || state.jobId !== null) && (
<div className="flex flex-wrap items-center gap-2 border-b border-border px-2 py-1.5 text-xs">
{state.requestId && (
<PivotChip
label={t("adminLogs.pivotRequest", { id: state.requestId })}
clearLabel={t("adminLogs.clearPivot")}
onClear={() => update({ requestId: null })}
/>
)}
{state.jobId !== null && (
<PivotChip
label={t("adminLogs.pivotJob", { id: state.jobId })}
clearLabel={t("adminLogs.clearPivot")}
onClear={() => update({ jobId: null })}
/>
)}
</div>
)}
</div>
{failure && (
<StateMessage
@@ -361,7 +391,7 @@ export default function AdminLogs() {
{t("common.loadError")}
</StateMessage>
) : logs.isLoading ? (
<LoadingState />
<LoadingState shape="table" />
) : rows.length === 0 ? (
<p className="px-3 py-6 text-center font-sans text-sm text-muted">
{t("adminLogs.empty")}
@@ -376,7 +406,7 @@ export default function AdminLogs() {
level={row.level}
event={row.event ?? "—"}
msg={row.msg}
title={formatDateTime(row.ts)}
title={formatDateTimeSec(row.ts)}
open={open === row.id}
onToggle={() => setOpen(open === row.id ? null : row.id)}
>
@@ -421,18 +451,9 @@ function PivotChip({
onClear: () => void;
}) {
return (
<span className="chip py-0.5 pl-2.5 pr-1">
<span className="font-mono">{label}</span>
<button
type="button"
onClick={onClear}
aria-label={clearLabel}
title={clearLabel}
className="rounded-full p-0.5 hover:bg-accent/20"
>
<X className="h-3 w-3" aria-hidden />
</button>
</span>
<DismissibleChip onDismiss={onClear} dismissLabel={clearLabel} className="font-mono">
{label}
</DismissibleChip>
);
}
@@ -449,12 +470,12 @@ function RowDetail({
}) {
const { t } = useTranslation();
const pivotCls =
"font-mono text-accent underline decoration-dotted underline-offset-2 hover:decoration-solid";
"font-mono underline decoration-dotted underline-offset-2 hover:decoration-solid";
return (
<div className="flex flex-col gap-2">
<dl className="grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-0.5 font-sans text-xs">
<dt>{t("adminLogs.fields.when")}</dt>
<dd className="tabular-nums">{formatDateTime(row.ts)}</dd>
<dd className="tabular-nums">{formatDateTimeSec(row.ts)}</dd>
<dt>{t("adminLogs.fields.source")}</dt>
<dd className="font-mono">
{row.source} · {row.logger}
@@ -465,14 +486,14 @@ function RowDetail({
<>
<dt>{t("adminLogs.fields.request")}</dt>
<dd>
<button
type="button"
<Button
variant="link"
className={pivotCls}
title={t("adminLogs.showRequest")}
onClick={() => onRequest(row.request_id!)}
>
{row.request_id}
</button>
</Button>
</dd>
</>
)}
@@ -480,14 +501,14 @@ function RowDetail({
<>
<dt>{t("adminLogs.fields.job")}</dt>
<dd>
<button
type="button"
<Button
variant="link"
className={pivotCls}
title={t("adminLogs.showJob")}
onClick={() => onJob(row.job_id!)}
>
{row.job_id}
</button>
</Button>
</dd>
</>
)}
@@ -98,6 +98,24 @@ describe("the last sign-in column (E22 S1)", () => {
});
});
// E28 (owner): the hover ended wherever the row's first badge or button began, because the layer was
// the name button's own. The card is the row: it paints the layer under its content, and the name
// button's hit layer spans the card, under the actions that sit above it.
describe("a user row", () => {
it("answers as a whole card, with its actions above the card's hit layer", async () => {
mount();
const open = await screen.findByTitle("users.account.open");
const card = open.parentElement!;
expect(card.className).toMatch(/(^|\s)isolate(\s|$)/);
expect(card.className).toMatch(/(^|\s)hover:before:bg-\(--hover\)(\s|$)/);
expect(open.className).toMatch(/(^|\s)after:inset-0(\s|$)/);
expect(open.className).not.toMatch(/selectable|hover:/);
const actions = [...card.querySelectorAll("button")].filter((b) => b !== open);
expect(actions.length).toBeGreaterThan(0);
for (const b of actions) expect(b.className).toMatch(/(^|\s)relative(\s|$)/);
});
});
describe("a user action that fails", () => {
it("says a user who is gone, and refetches the list", async () => {
const users = mount();
+141 -127
View File
@@ -28,7 +28,9 @@ import { notify } from "../lib/notifications";
import { formatDateTime, relativeTime } from "../lib/format";
import { invalidateUserAccess } from "../lib/adminUsers";
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
import { Badge } from "./ui/Badge";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import Tooltip from "./ui/Tooltip";
import InlineNotice from "./ui/InlineNotice";
import { Section } from "./ui/form";
@@ -38,11 +40,14 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
import AccountPanel from "./AccountPanel";
import { useConfirm } from "./confirmContext";
import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
import { useAccountPersistedState } from "../lib/storage";
import { PageToolbar } from "./ui/PageShell";
import { useMe } from "../lib/useMe";
import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab
@@ -50,10 +55,18 @@ import { FocusSafeButton } from "./ui/FocusSafeButton";
// (The tab key + a "focus the Access tab" helper live in lib/adminUsersTab so callers can
// pre-select the tab without importing this lazy-loaded page — see that file.)
// A user row's B3 layer (DESIGN.md "State layers"), painted by the card under its content, and the name
// button's stretched hit layer over the whole card. Why the row is built so: at the row below.
const ROW_LAYER =
"relative isolate before:absolute before:inset-0 before:z-under before:rounded-[inherit] before:transition-colors before:content-[''] hover:before:bg-(--hover) has-[[data-row-open]:active]:before:bg-(--press)";
const ROW_OPEN =
"focus-visible:outline-none after:absolute after:inset-0 after:rounded-xl after:content-[''] focus-visible:after:outline-2 focus-visible:after:-outline-offset-2 focus-visible:after:outline-accent";
export default function AdminUsers() {
const me = useMe();
const { t } = useTranslation();
const [tab, setTab] = useAccountPersistedState(ADMIN_USERS_TAB_KEY, "roles");
const panel = useTabPanel();
const tabs = [
{ id: "roles", label: t("users.tabs.roles") },
// Both kinds of "waiting on the admin": a request needing a decision, and a sign-up whose
@@ -72,10 +85,10 @@ export default function AdminUsers() {
{/* The tab bar is fixed chrome; the tab content scrolls under it. */}
<PageToolbar>
<div className="px-4 pt-2 max-w-3xl w-full mx-auto">
<Tabs tabs={tabs} active={active} onChange={setTab} />
<Tabs idBase={panel.idBase} tabs={tabs} active={active} onChange={setTab} />
</div>
</PageToolbar>
<div className="px-4 pb-4 pt-3 max-w-3xl w-full mx-auto">
<div {...panel.panelProps(active)} className="px-4 pb-4 pt-3 max-w-3xl w-full mx-auto">
{active === "roles" && <UsersRoles me={me} />}
{active === "access" && (
<>
@@ -89,24 +102,6 @@ export default function AdminUsers() {
);
}
function Badge({
tone,
children,
}: {
tone: "accent" | "muted" | "warning";
children: React.ReactNode;
}) {
const cls =
tone === "accent"
? "border-accent/40 text-accent"
: tone === "warning"
? "border-warn/40 text-warn"
: "border-border text-muted";
return (
<span className={`shrink-0 text-2xs px-2 py-0.5 rounded-full border ${cls}`}>{children}</span>
);
}
function UsersRoles({ me }: { me: Me }) {
const { t } = useTranslation();
const qc = useQueryClient();
@@ -221,7 +216,7 @@ function UsersRoles({ me }: { me: Me }) {
{t("common.loadError")}
</StateMessage>
) : q.isLoading && !q.data ? (
<LoadingState />
<LoadingState shape="list" />
) : rows.length === 0 ? (
<p className="text-sm text-muted">{t("users.roles.empty")}</p>
) : (
@@ -229,28 +224,39 @@ function UsersRoles({ me }: { me: Me }) {
{rows.map((u) => {
const self = u.id === me.id;
return (
<div key={u.id} className="glass-card flex items-center gap-2.5 p-2.5 rounded-xl">
<button
// The whole card is the row, so the whole card answers (owner, E28): the name's button
// stretches an invisible layer over the card (`after:inset-0`), so a click on a badge or
// a gap opens the account too, and the B3 hover layer is the card's own `::before`, under
// the badges and buttons (`isolate` + `z-under`). The actions are `relative`, above the
// stretched layer. The hover used to be the name button's own and ended at the first badge.
<div
key={u.id}
className={`${SURFACE.card} ${ROW_LAYER} flex items-center gap-2.5 p-2.5`}
>
<Pressable
selectable
data-row-open=""
onClick={() => setPanelFor(u)}
title={t("users.account.open")}
className="selectable cursor-pointer min-w-0 flex-1 text-left rounded-lg -m-1 p-1"
className={`min-w-0 flex-1 cursor-pointer ${ROW_OPEN}`}
>
<div className="text-sm truncate">
{displayName(u)}
{self && <span className="text-muted"> · {t("users.roles.you")}</span>}
</div>
<div className="text-2xs text-muted truncate">{u.email}</div>
</button>
</Pressable>
{u.role === "admin" && <Badge tone="accent">{t("users.roles.admin")}</Badge>}
{u.is_demo && <Badge tone="muted">{t("users.roles.demo")}</Badge>}
{u.is_suspended && <Badge tone="warning">{t("users.roles.suspended")}</Badge>}
{!u.is_active && <Badge tone="warning">{t("users.roles.pending")}</Badge>}
{u.is_demo && <Badge>{t("users.roles.demo")}</Badge>}
{u.is_suspended && <Badge tone="warn">{t("users.roles.suspended")}</Badge>}
{!u.is_active && <Badge tone="warn">{t("users.roles.pending")}</Badge>}
{/* A Google sign-in proves the email, so "unverified" only applies to a
password-only account that hasn't clicked its verification link. */}
{u.is_active && !u.email_verified && !u.has_google && (
<Badge tone="warning">{t("users.roles.unverified")}</Badge>
<Badge tone="warn">{t("users.roles.unverified")}</Badge>
)}
<Tooltip
always={u.is_demo || self}
hint={
u.is_demo
? t("users.roles.demoLocked")
@@ -264,15 +270,19 @@ function UsersRoles({ me }: { me: Me }) {
<Button
variant="glass"
size="xs"
className="shrink-0"
className="relative shrink-0"
onClick={() => onToggle(u)}
disabled={u.is_demo || self || pendingIds.has(u.id)}
// Locked (demo / self) is unavailable WITH a reason, the tooltip's: aria-disabled keeps it
// focusable so a keyboard user reaches the reason. A pending write is transient.
aria-disabled={u.is_demo || self}
disabled={pendingIds.has(u.id)}
icon={<Shield className="w-3.5 h-3.5" />}
>
{u.role === "admin" ? t("users.roles.makeUser") : t("users.roles.makeAdmin")}
</Button>
</Tooltip>
<Tooltip
always={u.is_demo || self}
hint={
u.is_demo
? t("users.suspend.demoLocked")
@@ -283,36 +293,41 @@ function UsersRoles({ me }: { me: Me }) {
: t("users.suspend.action")
}
>
<Button
variant={u.is_suspended ? "outline-warn" : "outline"}
size="icon-sm"
className="shrink-0"
onClick={() => onSuspend(u)}
disabled={u.is_demo || self || pendingIds.has(u.id)}
aria-label={
<IconButton
noTitle
label={
u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action")
}
icon={
u.is_suspended ? (
<RotateCcw className="w-4 h-4" />
) : (
<Ban className="w-4 h-4" />
)
}
/>
size="sm"
variant={u.is_suspended ? "outline-warn" : "outline"}
className="relative"
onClick={() => onSuspend(u)}
aria-disabled={u.is_demo || self}
disabled={pendingIds.has(u.id)}
>
{u.is_suspended ? (
<RotateCcw className="w-4 h-4" />
) : (
<Ban className="w-4 h-4" />
)}
</IconButton>
</Tooltip>
<Tooltip
always={u.is_demo || self}
hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}
>
<Button
<IconButton
noTitle
label={t("users.delete.action")}
size="sm"
variant="outline-danger"
size="icon-sm"
className="shrink-0"
className="relative"
onClick={() => onDelete(u)}
disabled={u.is_demo || self || pendingIds.has(u.id)}
aria-label={t("users.delete.action")}
icon={<Trash2 className="w-4 h-4" />}
/>
aria-disabled={u.is_demo || self}
disabled={pendingIds.has(u.id)}
>
<Trash2 className="w-4 h-4" />
</IconButton>
</Tooltip>
{/* How long ago this account last signed in. LAST in the row on purpose: the badges and
the role button before it vary in width row to row, so only the row's own right
@@ -327,7 +342,7 @@ function UsersRoles({ me }: { me: Me }) {
: t("users.roles.lastLoginNone")
}
>
<span className="hidden sm:inline-block w-20 shrink-0 text-right text-2xs text-muted tabular-nums">
<span className="relative hidden sm:inline-block w-20 shrink-0 text-right text-2xs text-muted tabular-nums">
{u.last_login_at ? relativeTime(u.last_login_at) : "—"}
</span>
</Tooltip>
@@ -392,7 +407,7 @@ function PendingAccess() {
{t("common.loadError")}
</StateMessage>
) : rows.isLoading && !rows.data ? (
<LoadingState />
<LoadingState shape="list" />
) : (
<div className="space-y-1.5">
{list.map((u) => (
@@ -427,7 +442,7 @@ function PendingAccessRowView({
}) {
const { t } = useTranslation();
return (
<div className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
<div className={`${SURFACE.card} flex items-center gap-2 p-2.5`}>
<div className="min-w-0 flex-1">
<div className="text-sm truncate">{row.email}</div>
<div className="text-2xs text-muted flex items-center gap-2 mt-0.5 flex-wrap">
@@ -459,15 +474,16 @@ function PendingAccessRowView({
</div>
{!row.email_verified && (
<Tooltip hint={t("users.pendingAccess.resendHint")}>
<Button
<IconButton
noTitle
label={t("users.pendingAccess.resend")}
size="sm"
variant="outline"
size="icon-sm"
className="shrink-0"
onClick={onResend}
disabled={busy}
aria-label={t("users.pendingAccess.resend")}
icon={<Send className="w-4 h-4" />}
/>
>
<Send className="w-4 h-4" />
</IconButton>
</Tooltip>
)}
{row.can_activate && (
@@ -660,7 +676,7 @@ function AdminInvites() {
{t("common.loadError")}
</StateMessage>
) : invites.isLoading && !invites.data ? (
<LoadingState />
<LoadingState shape="list" />
) : pending.length === 0 ? (
<p className="text-sm text-muted">{t("settings.invites.noPending")}</p>
) : (
@@ -685,12 +701,12 @@ function AdminInvites() {
}}
className="flex items-center gap-2 mt-3"
>
<input
<Input
className="flex-1"
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder={t("settings.invites.addPlaceholder")}
className="flex-1 min-w-0 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<Button
type="submit"
@@ -729,18 +745,15 @@ function AdminInvites() {
: t("settings.invites.inviteHint")
}
>
<FocusSafeButton
<IconButton
noTitle
onClick={() => onInvite(i)}
disabled={invite.isPending}
className={`shrink-0 p-1 rounded-md disabled:opacity-50 transition ${
i.invited_at
? "text-muted/50 hover:text-fg"
: "text-muted hover:text-accent"
}`}
aria-label={t("settings.invites.inviteLabel", { email: i.email })}
label={t("settings.invites.inviteLabel", { email: i.email })}
>
<Mails className="w-3.5 h-3.5" />
</FocusSafeButton>
{/* Already invited: the glyph fades, the action stays (a resend). */}
<Mails className={i.invited_at ? "opacity-50" : ""} />
</IconButton>
</Tooltip>
)}
{/* An entry with an account behind it stays read-only: deleting it would revoke that
@@ -748,26 +761,24 @@ function AdminInvites() {
{i.has_account ? (
// `disabled` would drop it from the tab order AND suppress the title, so the
// reason reached nobody — same call as the Playlists drag handle.
<button
type="button"
<IconButton
aria-disabled
onClick={(e) => e.preventDefault()}
title={t("settings.invites.deleteHasAccount")}
aria-label={t("settings.invites.deleteHasAccount")}
className="shrink-0 p-1 text-muted/50 cursor-not-allowed"
label={t("settings.invites.deleteHasAccount")}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
<Trash2 />
</IconButton>
) : (
<Tooltip hint={t("settings.invites.deleteHint")}>
<FocusSafeButton
<IconButton
noTitle
variant="danger"
onClick={() => onDelete(i)}
disabled={remove.isPending}
className="shrink-0 p-1 rounded-md text-muted hover:text-danger disabled:opacity-50 transition"
aria-label={t("settings.invites.deleteLabel", { email: i.email })}
label={t("settings.invites.deleteLabel", { email: i.email })}
>
<Trash2 className="w-3.5 h-3.5" />
</FocusSafeButton>
<Trash2 />
</IconButton>
</Tooltip>
)}
</div>
@@ -831,7 +842,7 @@ function AdminDemo() {
{rows.length > 0 && (
<div className="space-y-1.5 mb-3">
{rows.map((r) => (
<div key={r.id} className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
<div key={r.id} className={`${SURFACE.card} flex items-center gap-2 p-2.5`}>
<div className="min-w-0 flex-1">
<div className="text-sm truncate">{r.email}</div>
{r.added_by && (
@@ -841,15 +852,16 @@ function AdminDemo() {
)}
</div>
<Tooltip hint={t("settings.demo.removeHint")}>
<Button
<IconButton
noTitle
label={t("settings.demo.remove")}
size="sm"
variant="outline-danger"
size="icon-sm"
className="shrink-0"
onClick={() => onRemove(r)}
disabled={remove.isPending}
aria-label={t("settings.demo.remove")}
icon={<Trash2 className="w-4 h-4" />}
/>
>
<Trash2 className="w-4 h-4" />
</IconButton>
</Tooltip>
</div>
))}
@@ -863,12 +875,12 @@ function AdminDemo() {
}}
className="flex items-center gap-2"
>
<input
<Input
className="flex-1"
type="email"
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder={t("settings.demo.addPlaceholder")}
className="flex-1 min-w-0 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<Button variant="glass" size="md" className="shrink-0" type="submit">
<UserPlus className="w-4 h-4" /> {t("settings.demo.add")}
@@ -877,7 +889,11 @@ function AdminDemo() {
<div className="mt-4 pt-3 border-t border-border">
<Tooltip hint={t("settings.demo.resetHint")}>
<FocusSafeButton
<Button
variant="glass"
size="sm"
icon={<RotateCcw />}
loading={reset.isPending}
onClick={async () => {
if (
await confirm({
@@ -889,12 +905,9 @@ function AdminDemo() {
)
reset.mutate();
}}
disabled={reset.isPending}
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<RotateCcw className={`w-4 h-4 ${reset.isPending ? "animate-spin" : ""}`} />
{t("settings.demo.reset")}
</FocusSafeButton>
</Button>
</Tooltip>
</div>
</Section>
@@ -916,7 +929,7 @@ function InviteRow({
}) {
const { t } = useTranslation();
return (
<div className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
<div className={`${SURFACE.card} flex items-center gap-2 p-2.5`}>
<div className="min-w-0 flex-1">
<div className="text-sm truncate">{inv.email}</div>
{inv.requested_at && (
@@ -928,26 +941,28 @@ function InviteRow({
)}
</div>
<Tooltip hint={t("settings.invites.approveHint")}>
<Button
<IconButton
noTitle
label={t("settings.invites.approve")}
size="sm"
variant="outline-accent"
size="icon-sm"
className="shrink-0"
onClick={onApprove}
disabled={busy}
aria-label={t("settings.invites.approve")}
icon={<Check className="w-4 h-4" />}
/>
>
<Check className="w-4 h-4" />
</IconButton>
</Tooltip>
<Tooltip hint={t("settings.invites.denyHint")}>
<Button
<IconButton
noTitle
label={t("settings.invites.deny")}
size="sm"
variant="outline-danger"
size="icon-sm"
className="shrink-0"
onClick={onDeny}
disabled={busy}
aria-label={t("settings.invites.deny")}
icon={<X className="w-4 h-4" />}
/>
>
<X className="w-4 h-4" />
</IconButton>
</Tooltip>
{/* Deny records a decision and keeps the row; delete forgets the address. Once an account
exists for it the row is that account's approval gate, so it is managed from Users & roles
@@ -955,27 +970,26 @@ function InviteRow({
silently missing a button their other rows have. */}
{inv.has_account ? (
// aria-disabled, not `disabled`: the reason has to stay reachable by keyboard.
<button
type="button"
<IconButton
variant="outline"
aria-disabled
onClick={(e) => e.preventDefault()}
title={t("settings.invites.deleteHasAccount")}
aria-label={t("settings.invites.deleteHasAccount")}
className="shrink-0 p-1.5 rounded-lg border border-border/50 text-muted/50 cursor-not-allowed"
label={t("settings.invites.deleteHasAccount")}
>
<Trash2 className="w-4 h-4" />
</button>
<Trash2 />
</IconButton>
) : (
<Tooltip hint={t("settings.invites.deleteHint")}>
<Button
<IconButton
noTitle
label={t("settings.invites.deleteLabel", { email: inv.email })}
size="sm"
variant="outline-danger"
size="icon-sm"
className="shrink-0"
onClick={onDelete}
disabled={busy}
aria-label={t("settings.invites.deleteLabel", { email: inv.email })}
icon={<Trash2 className="w-4 h-4" />}
/>
>
<Trash2 className="w-4 h-4" />
</IconButton>
</Tooltip>
)}
</div>
+3 -1
View File
@@ -62,7 +62,7 @@ export default function AuditLog() {
{t("common.loadError")}
</StateMessage>
) : q.isLoading ? (
<LoadingState />
<LoadingState shape="table" />
) : rows.length === 0 ? (
<p className="text-sm text-muted">{t("audit.empty")}</p>
) : (
@@ -73,6 +73,8 @@ export default function AuditLog() {
rowKey={(r) => String(r.id)}
persistKey={LS.auditTable}
controlsPosition="top"
controlsInBand
controlsBandClassName="px-4 max-w-5xl mx-auto"
controlsLeading={
<Button
variant="outline-danger"
+2 -2
View File
@@ -30,7 +30,7 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
* Suspense fallback after it and the first page's own loading state keep one clock (see LoadingState). */
export function BootScreen() {
return (
<div className="min-h-screen grid place-items-center">
<div className="min-h-dvh grid place-items-center">
<LoadingState since={0} delayMs={400} />
</div>
);
@@ -121,7 +121,7 @@ export default function BootGate({ children }: { children: ReactNode }) {
: null;
if (failed)
return (
<div className="min-h-screen grid place-items-center">
<div className="min-h-dvh grid place-items-center">
<StateMessage tone="error" onRetry={() => failed.refetch()}>
{t("common.somethingWrong")}
</StateMessage>
+4 -7
View File
@@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
import { ExternalLink } from "lucide-react";
import type { ChannelDetail } from "../lib/api";
import { linkify } from "../lib/linkify";
import { Badge } from "./ui/Badge";
// --- About-tab metadata helpers (shared by the channel page and the manager's About overlay) ---
function displayName(code: string, lang: string, type: "region" | "language"): string {
@@ -50,7 +51,7 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
return (
<>
{ch.description ? (
<p className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">
<p className="measure text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">
{linkify(ch.description)}
</p>
) : (
@@ -101,9 +102,7 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
<div className="flex flex-wrap gap-1.5">
{/* Future search epic: these topic chips become clickable filters. */}
{topics.map((tp) => (
<span key={tp} className="glass-card px-2 py-0.5 rounded-full text-xs">
{tp}
</span>
<Badge key={tp}>{tp}</Badge>
))}
</div>
</div>
@@ -113,9 +112,7 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
<span className="text-muted w-24 shrink-0 pt-0.5">{t("channel.keywords")}</span>
<div className="flex flex-wrap gap-1.5">
{keywords.map((kw, i) => (
<span key={i} className="glass-card px-2 py-0.5 rounded-full text-xs">
{kw}
</span>
<Badge key={i}>{kw}</Badge>
))}
</div>
</div>
+32 -33
View File
@@ -2,6 +2,7 @@ import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { invalidateSubscription } from "../lib/subscriptionCache";
import { UserPlus } from "lucide-react";
import { api, type DiscoveredChannel } from "../lib/api";
import { accountKey, LS } from "../lib/storage";
@@ -23,7 +24,7 @@ import Pager from "./ui/Pager";
import { PageToolbar } from "./ui/PageShell";
import { useConfirm } from "./confirmContext";
import { useChannelLayout } from "./channelsContext";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { Button } from "./ui/Button";
// The Channel manager's "Discovery" tab: channels that turn up in the user's playlists but
// that they don't subscribe to. Subscribing here only creates the subscription + enriches
@@ -58,9 +59,7 @@ export default function ChannelDiscovery({
onSuccess: (_data, c) => {
// The channel moves from "discovery" to "subscribed", and its videos will start
// arriving — refresh both lists plus the per-user status.
qc.invalidateQueries({ queryKey: qk.discoveredChannels() });
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.myStatus() });
invalidateSubscription(qc, c.id);
const name = c.title ?? c.id;
// Name the channel and ship a typed payload so the inbox can offer "open in the
// Channel manager" / "open on YouTube" links — kept after reload, when live
@@ -93,8 +92,8 @@ export default function ChannelDiscovery({
);
const columns: Column<DiscoveredChannel>[] = [
// Actions first, mirroring the Subscriptions table (where it sits right after Prio) so the two
// tabs' action clusters line up in the same place.
// Subscribe first: it is this table's primary action, the one thing a discovery row is for (the
// Subscriptions table has no such button — its row ends in the channel's "…", E28 C1).
{
key: "actions",
header: t("channels.cols.actions"),
@@ -104,20 +103,21 @@ export default function ChannelDiscovery({
cardLabel: false,
render: (c) => (
<Tooltip
always={!canWrite}
hint={
canWrite ? t("channels.discovery.subscribeHint") : t("channels.discovery.needWriteHint")
}
>
<FocusSafeButton
// Busy blocks the click, not the focus (FocusSafeButton).
<Button
variant="glass"
size="xs"
icon={<UserPlus />}
onClick={() => onSubscribe(c)}
disabled={!canWrite}
busy={subscribe.isPending}
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-50 aria-disabled:opacity-50 transition"
aria-disabled={!canWrite}
loading={subscribe.isPending}
>
<UserPlus className="w-3.5 h-3.5" />
{t("channels.discovery.subscribe")}
</FocusSafeButton>
</Button>
</Tooltip>
),
},
@@ -201,34 +201,33 @@ export default function ChannelDiscovery({
return (
<>
{/* Intro + the table controls are fixed chrome; only the table rows scroll (sticky header). */}
{/* The table controls are the page's band (B4); only the rows scroll (sticky header). The intro
scrolls with them: in the band it held two lines of a short window for text read once. */}
<PageToolbar>
<div className="px-4 pt-2 max-w-384 mx-auto flex items-start justify-between gap-4">
<p className="text-xs text-muted leading-relaxed">{t("channels.discovery.intro")}</p>
{/* Cards has no column headers, so a dropdown drives the sort + a pager (table uses its own). */}
{layout === "cards" && (
<div className="flex items-center gap-3 shrink-0">
<Pager {...pagerProps} />
<ColumnSortControl
columns={columns}
sort={sort}
onChange={setSort}
label={t("channels.sort.label")}
defaultLabel={t("channels.sort.default")}
ascLabel={t("channels.sort.asc")}
descLabel={t("channels.sort.desc")}
/>
</div>
)}
</div>
{/* Cards has no column headers, so a dropdown drives the sort + a pager (table uses its own). */}
{layout === "cards" && (
<div className="px-4 pt-2 max-w-384 mx-auto flex items-center justify-end gap-3">
<Pager {...pagerProps} />
<ColumnSortControl
columns={columns}
sort={sort}
onChange={setSort}
label={t("channels.sort.label")}
defaultLabel={t("channels.sort.default")}
ascLabel={t("channels.sort.asc")}
descLabel={t("channels.sort.desc")}
/>
</div>
)}
</PageToolbar>
<div className="px-4 pb-4 max-w-384 mx-auto">
<p className="text-xs text-muted leading-relaxed pt-2">{t("channels.discovery.intro")}</p>
{query.isError && !query.data ? (
<StateMessage tone="error" onRetry={() => query.refetch()}>
{t("common.loadError")}
</StateMessage>
) : query.isLoading ? (
<LoadingState label={t("channels.discovery.loading")} />
<LoadingState shape="list" label={t("channels.discovery.loading")} />
) : layout === "table" ? (
<DataTable
rows={rows}
@@ -85,3 +85,20 @@ describe("ChannelLayoutGrid · keyboard", () => {
expect(opened).toEqual(["UCb"]);
});
});
describe('ChannelLayoutGrid · the card\'s "…"', () => {
it("docks it in the heading row beside the name, not in the meta line", () => {
const columns: Column<Ch>[] = [
{ key: "channel", header: "Channel", cardPrimary: true, render: (c) => <b>{c.title}</b> },
{ key: "stored", header: "Stored", render: () => <i>12</i> },
{ key: "actions", header: "", render: (c) => <u data-testid={`more-${c.id}`}>…</u> },
];
const { getByTestId, getByText } = render(
<ChannelLayoutGrid rows={rows} columns={columns} rowKey={(c) => c.id} moreKey="actions" />,
);
const heading = getByText("Alpha").closest("div")!.parentElement!;
expect(heading.contains(getByTestId("more-UCa"))).toBe(true);
// Exactly once: a slotted column is not also painted in the meta line.
expect(document.querySelectorAll('[data-testid="more-UCa"]')).toHaveLength(1);
});
});
@@ -4,6 +4,7 @@ import { type Column } from "./ui/DataTable";
import VirtualGrid from "./ui/VirtualGrid";
import { CHANNEL_LAYOUT_SPEC } from "../lib/channelLayout";
import { navItemAttrs } from "../lib/listNav";
import { SURFACE } from "./ui/surfaces";
// The channel manager's `cards` layout (E4 S3), built by reusing the SAME `Column<T>` render closures
// the DataTable draws — so the subscribed and discovery tables (which have different columns) both get
@@ -27,6 +28,9 @@ interface ChannelLayoutGridProps<T> {
aboutKey?: string;
/** Column key rendered as the action cluster, pinned to the card's bottom edge. */
actionsKey?: string;
/** Column key docked at the top-right corner after the lead: the card's "…" (E28 C1), which
* belongs beside the name it acts on, not under the card's last line. */
moreKey?: string;
rowClassName?: (row: T) => string;
/** Extra DOM props per card — same escape hatch, same single consumer, as `DataTable.rowProps`, so
* a channel answers the right-click in the card layout exactly as it does in the table. */
@@ -41,6 +45,7 @@ export default function ChannelLayoutGrid<T>({
leadKey,
aboutKey,
actionsKey,
moreKey,
rowClassName,
rowProps,
emptyText,
@@ -48,11 +53,12 @@ export default function ChannelLayoutGrid<T>({
const { t } = useTranslation();
const spec = CHANNEL_LAYOUT_SPEC.cards;
const slotted = new Set([leadKey, aboutKey, actionsKey].filter(Boolean) as string[]);
const slotted = new Set([leadKey, aboutKey, actionsKey, moreKey].filter(Boolean) as string[]);
const primary = columns.filter((c) => c.cardPrimary);
const lead = leadKey ? columns.find((c) => c.key === leadKey) : undefined;
const about = aboutKey ? columns.find((c) => c.key === aboutKey) : undefined;
const actions = actionsKey ? columns.find((c) => c.key === actionsKey) : undefined;
const more = moreKey ? columns.find((c) => c.key === moreKey) : undefined;
// Everything else flows in the meta line: not the heading, not a slotted column.
const meta = columns.filter((c) => !c.cardPrimary && !slotted.has(c.key));
@@ -60,7 +66,7 @@ export default function ChannelLayoutGrid<T>({
<div
{...rowProps?.(row)}
{...navItemAttrs(index)}
className={`glass-card rounded-xl p-3 h-full flex flex-col gap-2 ${rowClassName?.(row) ?? ""}`}
className={`${SURFACE.card} p-3 h-full flex flex-col gap-2 ${rowClassName?.(row) ?? ""}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1 font-medium">
@@ -69,6 +75,7 @@ export default function ChannelLayoutGrid<T>({
))}
</div>
{lead && <div className="shrink-0">{lead.render(row)}</div>}
{more && <div className="shrink-0 -my-1">{more.render(row)}</div>}
</div>
{about && <div className="min-w-0 text-sm">{about.render(row)}</div>}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-sm min-w-0">
+15 -15
View File
@@ -1,8 +1,9 @@
import { useTranslation } from "react-i18next";
import { ExternalLink } from "lucide-react";
import Avatar from "./ui/Avatar";
import Tooltip from "./ui/Tooltip";
import { channelYouTubeUrl } from "../lib/format";
import { Pressable } from "./ui/Pressable";
import { IconLink } from "./ui/LinkButton";
// Avatar + channel name + "open on YouTube" link, shared by the Channel manager and the
// discovery list (and anywhere else that lists a channel). When `onView` is given the name
@@ -28,7 +29,8 @@ export default function ChannelLink({
<div className="flex items-center gap-2 min-w-0">
<Avatar src={thumbnailUrl} fallback={title ?? ""} className="w-8 h-8 rounded-full shrink-0" />
{onView ? (
<button
<Pressable
selectable
onClick={onView}
// The channel's "open" element: in a keyboard-walkable list (lib/listNav) Enter on the row or
// card clicks this.
@@ -46,26 +48,24 @@ export default function ChannelLink({
}}
// title = the full name, so a truncated fluid-column name is still readable on hover.
title={name}
className="text-sm font-medium truncate hover:text-accent text-left min-w-0"
className="min-h-6 min-w-0 truncate text-sm font-medium hover:text-accent pointer-coarse:min-h-(--touch-target)"
>
{name}
</button>
</Pressable>
) : (
<span title={name} className="text-sm font-medium truncate min-w-0">
{name}
</span>
)}
<Tooltip hint={t("channels.row.openOnYouTube")}>
<a
href={ytUrl}
target="_blank"
rel="noopener noreferrer"
className="text-muted hover:text-accent shrink-0"
aria-label={t("channels.row.openOnYouTube")}
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
</Tooltip>
<IconLink
href={ytUrl}
target="_blank"
rel="noopener noreferrer"
label={t("channels.row.openOnYouTube")}
className="shrink-0"
>
<ExternalLink />
</IconLink>
</div>
);
}
@@ -0,0 +1,89 @@
// @vitest-environment jsdom
//
// The channel banner is served from Google's image CDN (yt3.googleusercontent.com), which refuses a
// request that carries a Referer from our origin — every time, not only under load (measured
// 2026-09-26: 6/6 refused with the page's referrer or its bare origin, 8/8 served without one). So the
// banner <img> must send none, as ui/Avatar does for the same CDN; with a referrer the banner never
// showed and the page fell back to the blank bar.
import type { ReactNode } from "react";
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, cleanup, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
vi.mock("./navigationContext", () => ({
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
}));
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
vi.mock("./feedFiltersContext", () => ({
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
}));
vi.mock("./Feed", () => ({ default: () => null }));
vi.mock("./ChannelAbout", () => ({ default: () => null }));
// The page's chrome portals into the shell's fixed toolbar band, which this test has no shell for.
vi.mock("./ui/PageShell", async (importOriginal) => ({
...(await importOriginal<typeof import("./ui/PageShell")>()),
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
}));
import ChannelPage from "./ChannelPage";
import { api, type ChannelDetail, type Me } from "../lib/api";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
const CH = "UCqoAEDirJPjEUFcF2FklnBA";
describe("ChannelPage — the banner", () => {
it("is requested without a referrer, which Google's image CDN refuses", async () => {
vi.spyOn(api, "channelDetail").mockResolvedValue({
id: CH,
title: "StarTalk",
banner_url: "https://yt3.googleusercontent.com/banner",
subscribed: true,
blocked: false,
hidden: false,
deep_requested: false,
backfill_done: true,
stored_videos: 10,
} as unknown as ChannelDetail);
vi.spyOn(api, "tags").mockResolvedValue([] as never);
const { container } = render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<ChannelPage
channelId={CH}
initialName="StarTalk"
me={{ id: 1, can_write: true } as unknown as Me}
view="cards"
setView={() => {}}
onShowInFeed={() => {}}
/>
</QueryClientProvider>,
);
const banner = await waitFor(() => {
const img = container.querySelector<HTMLImageElement>('img[src^="https://yt3."]');
expect(img).not.toBeNull();
return img!;
});
expect(banner.getAttribute("referrerpolicy")).toBe("no-referrer");
});
});
@@ -6,7 +6,7 @@
// ui/Button in E11 S2), so a busy toggle blocks the click with aria-disabled instead — focusable, and
// still pressed only once.
import type { ReactNode } from "react";
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { describe, it, expect, vi, beforeAll, afterEach, onTestFinished } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -97,3 +97,53 @@ describe("ChannelPage — a busy toggle keeps the keyboard", () => {
},
);
});
// The banner is retried on error (googleusercontent drops a burst); while an attempt fails and the
// next waits, a visible <img> shows the browser's broken-image glyph (seen live, E28). It stays
// invisible until a source has actually loaded.
describe("ChannelPage — the banner", () => {
it("is invisible until it loads, and again while a failed attempt is retried", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
onTestFinished(() => {
vi.useRealTimers();
});
vi.spyOn(api, "channelDetail").mockResolvedValue({
id: CH,
title: "LEMMiNO",
subscribed: true,
blocked: false,
hidden: false,
deep_requested: false,
backfill_done: true,
stored_videos: 10,
banner_url: "https://yt3.example/banner",
} as unknown as ChannelDetail);
vi.spyOn(api, "tags").mockResolvedValue([] as never);
const { container } = render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<ChannelPage
channelId={CH}
initialName="LEMMiNO"
me={{ id: 1, can_write: true } as unknown as Me}
view="cards"
setView={() => {}}
onShowInFeed={() => {}}
/>
</QueryClientProvider>,
);
const banner = () =>
container.querySelector<HTMLImageElement>('img[src^="https://yt3.example/banner"]')!;
await waitFor(() => expect(banner()).toBeTruthy());
expect(banner().classList.contains("opacity-0")).toBe(true);
fireEvent.error(banner());
await act(async () => {
await vi.advanceTimersByTimeAsync(600);
});
expect(banner().getAttribute("src")).toContain("?r=1");
expect(banner().classList.contains("opacity-0")).toBe(true);
fireEvent.load(banner());
expect(banner().classList.contains("opacity-0")).toBe(false);
});
});
+101 -108
View File
@@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useLiveQuery } from "../lib/useLiveQuery";
import { channelDetailPollMs, useBackfillFeedRefresh } from "../lib/channelDetailPoll";
import { qk } from "../lib/queryKeys";
import { invalidateSubscription } from "../lib/subscriptionCache";
import { useNavigationActions } from "./navigationContext";
import { PriorityStepper } from "./ui/PriorityStepper";
import { useChannelPriority } from "../lib/useChannelPriority";
@@ -22,6 +23,7 @@ import {
} from "lucide-react";
import Avatar from "./ui/Avatar";
import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
import Feed from "./Feed";
import ChannelAboutContent from "./ChannelAbout";
import { PageToolbar } from "./ui/PageShell";
@@ -33,11 +35,13 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { notifyActionError } from "../lib/actionErrors";
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
import type { FeedView } from "../lib/feedView";
import { channelYouTubeUrl, formatViews } from "../lib/format";
import { channelYouTubeUrl, formatDate, formatViews } from "../lib/format";
import { ChannelTagsControl } from "./ChannelTags";
import { useChannelTagActions } from "../lib/useChannelTagActions";
import { Button } from "./ui/Button";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { IconButton } from "./ui/IconButton";
import { IconLink } from "./ui/LinkButton";
import { PageHeading } from "./ui/Headings";
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
@@ -74,18 +78,22 @@ export default function ChannelPage({
// inherit it instead of forcing Live/Upcoming on. The channel toolbar can still toggle these.
const globalFilters = useFeedFilters();
const [tab, setTab] = useState<"videos" | "about">("videos");
const panel = useTabPanel();
// The whole channel chrome (banner, identity, tabs, toolbar) is fixed: it portals into the shell's
// fixed band so only the video grid scrolls. This node sits at the bottom of that chrome and is
// where the channelScoped Feed portals ITS toolbar (via the overridden PageToolbarSlot below), so
// the toolbar lands under the tabs inside the fixed band rather than in the scroller.
const [chromeSlot, setChromeSlot] = useState<HTMLDivElement | null>(null);
// The banner URL is valid, but googleusercontent intermittently throttles it when the channel
// page fires the banner + avatar + ~16 thumbnails in one burst — the request is dropped and the
// browser then NEGATIVE-CACHES the miss, so the banner stays broken even though a retry would
// succeed. So on error we retry a few times with a cache-buster (forcing a fresh request), and
// only fall back to the blank bar if it keeps failing (a genuinely dead URL — rare).
// The banner comes from Google's image CDN, which refuses any request carrying a Referer from our
// origin — every time, not only in a burst (measured 2026-09-26) — so the <img> sends none
// (`referrerPolicy`, as ui/Avatar). A load can still drop, and the browser NEGATIVE-CACHES the miss,
// so on error we retry a few times with a cache-buster (forcing a fresh request), and only fall
// back to the blank bar if it keeps failing (a genuinely dead URL — rare).
const MAX_BANNER_RETRIES = 3;
const [bannerAttempt, setBannerAttempt] = useState(0);
// The banner source that has actually loaded. Until then the <img> is invisible: an attempt that
// fails shows the browser's broken-image glyph for as long as the retry waits (seen live, E28).
const [bannerLoaded, setBannerLoaded] = useState<string | null>(null);
// A LIVE query: while THIS user's full-history backfill is running, poll so the stored count (and
// the N/S/L breakdown) climb on their own — the immediate backfill fills it in seconds, and the
@@ -174,12 +182,7 @@ export default function ChannelPage({
const subscribe = useMutation({
mutationFn: () => api.subscribeChannel(channelId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.channels() });
// Match ChannelDiscovery: the channel leaves discovery and the manager's stats move.
qc.invalidateQueries({ queryKey: qk.myStatus() });
qc.invalidateQueries({ queryKey: qk.discoveredChannels() });
invalidateSubscription(qc, channelId);
notify({ level: "info", message: t("channel.subscribed", { name: ch?.title ?? "" }) });
},
// A 403 (missing write scope) otherwise fails silently on the channel page (no wizard handle
@@ -188,11 +191,7 @@ export default function ChannelPage({
});
const unsubscribe = useMutation({
mutationFn: () => api.unsubscribeChannel(channelId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.channels() });
},
onSuccess: () => invalidateSubscription(qc, channelId),
onError: (err) => notifyYouTubeActionError(err, t("channels.notify.unsubscribeFailed")),
});
// "Get full history": the catalog only holds the recent-backfill/RSS subset of a channel's
@@ -280,12 +279,7 @@ export default function ChannelPage({
const name = ch?.title ?? initialName ?? channelId;
const ytUrl = channelYouTubeUrl(channelId, ch?.handle);
const joined = ch?.published_at
? new Date(ch.published_at).toLocaleDateString(i18n.language, {
year: "numeric",
month: "short",
})
: null;
const joined = ch?.published_at ? formatDate(ch.published_at, i18n.language) : null;
// Handle + stats on one compact meta line under the name (saves the separate stats row).
const metaParts = [
ch?.handle ? `@${ch.handle.replace(/^@/, "")}` : null,
@@ -317,12 +311,15 @@ export default function ChannelPage({
// The channel page owns no scroller of its own (PageShell provides the one app scroller). Its
// whole chrome is fixed — wrapped in <PageToolbar> it portals into the shell's fixed band, so the
// banner/identity/tabs/toolbar stay put and only the video grid scrolls.
const bannerSrc = ch?.banner_url
? `${ch.banner_url}=w1707${bannerAttempt ? `?r=${bannerAttempt}` : ""}`
: null;
return (
<>
<PageToolbar>
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
<div className="relative px-4 sm:px-6 pt-3">
{ch?.banner_url && bannerAttempt <= MAX_BANNER_RETRIES ? (
{bannerSrc && bannerAttempt <= MAX_BANNER_RETRIES ? (
// Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a
// low default size, so request a crisp wide version (=w1707) and object-cover the
// desktop "safe area" — the centre 2560×423 (~6:1) band.
@@ -334,9 +331,13 @@ export default function ChannelPage({
// key forces a fresh <img> per attempt; the ?r= cache-buster on a retry bypasses the
// browser's negative cache (googleusercontent accepts an extra query param).
key={bannerAttempt}
src={`${ch.banner_url}=w1707${bannerAttempt ? `?r=${bannerAttempt}` : ""}`}
src={bannerSrc}
referrerPolicy="no-referrer"
alt=""
className="w-full h-full object-cover object-center"
className={`w-full h-full object-cover object-center ${
bannerLoaded === bannerSrc ? "" : "opacity-0"
}`}
onLoad={() => setBannerLoaded(bannerSrc)}
onError={() => {
if (bannerAttempt <= MAX_BANNER_RETRIES) {
window.setTimeout(
@@ -356,14 +357,15 @@ export default function ChannelPage({
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
/>
)}
<button
<Button
variant="media"
data-testid="channel-back"
icon={<ArrowLeft />}
onClick={closeChannel}
className="absolute top-5 left-7 sm:left-9 z-base inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-black/45 text-white hover:bg-black/65 backdrop-blur-xs"
className="absolute top-5 left-7 sm:left-9 z-base backdrop-blur-xs"
>
<ArrowLeft className="w-4 h-4" />
{t("channel.back")}
</button>
</Button>
</div>
<div className="px-4 sm:px-6">
@@ -377,9 +379,9 @@ export default function ChannelPage({
/>
<div className="flex-1 min-w-0 pb-1">
<div className="flex items-center gap-2 flex-wrap">
<h1 data-testid="channel-title" className="text-xl font-semibold truncate">
<PageHeading data-testid="channel-title" className="text-xl font-semibold truncate">
{name}
</h1>
</PageHeading>
{ch?.blocked ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-danger/15 text-danger">
{t("channel.blockedBadge")}
@@ -416,6 +418,7 @@ export default function ChannelPage({
within a few pixels of each other instead of across the page. */}
<div className="flex items-center gap-2 mt-3 border-b border-border pb-2">
<Tabs
idBase={panel.idBase}
className="flex gap-1"
active={tab}
onChange={(id) => setTab(id as "videos" | "about")}
@@ -425,25 +428,25 @@ export default function ChannelPage({
]}
/>
<div className="ml-2 flex items-center gap-2">
<a
<IconLink
href={ytUrl}
target="_blank"
rel="noreferrer"
title={t("channels.row.openOnYouTube")}
aria-label={t("channels.row.openOnYouTube")}
className="inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg border border-border"
label={t("channels.row.openOnYouTube")}
size="md"
variant="outline"
>
<ExternalLink className="w-4 h-4" />
</a>
<ExternalLink />
</IconLink>
{ch?.subscribed && (
<Button
<IconButton
label={t("channel.showInFeed")}
size="md"
variant="outline"
size="icon"
onClick={onShowInFeed}
title={t("channel.showInFeed")}
aria-label={t("channel.showInFeed")}
icon={<SlidersHorizontal className="w-4 h-4" />}
/>
>
<SlidersHorizontal className="w-4 h-4" />
</IconButton>
)}
{ch?.subscribed && !me.is_demo && (
<div className="inline-flex items-center gap-1.5 h-9 px-2.5 rounded-lg border border-border">
@@ -488,39 +491,32 @@ export default function ChannelPage({
</Button>
)}
{!me.is_demo && (
<FocusSafeButton
<IconButton
// Busy blocks the click, not the focus (FocusSafeButton). Nor does a busy toggle
// light up on hover, as if a click would do something.
size="md"
variant={ch?.blocked ? "danger-soft" : "outline-danger"}
onClick={() => block.mutate()}
busy={block.isPending}
title={ch?.blocked ? t("channel.unblock") : t("channel.block")}
aria-label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border aria-disabled:opacity-50 transition ${
ch?.blocked
? "border-danger text-danger"
: "border-border text-muted not-aria-disabled:hover:text-danger not-aria-disabled:hover:border-danger"
}`}
loading={block.isPending}
label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
>
<Ban className="w-4 h-4" />
</FocusSafeButton>
<Ban />
</IconButton>
)}
{/* Hide-from-feed toggle — subscription-scoped, so offered only on a followed channel
(mirrors the manager's Eye/EyeOff). Keeps the subscription; only drops its videos
from the feed. EyeOff = currently hidden (click to unhide). */}
{ch?.subscribed && !me.is_demo && (
<FocusSafeButton
<IconButton
size="md"
variant={ch.hidden ? "outline-accent" : "outline"}
onClick={() => toggleHidden.mutate()}
busy={toggleHidden.isPending}
loading={toggleHidden.isPending}
label={ch.hidden ? t("channel.unhide") : t("channel.hide")}
title={ch.hidden ? t("channel.hiddenHint") : t("channel.hideHint")}
aria-label={ch.hidden ? t("channel.unhide") : t("channel.hide")}
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border aria-disabled:opacity-50 transition ${
ch.hidden
? "border-accent text-accent"
: "border-border text-muted not-aria-disabled:hover:text-fg not-aria-disabled:hover:border-fg/40"
}`}
>
{ch.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</FocusSafeButton>
{ch.hidden ? <EyeOff /> : <Eye />}
</IconButton>
)}
{!me.is_demo &&
!ch?.blocked &&
@@ -568,48 +564,45 @@ export default function ChannelPage({
{/* Tab content — the feed's toolbar portals up into the fixed chrome via this slot override;
only the grid (and About) render here, in the scroller. */}
<PageToolbarSlot.Provider value={chromeSlot}>
{tab === "videos" ? (
<>
<Feed
filters={filters}
setFilters={setFilters}
view={view}
setView={setView}
canRead={me.can_read}
isDemo={me.is_demo}
ytSearch={null}
onYtSearch={() => {}}
onExitYtSearch={() => {}}
channelScoped
/>
{!ch?.subscribed && nextToken && (
<div className="flex justify-center pb-6">
<button
// Busy blocks the click, not the focus (see Block above).
onClick={() => !exploring && runExplore(nextToken)}
aria-disabled={exploring || undefined}
aria-busy={exploring || undefined}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm border border-border not-aria-disabled:hover:bg-surface aria-disabled:opacity-50"
>
{exploring ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCw className="w-4 h-4" />
)}
{t("channel.loadMore")}
</button>
</div>
)}
</>
) : (
<div className="px-4 sm:px-6 py-4 max-w-3xl">
{ch ? (
<ChannelAboutContent ch={ch} />
) : (
<p className="text-sm text-muted">{t("channel.noDescription")}</p>
)}
</div>
)}
<div {...panel.panelProps(tab)}>
{tab === "videos" ? (
<>
<Feed
filters={filters}
setFilters={setFilters}
view={view}
setView={setView}
canRead={me.can_read}
isDemo={me.is_demo}
ytSearch={null}
onYtSearch={() => {}}
onExitYtSearch={() => {}}
channelScoped
/>
{!ch?.subscribed && nextToken && (
<div className="flex justify-center pb-6">
<Button
// Busy blocks the click, not the focus (see Block above).
variant="outline"
icon={<RefreshCw />}
loading={exploring}
onClick={() => runExplore(nextToken)}
>
{t("channel.loadMore")}
</Button>
</div>
)}
</>
) : (
<div className="px-4 sm:px-6 py-4 max-w-3xl">
{ch ? (
<ChannelAboutContent ch={ch} />
) : (
<p className="text-sm text-muted">{t("channel.noDescription")}</p>
)}
</div>
)}
</div>
</PageToolbarSlot.Provider>
</>
);
@@ -40,6 +40,8 @@ export default function ChannelPicker({
selected={filters.channelIds}
onChange={(channelIds) => setFilters({ ...filters, channelIds })}
loading={channelsQuery.isLoading}
error={channelsQuery.isError && !channelsQuery.data ? t("common.loadError") : undefined}
onRetry={() => void channelsQuery.refetch()}
emptyText={t("sidebar.noChannelMatch")}
selectedLabels={selectedLabels}
/>
@@ -14,6 +14,8 @@ import { type MyStatus } from "../lib/api";
import { formatEta, formatNumber } from "../lib/format";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Button } from "./ui/Button";
import { SURFACE } from "./ui/surfaces";
// The channel-manager catalog stats, collapsed from a crowded inline row into a compact "Stats" chip
// that reveals the numbers on hover (reuses the AnchoredPopover hover mode). One row per stat, each
@@ -77,19 +79,19 @@ export default function ChannelStatsPopover({ status }: { status: MyStatus }) {
return (
<>
<button
type="button"
<Button
variant="glass"
size="sm"
icon={<BarChart3 aria-hidden="true" />}
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-muted transition"
>
<BarChart3 className="h-4 w-4" aria-hidden="true" />
{t("channels.stats.label")}
</button>
</Button>
<PopoverSurface
popover={pop}
ariaLabel={t("channels.stats.label")}
className="glass-menu w-60 rounded-xl p-2 text-xs animate-[popIn_0.16s_ease]"
className={`${SURFACE.popover} w-60 p-2 text-xs animate-[popIn_0.16s_ease]`}
>
{rows.map((r) => (
<div
+56 -78
View File
@@ -1,5 +1,6 @@
import { useRef, useState } from "react";
import { Check, Plus, Sparkles, X } from "lucide-react";
import { Plus, Sparkles } from "lucide-react";
import { MenuCheck } from "./ui/MenuCheck";
import { useTranslation } from "react-i18next";
import { type Tag } from "../lib/api";
import { PopoverSurface, PopoverItem } from "./ui/AnchoredPopover";
@@ -7,7 +8,11 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { useScrollFade } from "../lib/useScrollFade";
import { fitChipsWithPill } from "../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { IconButton } from "./ui/IconButton";
import { Chip, DismissibleChip } from "./ui/Chip";
import { chipClass } from "./ui/chipStyles";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
// page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell:
@@ -25,20 +30,15 @@ interface TagChipProps {
title?: string;
}
// `block truncate` on the name, not the chip: the chip is inline-flex, where text-overflow cannot bite.
function TagChip({ tag, onClick, title }: TagChipProps) {
const cls =
"chip-solid shrink-0 text-2xs font-medium px-1.5 py-0.5 rounded-full max-w-36 truncate";
const name = <span className="block max-w-32 truncate">{tag.name}</span>;
return onClick ? (
<button
type="button"
onClick={onClick}
title={title}
className={`${cls} hover:opacity-80 transition`}
>
{tag.name}
</button>
<Chip variant="tag" size="xs" onClick={onClick} title={title}>
{name}
</Chip>
) : (
<span className={cls}>{tag.name}</span>
<span className={chipClass("tag", "xs")}>{name}</span>
);
}
@@ -63,33 +63,22 @@ function SuggestedChip({
const { t } = useTranslation();
const pct = Math.round(suggestion.confidence * 100);
return (
<span className="shrink-0 inline-flex items-center rounded-full border border-dashed border-accent/50 text-accent/90 text-2xs font-medium pl-1 pr-0.5 py-0.5 max-w-36">
<button
type="button"
onClick={onAccept}
title={t("channels.tags.suggestedHint", { pct })}
aria-label={t("channels.tags.acceptSuggestion", { name: suggestion.tag.name })}
className="inline-flex items-center gap-1 min-w-0 hover:text-accent transition"
>
<Sparkles className="w-2.5 h-2.5 shrink-0" />
<span className="truncate">{suggestion.tag.name}</span>
</button>
<button
type="button"
onClick={onDismiss}
title={t("channels.tags.dismissSuggestion")}
aria-label={t("channels.tags.dismissSuggestion")}
className="shrink-0 ml-0.5 rounded-full text-accent/60 hover:text-danger transition"
>
<X className="w-2.5 h-2.5" />
</button>
</span>
<DismissibleChip
variant="suggest"
size="xs"
className="max-w-36 font-medium"
icon={<Sparkles />}
onClick={onAccept}
bodyTitle={t("channels.tags.suggestedHint", { pct })}
bodyLabel={t("channels.tags.acceptSuggestion", { name: suggestion.tag.name })}
onDismiss={onDismiss}
dismissLabel={t("channels.tags.dismissSuggestion")}
>
{suggestion.tag.name}
</DismissibleChip>
);
}
const GHOST_PILL =
"shrink-0 text-3xs px-1.5 py-0.5 rounded-full border border-dashed border-accent/50 text-accent/90";
/** A single-line lane of suggested ghost chips that collapses its overflow into a "+N" pill (hover
* reveals the rest, still accept/dismiss-able), reusing the SAME fit machinery as the attached-chip
* lane. Rendered in the untagged-channel case only, so it owns the full tags-cell width — this is what
@@ -158,7 +147,7 @@ function SuggestedChipsLane({
<SuggestedChip suggestion={s} onAccept={() => {}} onDismiss={() => {}} />
</span>
))}
<span ref={pillRef} className={GHOST_PILL}>
<span ref={pillRef} className={chipClass("suggest", "xs")}>
+{suggestions.length}
</span>
</div>
@@ -173,22 +162,22 @@ function SuggestedChipsLane({
/>
))}
{hidden > 0 && (
<button
<Chip
variant="suggest"
size="xs"
ref={overflowPop.refs.setReference}
type="button"
aria-label={t("channels.tags.moreCount", { count: hidden })}
className={`${GHOST_PILL} hover:text-accent transition`}
{...overflowPop.getReferenceProps()}
>
+{hidden}
</button>
</Chip>
)}
</div>
{/* Hover: the overflowed suggestions, each still one-tap accept / × dismiss. */}
<PopoverSurface
popover={overflowPop}
className="glass-menu max-w-[18rem] rounded-xl p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]"
className={`${SURFACE.popover} max-w-[18rem] p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]`}
>
{suggestions.slice(visible).map((s) => (
<SuggestedChip
@@ -240,17 +229,11 @@ function TagEditorMenu({
checked={on}
onSelect={() => onToggle(tg.id)}
label={tg.name}
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition ${
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-lg outline-hidden transition ${
on ? "text-fg" : "text-muted"
}`}
>
<span
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
on ? "bg-accent border-accent text-accent-fg" : "border-border"
}`}
>
{on && <Check className="w-2.5 h-2.5" />}
</span>
<MenuCheck kind="many" on={on} />
<span className="truncate">{tg.name}</span>
</PopoverItem>
);
@@ -260,7 +243,9 @@ function TagEditorMenu({
)}
</div>
<div className="mt-1 pt-1.5 border-t border-border flex items-center gap-1">
<input
<Input
className="flex-1"
size="sm"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
@@ -276,18 +261,15 @@ function TagEditorMenu({
}}
maxLength={64}
placeholder={t("channels.tags.createPlaceholder")}
className="flex-1 min-w-0 bg-transparent text-xs px-2 py-1 rounded-md border border-border focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<FocusSafeButton
type="button"
<IconButton
variant="outline"
onClick={submit}
disabled={!draft.trim()}
title={t("channels.tags.create")}
aria-label={t("channels.tags.create")}
className="shrink-0 w-6 h-6 inline-flex items-center justify-center rounded-md border border-border text-muted hover:text-accent hover:border-accent disabled:opacity-40 transition"
label={t("channels.tags.create")}
>
<Plus className="w-3.5 h-3.5" />
</FocusSafeButton>
<Plus />
</IconButton>
</div>
</div>
);
@@ -381,8 +363,8 @@ export function ChannelTagsControl({
placement: "bottom",
});
const pillClass =
"shrink-0 text-3xs px-1.5 py-0.5 rounded-full bg-card text-muted border border-border";
// The "+N" is a filter-look chip (neutral): the tags themselves are the accent.
const pillClass = chipClass("filter", "xs");
return (
// When `bounded`, a FIXED width keeps the cell's footprint constant under `table-layout:auto`: a
@@ -396,21 +378,18 @@ export function ChannelTagsControl({
{/* The add trigger LEADS the row — a stable left anchor that never moves as chips are added
(they grow to its right), so its editor popover can't drift. Compact icon in the width-
bounded manager cell; a labelled "+ Tag" pill where there's free width (the detail header). */}
<button
<Chip
variant="ghost"
size="xs"
ref={editPop.refs.setReference}
type="button"
title={t("channels.row.editTags")}
aria-label={t("channels.row.editTags")}
className={
bounded
? "shrink-0 w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
: "shrink-0 inline-flex items-center gap-1 h-6 pl-1.5 pr-2 rounded-full border border-dashed border-border text-2xs font-medium text-muted hover:text-accent hover:border-accent transition"
}
icon={<Plus className="size-3" />}
className={`font-medium ${bounded ? "w-6 justify-center px-0" : ""}`}
{...editPop.getReferenceProps()}
>
<Plus className="w-3 h-3 shrink-0" />
{!bounded && <span className="whitespace-nowrap">{t("channels.tags.add")}</span>}
</button>
{!bounded && t("channels.tags.add")}
</Chip>
{/* Untagged + has suggestions → the ghost-chip lane (its own overflow "+N"); otherwise the
attached-chip lane. The two are mutually exclusive: suggestions are computed for untagged
@@ -459,15 +438,14 @@ export function ChannelTagsControl({
/>
))}
{hidden > 0 && (
<button
<Chip
size="xs"
ref={overflowPop.refs.setReference}
type="button"
aria-label={t("channels.tags.moreCount", { count: hidden })}
className={`${pillClass} hover:text-fg transition`}
{...overflowPop.getReferenceProps()}
>
+{hidden}
</button>
</Chip>
)}
</div>
</>
@@ -476,7 +454,7 @@ export function ChannelTagsControl({
{/* Hover: reveal every attached tag when some are collapsed into "+N". */}
<PopoverSurface
popover={overflowPop}
className="glass-menu max-w-[18rem] rounded-xl p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]"
className={`${SURFACE.popover} max-w-[18rem] p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]`}
>
{attached.map((tg) => (
<TagChip
@@ -491,7 +469,7 @@ export function ChannelTagsControl({
{/* Click: the shared editor (toggle existing + create inline). */}
<PopoverSurface
popover={editPop}
className="glass-menu w-56 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
className={`${SURFACE.menu} w-56 animate-[popIn_0.16s_ease]`}
>
<TagEditorMenu
userTags={userTags}
+301 -173
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
@@ -10,18 +10,24 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage";
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
import { notify } from "../lib/notifications";
import { Badge } from "./ui/Badge";
import { Button } from "./ui/Button";
import Tooltip from "./ui/Tooltip";
import DataTable from "./ui/DataTable";
import Tabs, { type TabDef } from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
import ChannelLayoutGrid from "./ChannelLayoutGrid";
import { ComfortPinMenu } from "./ComfortPinMenu";
import { pinChannel } from "./comfortPin";
import { pinChannel, useComfortPin } from "./comfortPin";
import { ContextMenu } from "./ui/ContextMenu";
import { MoreButton } from "./ui/MoreButton";
import { useChannelActions } from "./useChannelActions";
import { useContextMenu } from "./ui/useContextMenu";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { useChannelTagActions } from "../lib/useChannelTagActions";
import { useTagSuggestions } from "./useTagSuggestions";
import { useChannelPriority } from "../lib/useChannelPriority";
import { placeByPriority } from "../lib/channelOrder";
import { focusNavItem } from "../lib/listNav";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
import ColumnSortControl from "./ui/ColumnSortControl";
import FilterChipGroup from "./ui/FilterChipGroup";
@@ -31,12 +37,18 @@ import ChannelStatsPopover from "./ChannelStatsPopover";
import Pager from "./ui/Pager";
import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout";
import { sortRows } from "../lib/columnSort";
import { filterRows } from "../lib/tableColumns";
import { filterRows, isActive } from "../lib/tableColumns";
import { useFilterFold } from "../lib/useFilterFold";
import { FilterFoldButton } from "./ui/FilterFold";
import { HiddenFilterCue } from "./ui/HiddenFilterCue";
import { useCardPager } from "../lib/useCardPager";
import { PageToolbar } from "./ui/PageShell";
import { PageMenu } from "./ui/PageMenu";
import { menuGroups } from "./ui/menuGroups";
import { choiceSubmenu, sortSubmenu } from "./ui/pageMenuItems";
import { optionsFromColumns } from "../lib/sortOptions";
import ChannelDiscovery from "./ChannelDiscovery";
import TagManager from "./TagManager";
import { useConfirm } from "./confirmContext";
import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext";
import { useNavigationActions } from "./navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
@@ -45,6 +57,9 @@ import { useMe } from "../lib/useMe";
export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked";
// The tag filter has its own chip row above the list, so the hidden-filter cue does not name it again.
const SURFACED_FILTERS = ["tags"];
// The top sync chips are a controlled view over the subscribed table's `sync` column filter (shared
// via ChannelsProvider): each chip writes `filters.sync`, using the SAME vocab + predicate as the
// Sync column funnel (channelColumns.tsx) so the two can't drift. "" = all (isActive treats it as
@@ -62,8 +77,8 @@ const LAYOUT_ICON: Record<ChannelLayout, typeof Table> = {
export default function Channels() {
const me = useMe();
const panel = useTabPanel();
const canWrite = me.can_write;
const isAdmin = me.role === "admin";
// "Connect YouTube" onboarding lives in WizardProvider (a notify action / a mutation-error CTA).
const { openWizard: onOpenWizard } = useWizard();
// The manager's name filter, status chip, active tab, reset token, and focus-channel intent live
@@ -93,7 +108,6 @@ export default function Channels() {
const { setFilters: setFeedFilters } = useFeedFiltersActions();
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
// Clicking a tag chip filters the feed by that tag and jumps there. Internalised (was an App
// callback) now that Channels reads the feed filters + navigation straight from their providers.
@@ -129,24 +143,10 @@ export default function Channels() {
const userTags = (tagsQuery.data ?? []).filter((t) => !t.system);
const patch = useMutation({
mutationFn: (v: {
id: string;
body: { priority?: number; hidden?: boolean; deep_requested?: boolean };
}) => api.updateChannel(v.id, v.body),
meta: { failTitle: "errors.writeFailed.channelSettings" },
// Requesting full history may have just pulled in a channel's recent uploads, so
// refresh the per-user status and feed too — not only the channel list.
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.myStatus() });
qc.invalidateQueries({ queryKey: qk.feed() });
qc.invalidateQueries({ queryKey: qk.feedSummary() });
},
});
// Priority is updated optimistically in place and NOT re-sorted/refetched, so the list doesn't
// jump (the server list is priority-sorted; refetching would reorder rows and lose your scroll
// position). The new order takes effect next time the page loads. Press-and-hold is debounced +
// The row's stepper updates priority optimistically in place and does NOT re-sort/refetch, so the
// list doesn't jump under a press-and-hold (the server list is priority-sorted; refetching would
// reorder rows and lose your scroll position) — its new order shows next time the page loads. A
// pick from the row's menu DOES re-sort (`pickPriority` below). Press-and-hold is debounced +
// flushed on unmount by the shared hook (same flow the channel detail page uses).
const { setPriority, bumpPriority } = useChannelPriority({
read: (id) =>
@@ -158,6 +158,21 @@ export default function Channels() {
onError: () => qc.invalidateQueries({ queryKey: qk.channels() }),
});
// A priority step picked from the row's menu (E28 S3e3b, owner): the one thing a pick is FOR is
// to move the channel up (or down) the list, so the list re-sorts at once — only that row moves
// (`placeByPriority`, the server's order) — and the page and the focus follow it (`follow`): the
// table/card pager turns to its page, and the effect below hands the focus to its "…" (its node
// may have been re-inserted, which drops the focus to <body>, or be a virtualized card not yet
// mounted). Picked again from there, the next step is one Enter away.
const [follow, setFollow] = useState<{ key: string; seq: number } | null>(null);
const moreRefs = useRef(new Map<string, HTMLButtonElement>());
const listRef = useRef<HTMLDivElement>(null);
const pickPriority = (c: ManagedChannel, next: number) => {
setPriority(c.id, next);
qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) => old && placeByPriority(old, c.id));
setFollow((f) => ({ key: c.id, seq: (f?.seq ?? 0) + 1 }));
};
// Tagging is updated optimistically in place (no refetch of the whole channel list, which
// made the toggle feel ~2s slow); only revert by refetching if the server call fails. The
// attach/detach + create-and-attach flow is shared with the channel detail page.
@@ -215,25 +230,6 @@ export default function Channels() {
},
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.syncFailed"), onOpenWizard),
});
const unsubscribe = useMutation({
mutationFn: (v: { id: string; name: string }) => api.unsubscribeChannel(v.id),
onSuccess: (_d, v) => {
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.myStatus() });
notify({ level: "success", message: t("channels.notify.unsubscribed", { name: v.name }) });
},
onError: (e) =>
notifyYouTubeActionError(e, t("channels.notify.unsubscribeFailed"), onOpenWizard),
});
const resetBackfill = useMutation({
mutationFn: (v: { id: string; name: string }) => api.resetChannelBackfill(v.id),
onSuccess: (_d, v) => {
qc.invalidateQueries({ queryKey: qk.channels() });
qc.invalidateQueries({ queryKey: qk.myStatus() });
notify({ level: "success", message: t("channels.notify.resetDone", { name: v.name }) });
},
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.resetFailed"), onOpenWizard),
});
const deepAll = useMutation({
mutationFn: () => api.deepAll(true),
onSuccess: (r: { updated?: number }) => {
@@ -253,6 +249,13 @@ export default function Channels() {
// funnel share ONE state and stay in sync (bidirectional). Toggling a chip rewrites `filters.tags`;
// filterRows / DataTable then applies it. Channel-name search stays separate (the shared top box).
const tagFilter = Array.isArray(filters.tags) ? filters.tags : [];
// B4 (E28 review L7): on a short window the band's filters — the tag chip lane and the status /
// pager row — fold behind one "Filters · n"; the tabs and the TAGS editor stay. n = each picked tag,
// plus every other active column filter (the status chips are the Sync column's).
const fold = useFilterFold();
const foldCount =
tagFilter.length +
Object.entries(filters).filter(([k, v]) => k !== "tags" && isActive(v)).length;
// Picking a real tag drops the "No tag" sentinel (item B) — they're mutually exclusive, as in the feed.
const toggleTagFilter = (tagId: number) => {
const id = String(tagId);
@@ -327,24 +330,32 @@ export default function Channels() {
const onView = (c: ManagedChannel) =>
onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"));
const onUnsub = async (c: ManagedChannel) => {
const ok = await confirm({
title: t("channels.row.unsubscribeOnYoutube"),
message: t("channels.confirmUnsubscribe", { name: c.title ?? c.id }),
confirmLabel: t("channels.row.unsubscribeOnYoutube"),
danger: true,
});
if (ok) unsubscribe.mutate({ id: c.id, name: c.title ?? c.id });
};
const onReset = async (c: ManagedChannel) => {
const ok = await confirm({
title: t("channels.row.backfillThis"),
message: t("channels.confirmReset", { name: c.title ?? c.id }),
confirmLabel: t("channels.row.backfillThis"),
});
if (ok) resetBackfill.mutate({ id: c.id, name: c.title ?? c.id });
};
// The channel's ONE action list (E28 C1) — ONE menu for the list, shared by the table and the card
// layout: they are two paints of the same rows, so a right-click, a long-press and the row's "…"
// must mean the same thing in both. Only mounted under the SUBSCRIBED branch below: a discovery
// result is not a `Channel` row yet, so pinning or hiding one could only 404.
// Keyed by id: a tag toggled in the menu, a priority picked, a poll — each rebuilds the row object
// under the open menu, and its "…" must still know the menu is its own.
const rowMenu = useContextMenu<ManagedChannel>({ keyOf: (c) => c.id });
const rowPin = useComfortPin(rowMenu.open);
const channelActions = useChannelActions({
onView,
onOpenWizard,
tags: userTags,
tagsState: tagsQuery.isPending ? "loading" : tagsQuery.isError ? "error" : "ready",
onToggleTag: (c, tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId)),
onPickPriority: pickPriority,
});
// The row as the cache holds it NOW, not as it was when the menu opened: the Tags submenu stays
// open across toggles, and each tick (and the next toggle's attach-or-detach) reads this.
const menuRow =
rowMenu.target &&
((channelsQuery.data ?? []).find((c) => c.id === rowMenu.target!.id) ?? rowMenu.target);
const menuItems = menuRow
? channelActions.items(menuRow, {
pin: rowPin.item(pinChannel(menuRow.id), menuRow.title ?? t("channels.row.thisChannel")),
})
: [];
// Distinct priority levels present in the catalog → the priority column's filter options.
const prioOptions = [...new Set((channelsQuery.data ?? []).map((c) => c.priority))]
.sort((a, b) => b - a)
@@ -352,16 +363,25 @@ export default function Channels() {
const columns = buildChannelColumns({
t,
isAdmin,
canWrite,
prioOptions,
userTags,
bumpPriority,
setPriority,
onHide: (c) => patch.mutate({ id: c.id, body: { hidden: !c.hidden } }),
onDeep: (c) => patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } }),
onReset,
onUnsub,
more: (c) => {
const mine = rowMenu.open && rowMenu.target?.id === c.id;
return (
<MoreButton
ref={(el) => {
if (el) moreRefs.current.set(c.id, el);
else moreRefs.current.delete(c.id);
}}
label={t("channels.row.menu")}
expanded={mine}
menuProps={rowMenu.buttonProps(c)}
className={mine ? undefined : "reveal"}
/>
);
},
onView,
toggleTag,
createAndAttachTag,
@@ -429,11 +449,6 @@ export default function Channels() {
/>
);
const hasLayout = view === "subscribed" || view === "discovery";
// The comfort-shelf pin on a channel row (E24 S3). ONE menu for the list, shared by the table and
// the card layout — they are two paints of the same rows, so a right-click must mean the same
// thing in both. Only mounted under the SUBSCRIBED branch below: a discovery result is not a
// `Channel` row yet, so pinning one could only 404.
const pinMenu = useContextMenu<ManagedChannel>();
// In cards there are no column headers to sort by, so a dropdown drives the SAME shared sort state
// the table header uses (table ↔ cards stay in sync). Table mode keeps the header clicks.
const sortControl = (
@@ -461,7 +476,32 @@ export default function Channels() {
paged: pagedCards,
pagerProps,
resetPage,
} = useCardPager(sortedChannels, LS.channelCardSize, isCards);
} = useCardPager(
sortedChannels,
LS.channelCardSize,
isCards,
follow && { seq: follow.seq, match: (c) => c.id === follow.key },
);
// The focus half of `follow` (the pagers took the page half during render). A mounted row names
// its own place; a card the virtualized grid has not mounted yet is found by its place on the page.
const focusFollowed = useEffectEvent((key: string) => {
const wrap = listRef.current;
if (!wrap) return;
const button = () => moreRefs.current.get(key) ?? null;
const item = button()?.closest("[data-nav-index]");
const at = item
? Number(item.getAttribute("data-nav-index"))
: isCards
? sortedChannels.findIndex((c) => c.id === key) -
pagerProps.page * Math.max(0, pagerProps.size)
: -1;
// Filtered out by its new priority (a Prio column filter): nothing to follow — honest, not a
// jump to some other row.
if (at >= 0) focusNavItem(wrap, at, button);
});
useEffect(() => {
if (follow) focusFollowed(follow.key);
}, [follow]);
// Changing what you're looking at starts over at the first page, the way DataTable resets its own
// page on a filter/sort change. Deliberately NOT keyed on the row count: the qk.channels() query
// refetches on window focus and after every sync, so a catalogue that merely gained a channel would
@@ -483,6 +523,7 @@ export default function Channels() {
const tabs = (
<div className="px-4 pt-2 max-w-384 mx-auto flex items-center justify-between gap-4 flex-wrap">
<Tabs
idBase={panel.idBase}
tabs={viewTabs}
active={view}
onChange={(id) => setView(id as ChannelsView)}
@@ -496,19 +537,78 @@ export default function Channels() {
</div>
);
// The page's background menu (DESIGN "Background menu"): the toolbar's own controls, no more — the
// layout, the cards' sort, the status and tag filters, the tag editor and the catalog sync. Backfill
// stays in the "…" only: a long quota-heavy run is not a thing to start from a stray right-click.
const syncOptions = SYNC_CHIPS.map((c) => ({ value: c.value, label: t(c.labelKey) }));
const pageMenuItems = menuGroups(
hasLayout
? [
choiceSubmenu({
id: "layout",
label: t("channels.layoutLabel"),
options: layoutOptions.map((o) => ({ value: o.id, label: o.label })),
selected: layout,
onPick: setLayout,
}),
]
: [],
view === "subscribed" && isCards
? [
sortSubmenu({
id: "sort",
label: t("channels.sort.label"),
options: optionsFromColumns(columns),
sort,
onChange: setSort,
ascLabel: t("channels.sort.asc"),
descLabel: t("channels.sort.desc"),
defaultLabel: t("channels.sort.default"),
}),
]
: [],
view === "subscribed"
? [
choiceSubmenu({
id: "status",
label: t("channels.filters.syncLabel"),
options: syncOptions,
selected: syncFilter,
onPick: (sync) => setFilters({ ...filters, sync }),
}),
{ id: "tags", label: t("channels.tags.edit"), onSelect: () => setTagManagerOpen(true) },
...(tagFilter.length > 0
? [
{
id: "clearTags",
label: t("channels.tags.clearFilter"),
onSelect: () => setFilters({ ...filters, tags: [] }),
},
]
: []),
]
: [],
view === "subscribed" ? catalogActions.filter((a) => a.id === "sync") : [],
);
return (
<>
{pageMenuItems.length > 0 && (
<PageMenu ariaLabel={t("common.pageMenu")} items={pageMenuItems} />
)}
{/* The tab bar is always fixed chrome (it portals into the shell's fixed band, so it never
scrolls and doesn't jump between tabs). On Subscriptions the stats/description/tags ride
with it, and the status-chip + pager row lands just below (DataTable's controlsInBand). */}
<PageToolbar>
{tabs}
{view === "subscribed" && (
<div className="px-4 pt-2 pb-2 max-w-384 mx-auto">
{/* One non-wrapping row: the TAGS editor trigger (left) + the tag-filter chip lane (folds
overflow into "+N") + Clear, then the catalog "…" actions pinned right. It never wraps, so the
"…" menu always stays on the first line. Channel-name search lives in the shared top SearchBar. */}
<div className="flex items-center gap-2 min-w-0">
<div className="@container/tagrow px-4 pt-2 pb-2 max-w-384 mx-auto">
{/* One row: the TAGS editor trigger (left) + the tag-filter chip lane (folds overflow into
"+N") + Clear, then the catalog "…" actions pinned right, so the "…" menu always stays on the
first line. Only the suggestions block wraps: below 40rem it takes a line of its own under
the row (E28 S3f3, the owner's pick A) — at 375 it had squeezed the chip lane to 0 px and
pushed the "…" off the screen. Channel-name search lives in the shared top SearchBar. */}
<div className="flex flex-wrap items-center gap-2 min-w-0">
{/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate
"Manage tags" button; the tooltip explains what tags are + that it's clickable. */}
<Tooltip hint={t("channels.tags.yourTagsHint")}>
@@ -522,25 +622,30 @@ export default function Channels() {
{t("channels.tags.label")}
</Button>
</Tooltip>
<TagFilterChips chips={tagFilterChips} className="flex-1" />
{tagFilter.length > 0 && (
<button
<FilterFoldButton fold={fold} label={t("sidebar.filters")} count={foldCount} />
{!fold.folded && <TagFilterChips chips={tagFilterChips} className="flex-1" />}
{!fold.folded && tagFilter.length > 0 && (
<Button
variant="link-muted"
onClick={() => setFilters({ ...filters, tags: [] })}
className="shrink-0 text-xs px-2 py-1 text-muted hover:text-accent transition"
className="text-xs px-2"
>
{t("datatable.clear")}
</button>
</Button>
)}
{/* Suggested-tags affordance (E8·S4): a sparkle count of the visible suggestions, with a
one-click bulk-accept for the high-confidence ones. The per-row ghost chips handle the
rest. Hidden when there's nothing to suggest. */}
{visibleSuggestions.length > 0 && (
<div className="shrink-0 flex items-center gap-1.5">
<div
data-tag-suggestions
className="shrink-0 flex items-center gap-1.5 @max-[40rem]/tagrow:order-last @max-[40rem]/tagrow:basis-full"
>
<Tooltip hint={t("channels.tags.suggestionsHint")}>
<span className="inline-flex items-center gap-1 rounded-lg border border-dashed border-accent/50 px-2 py-1 text-xs text-accent/90">
<Sparkles className="h-3 w-3" />
<Badge tone="accent">
<Sparkles />
{t("channels.tags.suggestionsCount", { count: visibleSuggestions.length })}
</span>
</Badge>
</Tooltip>
{highConfidence.length > 0 && (
<Button variant="outline" size="xs" onClick={acceptAllHighConfidence}>
@@ -555,7 +660,7 @@ export default function Channels() {
)}
{/* In table mode the status chips + pager ride in DataTable's own in-band controls row. The card
layout has no such row, so surface the same status chips (left) and its pager (right) here. */}
{view === "subscribed" && layout === "cards" && (
{view === "subscribed" && layout === "cards" && !fold.folded && (
<div className="px-4 pb-2 max-w-384 mx-auto flex items-center justify-between gap-3 flex-wrap">
{statusChips}
{cardPager}
@@ -566,97 +671,120 @@ export default function Channels() {
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
)}
{view === "discovery" ? (
<ChannelDiscovery
canWrite={canWrite}
search={search}
onOpenWizard={onOpenWizard}
onViewChannel={onViewChannel}
/>
) : view === "blocked" ? (
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
<ExcludedChannelList
channels={blockedQuery.data ?? []}
icon={<Ban className="w-4 h-4 text-muted" />}
title={t("channels.blocked.title")}
hint={t("channels.blocked.hint")}
emptyText={t("channels.blocked.empty")}
actionLabel={t("channel.unblock")}
actionIcon={<X className="w-3.5 h-3.5" />}
onAction={(id) => unblock.mutate(id)}
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
<div {...panel.panelProps(view)}>
{view === "discovery" ? (
<ChannelDiscovery
canWrite={canWrite}
search={search}
onOpenWizard={onOpenWizard}
onViewChannel={onViewChannel}
/>
</div>
) : view === "hidden" ? (
/* Hidden channels — kept out of the Mine feed but still in the shared Library; managed here
) : view === "blocked" ? (
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
<ExcludedChannelList
channels={blockedQuery.data ?? []}
icon={<Ban className="w-4 h-4 text-muted" />}
title={t("channels.blocked.title")}
hint={t("channels.blocked.hint")}
emptyText={t("channels.blocked.empty")}
actionLabel={t("channel.unblock")}
actionIcon={<X className="w-3.5 h-3.5" />}
onAction={(id) => unblock.mutate(id)}
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
/>
</div>
) : view === "hidden" ? (
/* Hidden channels — kept out of the Mine feed but still in the shared Library; managed here
instead of dimmed in the subscribed table. Same card grid as Blocked (ExcludedChannelList). */
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
<ExcludedChannelList
channels={hiddenChannels}
icon={<EyeOff className="w-4 h-4 text-muted" />}
title={t("channels.hidden.title")}
hint={t("channels.hidden.hint")}
emptyText={t("channels.hidden.empty")}
actionLabel={t("channels.hidden.unhide")}
actionIcon={<Eye className="w-3.5 h-3.5" />}
onAction={(id) => patch.mutate({ id, body: { hidden: false } })}
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
/>
</div>
) : (
/* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
<ExcludedChannelList
channels={hiddenChannels}
icon={<EyeOff className="w-4 h-4 text-muted" />}
title={t("channels.hidden.title")}
hint={t("channels.hidden.hint")}
emptyText={t("channels.hidden.empty")}
actionLabel={t("channels.hidden.unhide")}
actionIcon={<Eye className="w-3.5 h-3.5" />}
onAction={channelActions.unhide}
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
/>
</div>
) : (
/* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when
`position: sticky` engages on the first scroll (the band's controls row above gives the
visual gap). */
<div className="px-4 pb-4 max-w-384 mx-auto">
{/* Channel table — only the rows scroll; the header row is sticky, and the controls row
<div ref={listRef} className="px-4 pb-4 max-w-384 mx-auto">
{/* Channel table — only the rows scroll; the header row is sticky, and the controls row
(status chips + pager) rides in the fixed band right above it (controlsInBand). The
card layout swaps the table for a virtualized grid over the same rows (its pager and
status chips ride in the fixed band above instead). */}
{channelsQuery.isError && !channelsQuery.data ? (
<StateMessage tone="error" onRetry={() => channelsQuery.refetch()}>
{t("common.loadError")}
</StateMessage>
) : channelsQuery.isLoading ? (
<LoadingState label={t("channels.loading")} />
) : layout === "table" ? (
<DataTable
rows={syncedChannels}
columns={columns}
rowKey={(c) => c.id}
persistKey={accountKey(LS.channelsTable) ?? undefined}
controlsPosition="both"
controlsLeading={statusChips}
controlsInBand
controlsBandClassName="px-4 max-w-384 mx-auto"
sort={sort}
onSortChange={setSort}
filters={filters}
onFiltersChange={setFilters}
fixedLayout
emptyText={t("channels.empty")}
rowProps={pinMenu.triggerProps}
keyboardNav
/>
) : (
<ChannelLayoutGrid
rows={pagedCards}
columns={columns}
rowKey={(c) => c.id}
leadKey="priority"
aboutKey="about"
actionsKey="actions"
emptyText={t("channels.empty")}
rowProps={pinMenu.triggerProps}
/>
)}
<ComfortPinMenu
menu={pinMenu}
target={(c) => pinChannel(c.id)}
title={(c) => c.title ?? t("channels.row.thisChannel")}
/>
</div>
)}
{channelsQuery.isError && !channelsQuery.data ? (
<StateMessage tone="error" onRetry={() => channelsQuery.refetch()}>
{t("common.loadError")}
</StateMessage>
) : channelsQuery.isLoading ? (
<LoadingState shape="table" label={t("channels.loading")} />
) : layout === "table" ? (
<DataTable
rows={syncedChannels}
columns={columns}
rowKey={(c) => c.id}
persistKey={accountKey(LS.channelsTable) ?? undefined}
// Folded, the status / pager row leaves the band; the list's own end keeps it.
controlsPosition={fold.folded ? "bottom" : "both"}
controlsLeading={statusChips}
controlsInBand
controlsBandClassName="px-4 max-w-384 mx-auto"
sort={sort}
onSortChange={setSort}
filters={filters}
onFiltersChange={setFilters}
fixedLayout
emptyText={t("channels.empty")}
rowProps={rowMenu.triggerProps}
// `group`: the row's "…" reveals on its hover or focus (`.reveal`), and stays on touch.
rowClassName={() => "group"}
keyboardNav
reveal={follow}
surfacedFilters={SURFACED_FILTERS}
/>
) : (
<>
{/* Cards have no funnels: a Priority or name filter set in the table would narrow them
unseen. The tags have their own chip row. */}
<HiddenFilterCue
columns={columns}
filters={filters}
by="cards"
surfaced={SURFACED_FILTERS}
onFilter={(key, value) => setFilters({ ...filters, [key]: value })}
/>
<ChannelLayoutGrid
rows={pagedCards}
columns={columns}
rowKey={(c) => c.id}
leadKey="priority"
aboutKey="about"
moreKey="actions"
emptyText={t("channels.empty")}
rowProps={rowMenu.triggerProps}
rowClassName={() => "group"}
/>
{/* Folded, the status / pager row leaves the band, as the table's does — and the
list's own end keeps it here too (round 2, F2: the cards had lost their pager). */}
{fold.folded && (
<div className="pt-3 flex items-center justify-between gap-3 flex-wrap">
{statusChips}
{cardPager}
</div>
)}
</>
)}
<ContextMenu menu={rowMenu} ariaLabel={t("channels.row.menu")} items={menuItems} />
</div>
)}
</div>
</>
);
}
+17 -16
View File
@@ -16,6 +16,9 @@ import * as e2ee from "../lib/e2ee";
import Avatar from "./ui/Avatar";
import ChatThread from "./ChatThread";
import Overlay from "./ui/Overlay";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
// The Messenger-style floating chat dock: open conversations live here, bottom-right, across
// page navigation (state persisted per user). Always mounted (for human users) so it also owns
@@ -73,7 +76,9 @@ export default function ChatDock({ meId }: { meId: number }) {
function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
const { t } = useTranslation();
return (
<div className="pointer-events-auto relative w-80 max-w-[calc(100vw-2rem)] glass rounded-2xl flex flex-col overflow-hidden">
<div
className={`${SURFACE.panel} pointer-events-auto relative w-80 max-w-[calc(100vw-2rem)] flex flex-col overflow-hidden`}
>
{/* One-shot attention flash on a new message; keyed by the counter so it replays each time. */}
{chat.flash > 0 && (
<div
@@ -82,9 +87,10 @@ function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
/>
)}
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-card/50">
<button
<Pressable
selectable
onClick={() => toggleMinimize(chat.partnerId)}
className="flex items-center gap-2 min-w-0 flex-1 text-left"
className="flex items-center gap-2 min-w-0 flex-1"
title={chat.minimized ? t("messages.expand") : t("messages.minimize")}
>
<Avatar
@@ -93,21 +99,16 @@ function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
className="w-7 h-7 rounded-full shrink-0 text-xs"
/>
<span className="font-semibold text-sm truncate">{chat.name}</span>
</button>
<button
</Pressable>
<IconButton
onClick={() => toggleMinimize(chat.partnerId)}
className="shrink-0 p-1 rounded-md text-muted hover:text-fg hover:bg-bg transition"
aria-label={chat.minimized ? t("messages.expand") : t("messages.minimize")}
label={chat.minimized ? t("messages.expand") : t("messages.minimize")}
>
{chat.minimized ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
<button
onClick={() => closeChat(chat.partnerId)}
className="shrink-0 p-1 rounded-md text-muted hover:text-fg hover:bg-bg transition"
aria-label={t("messages.close")}
>
<X className="w-4 h-4" />
</button>
{chat.minimized ? <ChevronUp /> : <ChevronDown />}
</IconButton>
<IconButton onClick={() => closeChat(chat.partnerId)} label={t("messages.close")}>
<X />
</IconButton>
</div>
{!chat.minimized && (
<div className="flex flex-col h-96">
+15 -14
View File
@@ -6,13 +6,14 @@ import { Send } from "lucide-react";
import { api, type Message, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { useScrollFade } from "../lib/useScrollFade";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { relativeTime } from "../lib/format";
import { notifyActionError } from "../lib/actionErrors";
import { partnerPub, POLL_MS, refreshPartnerPub, useKeyState } from "../lib/messaging";
import * as e2ee from "../lib/e2ee";
import KeyGate from "./KeyGate";
import { Textarea } from "./ui/Input";
import { RelativeTime } from "./ui/RelativeTime";
// The message list + composer for one conversation, shared by the full Messages page and the
// floating dock window. Handles client-side decryption and encrypt-on-send; the surrounding
@@ -193,9 +194,10 @@ export default function ChatThread({
}`}
>
{text}
<div className={`text-3xs mt-1 ${mine ? "text-accent-fg/70" : "text-muted"}`}>
{relativeTime(m.created_at)}
</div>
<RelativeTime
at={m.created_at}
className={`block text-3xs mt-1 ${mine ? "text-accent-fg/70" : "text-muted"}`}
/>
</div>
</div>
);
@@ -210,7 +212,8 @@ export default function ChatThread({
</div>
) : (
<div className="flex items-end gap-2 p-2 border-t border-border">
<textarea
<Textarea
className="flex-1 resize-none max-h-28"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
@@ -221,18 +224,16 @@ export default function ChatThread({
}}
rows={1}
placeholder={t("messages.writePlaceholder")}
className="flex-1 resize-none max-h-28 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<Button
<IconButton
label={t("messages.send")}
size="md"
variant="primary"
size="icon"
className="shrink-0"
onClick={submit}
disabled={!draft.trim() || send.isPending}
aria-label={t("messages.send")}
title={t("messages.send")}
icon={<Send className="w-4 h-4" />}
/>
>
<Send className="w-4 h-4" />
</IconButton>
</div>
)}
</>
-109
View File
@@ -1,109 +0,0 @@
import { useTranslation } from "react-i18next";
import { Bookmark, BookmarkX, Check, Eye, EyeOff, RotateCcw, Undo2 } from "lucide-react";
import { type PlexCard, type Video, type VideoStatus } from "../lib/api";
import { isResettable } from "../lib/api/feed";
import { pinPlex, pinVideo, useComfortPin, type PinTarget } from "./comfortPin";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { ContextMenu } from "./ui/ContextMenu";
import { type ContextMenuController } from "./ui/useContextMenu";
// The menu SURFACE for the comfort-shelf pin (E24 S3). Split from `comfortPin.tsx` for the same
// reason `ContextMenu.tsx` is split from `useContextMenu.ts`: a file that exports components and
// nothing else keeps react-refresh working for every surface that imports it.
/**
* The whole right-click menu for a surface whose only context action is the pin — the card grids and
* rails. Render ONE per list, outside the rows (see `ui/ContextMenu`), and spread
* `menu.triggerProps` on each row.
*
* `target` maps the row the user right-clicked to its pin; `title` names it in the confirmation
* toast. Both come from the call site because only it knows what its rows are.
*/
export function ComfortPinMenu<T>({
menu,
target,
title,
}: {
menu: ContextMenuController<T>;
target: (row: T) => PinTarget | null;
title: (row: T) => string;
}) {
const { t } = useTranslation();
const pin = useComfortPin(menu.open);
const row = menu.target;
return (
<ContextMenu
menu={menu}
ariaLabel={t("home.pin.menu")}
items={row ? pin.item(target(row), title(row)) : []}
/>
);
}
/** The Plex poster grids and rails — five of them, all pinning the same way. One component so the
* show-vs-item decision and the toast's name are written once rather than five times. */
export function PlexPinMenu({ menu }: { menu: ContextMenuController<PlexCard> }) {
return <ComfortPinMenu menu={menu} target={pinPlex} title={(c) => c.title} />;
}
/** The feed's menu (the grid and a channel page): the card's own state actions — the hover row's
* buttons, which a keyboard or a touch user otherwise reaches only by Tabbing through every card —
* then the pin. Playlists and downloads stay on the card: each opens its own anchored dialog. */
export function FeedVideoMenu({
menu,
onState,
onToggleSave,
onResetState,
}: {
menu: ContextMenuController<Video>;
onState: (id: string, status: VideoStatus) => void;
onToggleSave: (id: string, saved: boolean) => void;
onResetState: (id: string) => void;
}) {
const { t } = useTranslation();
const pin = useComfortPin(menu.open);
const v = menu.target;
const icon = "w-4 h-4";
const items: ActionMenuItem[] = v
? [
v.status === "watched"
? {
id: "watched",
label: t("card.menu.unmarkWatched"),
icon: <Undo2 className={icon} />,
onSelect: () => onState(v.id, "new"),
}
: {
id: "watched",
label: t("card.markWatched"),
icon: <Check className={icon} />,
onSelect: () => onState(v.id, "watched"),
},
{
id: "save",
label: v.saved ? t("card.menu.unsave") : t("card.saveForLater"),
icon: v.saved ? <BookmarkX className={icon} /> : <Bookmark className={icon} />,
onSelect: () => onToggleSave(v.id, !v.saved),
},
{
id: "hide",
label: v.status === "hidden" ? t("card.unhide") : t("card.hide"),
icon: v.status === "hidden" ? <Eye className={icon} /> : <EyeOff className={icon} />,
onSelect: () => onState(v.id, v.status === "hidden" ? "new" : "hidden"),
},
...(isResettable(v)
? [
{
id: "reset",
label: t("card.resetState"),
icon: <RotateCcw className={icon} />,
onSelect: () => onResetState(v.id),
},
]
: []),
...pin.item(pinVideo(v.id), v.title ?? ""),
]
: [];
return <ContextMenu menu={menu} ariaLabel={t("card.menu.label")} items={items} />;
}
@@ -9,7 +9,7 @@
// jsdom does no layout, so what is asserted is the class contract those rules rest on, the same
// trade SearchBar.dom.test.tsx already makes for the narrow-header rules.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ConfigItem } from "../lib/api";
@@ -42,7 +42,12 @@ vi.mock("../lib/api", async (importOriginal) => {
api: {
...actual.api,
adminConfig: () =>
Promise.resolve({ groups: { g: [text("site_name")] }, secrets_manageable: true }),
Promise.resolve({
groups: {
g: [text("site_name"), { ...text("signups_open"), type: "bool", value: "false" }],
},
secrets_manageable: true,
}),
},
};
});
@@ -73,4 +78,21 @@ describe("ConfigPanel field row (E22 round 2)", () => {
expect(control.className).toContain("sm:shrink-0");
expect(control.className).not.toMatch(/(^|\s)shrink-0(\s|$)/);
});
it("toggles a boolean key from its title and its hint, not only from the switch (F12)", async () => {
render(
<QueryClientProvider client={new QueryClient()}>
<ConfigPanel />
</QueryClientProvider>,
);
const sw = await screen.findByRole("switch", { name: "config.fields.signups_open.label" });
expect(sw.getAttribute("aria-checked")).toBe("false");
fireEvent.click(screen.getByText("config.fields.signups_open.label"));
expect(sw.getAttribute("aria-checked")).toBe("true");
fireEvent.click(screen.getByText("config.fields.signups_open.hint"));
expect(sw.getAttribute("aria-checked")).toBe("false");
// The switch itself still toggles exactly once, not once more through its label.
fireEvent.click(sw);
expect(sw.getAttribute("aria-checked")).toBe("true");
});
});
+65 -47
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -9,6 +9,7 @@ import Tooltip from "./ui/Tooltip";
import InlineNotice from "./ui/InlineNotice";
import { useMe } from "../lib/useMe";
import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { PageToolbar } from "./ui/PageShell";
import { Switch } from "./ui/form";
@@ -17,7 +18,10 @@ import FilterSelect from "./ui/FilterSelect";
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
import { LS, useAccountPersistedState } from "../lib/storage";
import { notifyActionError } from "../lib/actionErrors";
import { FocusSafeButton } from "./ui/FocusSafeButton";
import { Button } from "./ui/Button";
import { CheckRow } from "./ui/NativeControls";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
// backend — adding a key there makes it appear here automatically). Edits are drafted and
@@ -27,6 +31,7 @@ import { FocusSafeButton } from "./ui/FocusSafeButton";
export default function ConfigPanel() {
const { t } = useTranslation();
const panel = useTabPanel();
const qc = useQueryClient();
const me = useMe();
const q = useQuery({ queryKey: qk.adminConfig(), queryFn: api.adminConfig });
@@ -184,16 +189,22 @@ export default function ConfigPanel() {
return (
<>
{/* Intro + tab bar are fixed chrome; the config fields scroll under them. */}
{/* The tab bar is the page's band (B4); the intro scrolls with the fields — in the band it
took three lines of a short window for text read once. */}
<PageToolbar>
<div className="px-4 pt-3 max-w-3xl w-full mx-auto">
<p className="text-xs text-muted mb-3 leading-relaxed">{t("config.intro")}</p>
<Tabs tabs={groupTabs} active={activeGroup ?? ""} onChange={setTab} />
<Tabs
idBase={panel.idBase}
tabs={groupTabs}
active={activeGroup ?? ""}
onChange={setTab}
/>
</div>
</PageToolbar>
<div className="px-4 pb-24 pt-3 max-w-3xl w-full mx-auto">
<div className="px-4 pb-24 pt-1 max-w-3xl w-full mx-auto">
<p className="text-xs text-muted mb-3 leading-relaxed">{t("config.intro")}</p>
{activeGroup && (
<div className="glass rounded-2xl p-4 mb-4">
<div {...panel.panelProps(activeGroup ?? "")} className={`${SURFACE.panel} p-4 mb-4`}>
<div className="divide-y divide-border/60">
{(data.groups[activeGroup] ?? []).map((item) => (
<Field
@@ -218,47 +229,51 @@ export default function ConfigPanel() {
with no error anywhere — say so next to the fields that fix it. */}
{!me.email_configured && <InlineNotice>{t("config.emailOff")}</InlineNotice>}
<Tooltip hint={t("config.testEmailHint")}>
<FocusSafeButton
<Button
variant="glass"
icon={<Send />}
loading={testEmail.isPending}
aria-disabled={dirty}
onClick={() => testEmail.mutate()}
disabled={testEmail.isPending || dirty}
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<Send className={`w-4 h-4 ${testEmail.isPending ? "animate-pulse" : ""}`} />
{t("config.testEmail")}
</FocusSafeButton>
</Button>
</Tooltip>
{dirty && <p className="text-2xs text-muted">{t("config.testDirty")}</p>}
</div>
)}
{activeGroup === "issues" && (
<div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.issuesTestHint")}>
<FocusSafeButton
<Button
variant="glass"
icon={<Plug />}
loading={testIssues.isPending}
aria-disabled={dirty}
onClick={() => testIssues.mutate()}
disabled={testIssues.isPending || dirty}
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<Plug className={`w-4 h-4 ${testIssues.isPending ? "animate-pulse" : ""}`} />
{t("config.issuesTest")}
</FocusSafeButton>
</Button>
</Tooltip>
{dirty && <p className="text-2xs text-muted mt-2">{t("config.plexTestDirty")}</p>}
{dirty && <p className="text-2xs text-muted mt-2">{t("config.testDirty")}</p>}
</div>
)}
{activeGroup === "plex" && (
<div className="mt-3 pt-3 border-t border-border/60">
<Tooltip hint={t("config.plexTestHint")}>
<FocusSafeButton
<Button
variant="glass"
icon={<Plug />}
loading={testPlex.isPending}
aria-disabled={dirty}
onClick={() => testPlex.mutate()}
disabled={testPlex.isPending || dirty}
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
>
<Plug className={`w-4 h-4 ${testPlex.isPending ? "animate-pulse" : ""}`} />
{t("config.plexTest")}
</FocusSafeButton>
</Button>
</Tooltip>
{dirty && <p className="text-2xs text-muted mt-2">{t("config.plexTestDirty")}</p>}
{dirty && <p className="text-2xs text-muted mt-2">{t("config.testDirty")}</p>}
{plexResult && (
<div className="mt-3">
<p className="text-xs text-muted mb-2">
@@ -280,22 +295,18 @@ export default function ConfigPanel() {
<p className="text-2xs text-muted mb-1.5">{t("config.plexPickLibraries")}</p>
<div className="flex flex-col gap-1.5">
{plexResult.sections.map((s) => (
<label
<CheckRow
key={s.key}
className="inline-flex items-center gap-2 text-sm cursor-pointer"
checked={plexLibs.length === 0 || plexLibs.includes(s.key)}
onChange={() => togglePlexLib(s.key)}
className="text-sm"
>
<input
type="checkbox"
checked={plexLibs.length === 0 || plexLibs.includes(s.key)}
onChange={() => togglePlexLib(s.key)}
className="accent-accent"
/>
<span>{s.title}</span>
<span className="text-2xs text-muted">
{s.type === "movie" ? t("config.plexMovies") : t("config.plexShows")}
{s.count != null ? ` · ${s.count}` : ""}
</span>
</label>
</CheckRow>
))}
</div>
<p className="text-2xs text-muted mt-2">{t("config.plexPickHint")}</p>
@@ -367,22 +378,30 @@ function Field({
// toggles a secret's reset-on-save (applied on Save, not immediately). A boolean toggle is
// its own control — storing its value is equivalent to the default, so no reset link.
const showReset = item.is_set && !isBool;
const switchId = useId();
const Text = isBool ? "label" : "div";
return (
// Stacked below `sm`: the control column cannot shrink, and beside a wide control it left the
// label and hint a sliver of a phone's width.
<div className="flex flex-col gap-2 py-3 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{label}</div>
<p className="text-xs text-muted leading-relaxed mt-0.5">
{/* A switch's whole text column is its label, so the title or the hint toggles it (F12); a
label may only hold phrasing content, hence the block spans. */}
<Text
{...(isBool && { htmlFor: switchId })}
className={`min-w-0 flex-1 ${isBool ? "cursor-pointer select-none" : ""}`}
>
<span className="block text-sm font-medium">{label}</span>
<span className="block text-xs text-muted leading-relaxed mt-0.5">
{t(`config.fields.${item.key}.hint`, "")}
</p>
<p className="text-2xs text-muted mt-1">{status}</p>
</div>
</span>
<span className="block text-2xs text-muted mt-1">{status}</span>
</Text>
<div className="flex max-w-full flex-col items-start gap-1.5 sm:shrink-0 sm:items-end">
{isBool ? (
<Switch
id={switchId}
label={label}
checked={value === "true"}
onChange={(v) => onChange(v ? "true" : "false")}
@@ -390,7 +409,8 @@ function Field({
) : item.type === "choice" ? (
<ChoiceField item={item} label={label} value={value} onChange={onChange} />
) : (
<input
<Input
className="w-52"
type={item.secret ? "password" : isNum ? "number" : "text"}
value={value}
disabled={secretDisabled || (item.secret && pendingSecretReset)}
@@ -399,20 +419,18 @@ function Field({
max={isNum && item.max != null ? item.max : undefined}
placeholder={item.secret ? "••••••••" : String(item.default ?? "")}
aria-label={label}
className="w-52 bg-surface border border-border rounded-xl px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40 disabled:opacity-50"
/>
)}
{showReset && !secretDisabled && (
<Tooltip hint={t("config.resetHint")}>
<button
<Button
variant={item.secret && pendingSecretReset ? "link" : "link-muted"}
icon={<RotateCcw className="size-3.5!" />}
onClick={onResetField}
className={`inline-flex items-center gap-1 text-xs transition ${
item.secret && pendingSecretReset ? "text-accent" : "text-muted hover:text-fg"
}`}
className="text-xs gap-1"
>
<RotateCcw className="w-3.5 h-3.5" />
{t("config.reset")}
</button>
</Button>
</Tooltip>
)}
</div>
+5 -4
View File
@@ -1,6 +1,6 @@
import { useCallback, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import Modal from "./ui/Modal";
import Modal, { ModalFooter } from "./ui/Modal";
import { Button } from "./ui/Button";
import { ChoiceContext, type ChoiceFn, type ChoiceOptions } from "./confirmContext";
@@ -35,6 +35,7 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
{children}
{state && (
<Modal
initialFocus="action"
title={state.opts.title ?? t("common.confirmTitle")}
onClose={() => close(null)}
maxWidth={choices.length > 1 ? "max-w-md" : "max-w-sm"}
@@ -43,8 +44,8 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
{/* One action: the familiar Cancel/Confirm row. Two or more: a column, because side by
side they wrap raggedly and a wrapped button reads as a second, lesser group — and
these are peers. Ordered least to most destructive, top to bottom. */}
<div
className={multi ? "flex flex-col gap-2 mt-5" : "flex justify-end gap-2 mt-5 flex-wrap"}
<ModalFooter
className={multi ? "flex flex-col gap-2" : "flex flex-wrap justify-end gap-2"}
>
<Button
variant="outline"
@@ -66,7 +67,7 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
{c.label || t("common.confirm")}
</Button>
))}
</div>
</ModalFooter>
</Modal>
)}
</ChoiceContext.Provider>
@@ -0,0 +1,58 @@
// @vitest-environment jsdom
//
// E28 (owner): a native date field reports a complete, valid date on every keystroke of its year, so
// typing 2026 applied 0002, 0020, 0202 and 2026 — four feed refetches for one year.
import { describe, it, expect, vi, afterEach } from "vitest";
import { render, cleanup, screen, fireEvent } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
import DateFilterSelect from "./DateFilterSelect";
import { type FeedFilters } from "../lib/api";
afterEach(cleanup);
function mount(filters: Partial<FeedFilters>) {
const setFilters = vi.fn();
render(
<DateFilterSelect
filters={filters as FeedFilters}
setFilters={setFilters}
presets={[{ days: 7, key: "1week" }]}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /sidebar\.datePresetLabel/ }));
const [from] = document.querySelectorAll<HTMLInputElement>('input[type="date"]');
return { setFilters, from: from! };
}
describe("a custom range bound", () => {
it("is applied once, when the typed year becomes a plausible upload year", () => {
const { setFilters, from } = mount({ dateTo: "2026-09-01" });
for (const day of ["0002-01-15", "0020-01-15", "0202-01-15", "2026-01-15"]) {
fireEvent.change(from, { target: { value: day } });
}
expect(setFilters).toHaveBeenCalledTimes(1);
expect(setFilters.mock.calls[0]![0]).toMatchObject({ dateFrom: "2026-01-15" });
});
it("is cleared when the field is left empty, and kept when it is left half-typed", () => {
const { setFilters, from } = mount({ dateFrom: "2026-01-15" });
fireEvent.change(from, { target: { value: "0202-01-15" } });
fireEvent.blur(from);
expect(setFilters).not.toHaveBeenCalled();
expect(from.value).toBe("2026-01-15");
fireEvent.change(from, { target: { value: "" } });
expect(setFilters).not.toHaveBeenCalled();
fireEvent.blur(from);
expect(setFilters).toHaveBeenCalledTimes(1);
expect(setFilters.mock.calls[0]![0]).toMatchObject({ dateFrom: undefined });
});
});
+70 -55
View File
@@ -1,10 +1,21 @@
import { useLayoutEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { CalendarDays, Check, ChevronDown } from "lucide-react";
import { CalendarDays } from "lucide-react";
import { MenuCheck } from "./ui/MenuCheck";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { type FeedFilters } from "../lib/api";
import { deriveDateKey, selectDateChip, type DatePreset } from "../lib/dateFilter";
import {
deriveDateKey,
isCommittableDay,
selectDateChip,
type DatePreset,
} from "../lib/dateFilter";
import { formatDate } from "../lib/format";
import { Button } from "./ui/Button";
import { SelectTrigger } from "./ui/SelectTrigger";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// The feed rail's "Upload date" control: a single-select dropdown (the SortControl/FilterSelect pill,
// but full-width for the rail) over "Any time" + the day presets + a "Custom range". Picking Custom
@@ -47,10 +58,7 @@ export default function DateFilterSelect({
const key = deriveDateKey(filters, customOpen, presets);
const fmt = (s: string) => {
const d = parseDay(s);
return d
? new Intl.DateTimeFormat(i18n.language, { month: "short", day: "numeric" }).format(d)
: s;
return parseDay(s) ? formatDate(s, i18n.language) : s;
};
const rangeSummary = () => {
const { dateFrom: f, dateTo: to } = filters;
@@ -91,29 +99,23 @@ export default function DateFilterSelect({
if (value !== "custom") setOpen(false); // Custom stays open to reveal the range fields
};
const dateInputCls =
"bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40";
return (
<>
<button
type="button"
<SelectTrigger
context="form"
open={open}
icon={<CalendarDays className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />}
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={`${t("sidebar.datePresetLabel")}: ${summary}`}
className={`glass-card glass-hover flex w-full items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-sm transition ${
open ? "border-accent" : "border-border"
}`}
>
<CalendarDays className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
<span className="flex-1 truncate text-left text-fg">{summary}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
</button>
{summary}
</SelectTrigger>
<PopoverSurface
popover={pop}
ariaLabel={t("sidebar.datePresetLabel")}
className="glass-menu min-w-52 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
className={`${SURFACE.menu} min-w-52 animate-[popIn_0.16s_ease]`}
>
<div role="group" aria-label={t("sidebar.datePresetLabel")}>
{options.map((o) => {
@@ -125,67 +127,46 @@ export default function DateFilterSelect({
checked={on}
onSelect={() => pick(o.value)}
label={o.label}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ${
on ? "text-fg" : "text-muted"
}`}
>
<Check className={`h-3.5 w-3.5 shrink-0 ${on ? "text-accent" : "opacity-0"}`} />
<MenuCheck kind="one" on={on} />
<span className="flex-1 truncate">{o.label}</span>
</PopoverItem>
);
})}
</div>
{/* Reveal-on-select: the from/to fields appear once Custom is the chosen option. The inputs
stop non-Escape keys so the date-segment arrows act on the field, not the menu's list nav. */}
{/* Reveal-on-select: the from/to fields appear once Custom is the chosen option. */}
{key === "custom" && (
<div className="mt-1 flex flex-col gap-1.5 border-t border-border px-1 pt-2">
<label className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted">{t("sidebar.from")}</span>
<input
type="date"
value={filters.dateFrom ?? ""}
onKeyDown={(e) => {
if (e.key !== "Escape") e.stopPropagation();
}}
onChange={(e) =>
setFilters({
...filters,
dateFrom: e.target.value || undefined,
maxAgeDays: undefined,
year: undefined,
})
<DayField
value={filters.dateFrom}
onCommit={(v) =>
setFilters({ ...filters, dateFrom: v, maxAgeDays: undefined, year: undefined })
}
className={dateInputCls}
/>
</label>
<label className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted">{t("sidebar.to")}</span>
<input
type="date"
value={filters.dateTo ?? ""}
onKeyDown={(e) => {
if (e.key !== "Escape") e.stopPropagation();
}}
onChange={(e) =>
setFilters({
...filters,
dateTo: e.target.value || undefined,
maxAgeDays: undefined,
year: undefined,
})
<DayField
value={filters.dateTo}
onCommit={(v) =>
setFilters({ ...filters, dateTo: v, maxAgeDays: undefined, year: undefined })
}
className={dateInputCls}
/>
</label>
{(filters.dateFrom || filters.dateTo) && (
<button
type="button"
<Button
variant="link-muted"
onClick={() => setFilters({ ...filters, dateFrom: undefined, dateTo: undefined })}
className="text-2xs text-muted hover:text-accent self-end"
className="text-2xs self-end"
>
{t("sidebar.clearDates")}
</button>
</Button>
)}
</div>
)}
@@ -193,3 +174,37 @@ export default function DateFilterSelect({
</>
);
}
// One bound of the custom range. What the field shows is a draft while it has focus (`draft ?? value`):
// the bound is applied only once the draft is a plausible day (`isCommittableDay` says why), so typing a
// year no longer refetches the feed per digit; a date picked from the calendar applies at once. Leaving
// the field settles it: emptied, the bound is cleared; half-typed or implausible, it shows the bound again.
// It stops non-Escape keys so the date-segment arrows act on the field, not the menu's list navigation.
function DayField({
value,
onCommit,
}: {
value: string | undefined;
onCommit: (day: string | undefined) => void;
}) {
const [draft, setDraft] = useState<string>();
return (
<Input
size="sm"
type="date"
value={draft ?? value ?? ""}
onKeyDown={(e) => {
if (e.key !== "Escape") e.stopPropagation();
}}
onChange={(e) => {
const day = e.target.value;
setDraft(day);
if (isCommittableDay(day) && day !== value) onCommit(day);
}}
onBlur={() => {
if (draft === "" && value) onCommit(undefined);
setDraft(undefined);
}}
/>
);
}
@@ -143,7 +143,7 @@ function DialogToggle() {
<>
<button onClick={() => setOpen((v) => !v)}>toggle dialog</button>
{open && (
<Modal title="dialog" onClose={() => setOpen(false)}>
<Modal initialFocus="close" title="dialog" onClose={() => setOpen(false)}>
<p>inside</p>
</Modal>
)}
@@ -197,7 +197,7 @@ describe("the recorder pill", () => {
const onClose = vi.fn();
mount(
{ debug_session: session() },
<Modal title="dialog" onClose={onClose}>
<Modal initialFocus="close" title="dialog" onClose={onClose}>
<p>inside</p>
</Modal>,
);
@@ -215,7 +215,7 @@ describe("the recorder pill", () => {
it("Tab from the step prompt stays inside the dialog the pill sits in", () => {
mount(
{ debug_session: session() },
<Modal title="dialog" onClose={() => {}}>
<Modal initialFocus="close" title="dialog" onClose={() => {}}>
<p>inside</p>
</Modal>,
);
+16 -15
View File
@@ -16,19 +16,20 @@ import {
useStepCount,
useStepPrompt,
} from "../lib/debugRecorder";
import { formatDuration } from "../lib/format";
import { formatDateTimeSec, formatDuration } from "../lib/format";
import { useLayer } from "../lib/layerStack";
import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys";
import { dockUnderToasts } from "../lib/toastDock";
import { useMe } from "../lib/useMe";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
import Modal, { ModalFooter } from "./ui/Modal";
import { LoadingState, StateMessage } from "./ui/QueryState";
import SegmentedToggle from "./ui/SegmentedToggle";
import { inputCls } from "./ui/form";
import IssueComposer from "./IssueComposer";
import { useDebugRecording } from "./useDebugRecording";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
// The debug recorder (E16): Start → reproduce → Stop.
//
@@ -96,7 +97,7 @@ function StartDialog() {
const minutes = me.diagnostics.session_minutes;
const levels: readonly DebugSessionLevel[] = ["DEBUG", "TRACE"];
return (
<Modal title={t("recorder.start.title")} onClose={closeRecorderStart}>
<Modal initialFocus="field" title={t("recorder.start.title")} onClose={closeRecorderStart}>
<div className="flex flex-col gap-4">
<p className="text-sm text-muted leading-relaxed">{t("recorder.start.intro")}</p>
<div className="flex flex-col items-start gap-1.5">
@@ -112,7 +113,7 @@ function StartDialog() {
onChange={setLevel}
/>
</div>
<div className="rounded-lg border border-border bg-surface/60 px-4 py-3 flex flex-col gap-2 text-sm leading-relaxed">
<div className={`${SURFACE.card} px-4 py-3 flex flex-col gap-2 text-sm leading-relaxed`}>
<div className="font-semibold">{t("recorder.start.whatTitle")}</div>
<p>{t(`recorder.start.what.${level}`)}</p>
<p className="text-muted">{t("settings.diagnostics.consent.never")}</p>
@@ -121,7 +122,7 @@ function StartDialog() {
{t("recorder.start.cap", { count: minutes })}
</p>
</div>
<div className="flex justify-end gap-2">
<ModalFooter>
<Button variant="ghost" onClick={closeRecorderStart}>
{t("common.cancel")}
</Button>
@@ -133,7 +134,7 @@ function StartDialog() {
>
{t("recorder.start.confirm")}
</Button>
</div>
</ModalFooter>
</div>
</Modal>
);
@@ -152,7 +153,7 @@ function RecorderPill() {
<div
role="group"
aria-label={t("recorder.pill.label")}
className={`relative glass rounded-full h-12 pl-3 sm:pl-4 pr-1.5 flex items-center gap-1.5 sm:gap-2 shadow-lg ${
className={`relative ${SURFACE.floating} h-12 pl-3 sm:pl-4 pr-1.5 flex items-center gap-1.5 sm:gap-2 ${
hidden ? "invisible" : ""
}`}
>
@@ -304,18 +305,20 @@ function StepPrompt({ id, onDone }: { id: string; onDone: (label: string | null)
}
};
return (
<div className="absolute bottom-full left-0 mb-2 w-80 max-w-[calc(100vw-2rem)] glass rounded-xl p-3 flex flex-col gap-2 shadow-lg">
<div
className={`${SURFACE.popover} absolute bottom-full left-0 mb-2 w-80 max-w-[calc(100vw-2rem)] p-3 flex flex-col gap-2`}
>
<label htmlFor="debug-step-label" className="text-sm text-muted">
{t("recorder.pill.stepPrompt", { n })}
</label>
<input
<Input
className="w-full"
id="debug-step-label"
autoFocus
value={label}
onChange={(e) => setStepPrompt(e.target.value)}
onKeyDown={onKeyDown}
maxLength={200}
className={`${inputCls} w-full`}
/>
<div className="flex items-center justify-between gap-2 text-xs text-muted">
<span>{t("recorder.pill.stepHint")}</span>
@@ -369,7 +372,7 @@ const chipTone = (s: DebugSession): string => {
/** The last sessions, newest first — reopening one opens its composer, with what was written. */
function SessionHistory() {
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const q = useQuery({ queryKey: qk.debugSessions(), queryFn: api.debugSessions });
const items = q.data?.items.filter((s) => s.ended_at) ?? [];
if (q.isError && !q.data)
@@ -391,9 +394,7 @@ function SessionHistory() {
key={s.id}
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
>
<span className="tabular-nums">
{new Date(s.started_at).toLocaleString(i18n.language)}
</span>
<span className="tabular-nums">{formatDateTimeSec(s.started_at)}</span>
<span className="min-w-0 flex-1 truncate text-muted">
{s.draft?.title || t(`settings.diagnostics.levels.${s.level}`)}
</span>
@@ -122,9 +122,7 @@ describe("DiagnosticsSettings", () => {
fireEvent.click(levelButton("settings.diagnostics.levels.DEBUG"));
fireEvent.click(screen.getByTestId("diagnostics-consent-confirm"));
await waitFor(() => expect(screen.getByTestId("diagnostics-status")).toBeTruthy());
expect(screen.getByTestId("diagnostics-status").textContent).toContain(
"settings.diagnostics.hoursMinutes:24h0m",
);
expect(screen.getByTestId("diagnostics-status").textContent).toContain("24 hr 0 min");
} finally {
vi.useRealTimers();
}
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { api, type DiagnosticsLevel } from "../lib/api";
import { levelInForce } from "../lib/diagnostics";
import { focusIsUnclaimed } from "../lib/useFocusKeeper";
import { updateMeCache, useMe } from "../lib/useMe";
import { Button } from "./ui/Button";
import InlineNotice from "./ui/InlineNotice";
@@ -11,6 +12,7 @@ import SegmentedToggle from "./ui/SegmentedToggle";
import { Section, SettingRow } from "./ui/form";
import MyLogViewer from "./MyLogViewer";
import { RecorderSettings } from "./DebugRecorder";
import { formatUnit } from "../lib/format";
const LEVELS: readonly DiagnosticsLevel[] = ["INFO", "DEBUG", "TRACE"];
@@ -60,7 +62,7 @@ export default function DiagnosticsSettings() {
const from = closedFrom.current;
if (!from || from.isConnected) return;
closedFrom.current = null;
if (document.activeElement && document.activeElement !== document.body) return;
if (!focusIsUnclaimed()) return;
pickerRef.current?.querySelector<HTMLElement>('[aria-pressed="true"]')?.focus();
});
const watchFocus = () => {
@@ -171,8 +173,8 @@ export default function DiagnosticsSettings() {
level: levelName(current),
time:
remaining.h > 0
? t("settings.diagnostics.hoursMinutes", remaining)
: t("settings.diagnostics.minutes", remaining),
? `${formatUnit(remaining.h, "hour")} ${formatUnit(remaining.m, "minute")}`
: formatUnit(remaining.m, "minute"),
})}
</span>
</span>
+14 -10
View File
@@ -8,6 +8,7 @@ import clsx from "clsx";
// clicked, so it never ships in the feed chunk for the majority who just browse.
const DownloadDialog = lazy(() => import("./DownloadDialog"));
import { api, type Me } from "../lib/api";
import { IconButton } from "./ui/IconButton";
// Self-contained download affordance for a video card / the player. Hidden for the demo account
// (downloads spend server disk + need a real identity). Reflects the video's current state from
@@ -15,11 +16,13 @@ import { api, type Me } from "../lib/api";
export default function DownloadButton({
videoId,
title,
className,
variant = "ghost",
size = "sm",
}: {
videoId: string;
title?: string | null;
className?: string;
variant?: "ghost" | "outline" | "overlay";
size?: "sm" | "md";
}) {
const { t } = useTranslation();
const qc = useQueryClient();
@@ -47,20 +50,21 @@ export default function DownloadButton({
return (
<>
<button
<IconButton
variant={variant}
size={size}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpen(true);
}}
title={label}
className={clsx(
className ?? "p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
(downloaded || inQueue) && "text-accent",
)}
label={label}
>
<Download className={clsx("w-4 h-4", inQueue && "animate-pulse")} />
</button>
{/* The state is in the glyph: downloaded or queued is the accent, a running one pulses. */}
<Download
className={clsx((downloaded || inQueue) && "text-accent", inQueue && "animate-pulse")}
/>
</IconButton>
{open && (
<Suspense fallback={null}>
<DownloadDialog source={videoId} title={title} onClose={() => setOpen(false)} />
@@ -77,6 +77,24 @@ afterEach(() => {
mocks.confirm.mockClear();
});
/** Pick `label` from the row's menu, opened by its "…". The surface is visibility:hidden in jsdom
* (floating-ui never positions it), so the row is matched on its text. */
async function pick(row: string, label: string) {
const more = screen
.getByText(row)
.closest<HTMLElement>("[data-nav-index]")!
.querySelector<HTMLElement>('button[aria-label="downloads.rowMenu"]')!;
fireEvent.click(more);
const item = await waitFor(() => {
const found = screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === label);
expect(found).toBeTruthy();
return found!;
});
fireEvent.click(item);
}
describe("DownloadCenter confirm dialogs", () => {
it("label the confirm button with the action, not the dialog's question", async () => {
mocks.jobs = [job(1, "running"), job(2, "done")];
@@ -86,12 +104,15 @@ describe("DownloadCenter confirm dialogs", () => {
</QueryClientProvider>,
);
fireEvent.click(screen.getByTitle("downloads.actions.cancel"));
// E28 C1: the destructive and rare row buttons live in the row's "…".
await pick("Clip 1", "downloads.actions.cancel");
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
// The tab's label shares its button with a count badge.
fireEvent.click(screen.getByRole("button", { name: /downloads\.tabs\.done/ }));
fireEvent.click(await screen.findByTitle("downloads.actions.redownload"));
fireEvent.click(screen.getByTitle("downloads.actions.delete"));
fireEvent.click(screen.getByRole("tab", { name: /downloads\.tabs\.done/ }));
await screen.findByText("Clip 2");
await pick("Clip 2", "downloads.actions.redownload");
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(2));
await pick("Clip 2", "downloads.actions.delete");
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(3));
expect(mocks.confirm.mock.calls.map(([o]) => [o.title, o.confirmLabel])).toEqual([
@@ -0,0 +1,333 @@
// @vitest-environment jsdom
//
// E28 C1 on the Download Center: a download row keeps its primary action (play; pause / resume /
// retry in the queue) and the save link in sight, and everything else — the editors, sharing,
// re-download, the copies, cancel / delete / remove — is its ONE menu, opened by the row's "…" (it
// shows on hover or focus) or a right-click on the row. A link inside the row keeps the browser's own
// menu. i18n is key-passthrough, so labels read as keys.
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { api, type DownloadJob } from "../lib/api";
import { menuGroups } from "./ui/menuGroups";
const mocks = vi.hoisted(() => ({
jobs: [] as unknown[],
confirm: vi.fn(async (_o?: unknown) => true),
}));
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
vi.mock("../lib/useLiveQuery", () => ({
useLiveQuery: (key: readonly unknown[]) => ({
data: key[0] === "downloads" ? mocks.jobs : undefined,
isError: false,
isLoading: false,
refetch: vi.fn(),
}),
}));
vi.mock("../lib/useMe", () => ({ useMe: () => ({ role: "user" }) }));
vi.mock("./ui/PageShell", async (importOriginal) => ({
...(await importOriginal<typeof import("./ui/PageShell")>()),
PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
import DownloadCenter from "./DownloadCenter";
const job = (
id: number,
status: DownloadJob["status"],
extra: Partial<DownloadJob> = {},
): DownloadJob =>
({
id,
status,
progress: 0,
speed_bps: null,
eta_s: null,
phase: null,
error: null,
error_code: null,
error_detail: null,
queue_pos: 0,
created_at: null,
source_kind: "youtube",
source_ref: `v${id}`,
source_url: null,
poster_url: null,
profile_id: null,
spec: { mode: "av" },
display_name: `Clip ${id}`,
display_uploader: null,
display_uploader_url: null,
extra_links: [],
can_download: status === "done",
expired: false,
job_kind: "download",
asset: { media_kind: "video", duration_s: 60 },
...extra,
}) as unknown as DownloadJob;
afterEach(() => {
cleanup();
vi.restoreAllMocks();
mocks.confirm.mockClear();
});
function mount(tab?: string) {
render(
<QueryClientProvider client={new QueryClient()}>
<DownloadCenter />
</QueryClientProvider>,
);
if (tab)
fireEvent.click(screen.getByRole("tab", { name: new RegExp(`downloads\\.tabs\\.${tab}`) }));
}
const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!;
const moreOf = (text: string) =>
rowOf(text).querySelector<HTMLElement>('button[aria-label="downloads.rowMenu"]')!;
const menuRows = () =>
screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? "");
async function openMenu(open: () => void) {
act(open);
await waitFor(() =>
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
);
}
const rowButtons = (text: string) =>
[...rowOf(text).querySelectorAll<HTMLElement>("button, a[aria-label]")].map((b) =>
b.getAttribute("aria-label"),
);
describe("DownloadCenter · the queue row", () => {
it("shows pause and its '…' only; the '…' hides until hover or focus", async () => {
mocks.jobs = [job(1, "running")];
mount();
expect(rowButtons("Clip 1")).toEqual(["downloads.actions.pause", "downloads.rowMenu"]);
expect(moreOf("Clip 1").classList.contains("reveal")).toBe(true);
expect(rowOf("Clip 1").classList.contains("group")).toBe(true);
});
it("holds pause, the copies and cancel; a failed job's first row is Retry", async () => {
mocks.jobs = [job(1, "running", { source_url: "https://youtu.be/x" }), job(2, "error")];
mount();
await openMenu(() => fireEvent.click(moreOf("Clip 1")));
expect(menuRows()).toEqual([
"downloads.actions.pause",
"common.copy.title",
"common.copy.link",
"downloads.actions.cancel",
]);
expect(moreOf("Clip 1").getAttribute("aria-expanded")).toBe("true");
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
expect(menuRows()).toEqual([
"downloads.actions.retry",
"common.copy.title",
"downloads.actions.cancel",
]);
});
it("draws Retry as 'do it again', never the Play glyph (row and menu)", async () => {
mocks.jobs = [job(1, "error"), job(2, "paused")];
mount();
const glyph = (el: Element) => el.querySelector("svg")?.getAttribute("class") ?? "";
const retry = rowOf("Clip 1").querySelector('[aria-label="downloads.actions.retry"]')!;
expect(glyph(retry)).toContain("lucide-refresh-cw");
// A paused job resumes: that one keeps the transport glyph.
const resume = rowOf("Clip 2").querySelector('[aria-label="downloads.actions.resume"]')!;
expect(glyph(resume)).toContain("lucide-play");
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 1")));
const row = screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "downloads.actions.retry")!;
expect(glyph(row)).toContain("lucide-refresh-cw");
});
it("closes on a second press of the same '…'", async () => {
mocks.jobs = [job(1, "running")];
mount();
await openMenu(() => fireEvent.click(moreOf("Clip 1")));
// The press lands outside the panel, which floating-ui's dismiss closes on; the click after
// it must not open it again.
act(() => {
fireEvent.pointerDown(moreOf("Clip 1"), { pointerType: "mouse" });
fireEvent.mouseDown(moreOf("Clip 1"));
fireEvent.click(moreOf("Clip 1"), { detail: 1 });
});
await waitFor(() =>
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0),
);
expect(moreOf("Clip 1").getAttribute("aria-expanded")).toBe("false");
});
it("still closes when a poll replaced the job under the open menu", async () => {
mocks.jobs = [job(1, "running")];
mount();
await openMenu(() => fireEvent.click(moreOf("Clip 1")));
// The queue polls while a job runs: the next render lists a NEW object for the same job.
mocks.jobs = [job(1, "running", { progress: 40 })];
fireEvent.change(screen.getByPlaceholderText("downloads.page.addUrl"), {
target: { value: "x" },
});
act(() => {
fireEvent.pointerDown(moreOf("Clip 1"), { pointerType: "mouse" });
fireEvent.mouseDown(moreOf("Clip 1"));
fireEvent.click(moreOf("Clip 1"), { detail: 1 });
});
await waitFor(() =>
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0),
);
});
it("cancels only through the confirm", async () => {
mocks.jobs = [job(1, "queued")];
const cancel = vi.spyOn(api, "cancelDownload").mockResolvedValue({} as never);
mount();
const pickCancel = async () => {
await openMenu(() => fireEvent.click(moreOf("Clip 1")));
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "downloads.actions.cancel")!,
);
};
mocks.confirm.mockResolvedValueOnce(false);
await pickCancel();
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true }));
expect(cancel).not.toHaveBeenCalled();
await pickCancel();
await waitFor(() => expect(cancel).toHaveBeenCalledWith(1));
});
});
describe("DownloadCenter · the library row", () => {
it("shows play, share, save and its '…'; the editors, managing shares and delete are in the menu", async () => {
mocks.jobs = [job(2, "done", { source_url: "https://youtu.be/x" })];
mount("done");
await screen.findByText("Clip 2");
expect(rowButtons("Clip 2")).toEqual([
"downloads.actions.play",
"downloads.actions.share",
"downloads.actions.download",
"downloads.rowMenu",
]);
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
expect(menuRows()).toEqual([
"downloads.actions.play",
"downloads.actions.share",
"downloads.actions.download",
"downloads.actions.edit",
"downloads.actions.editDetails",
"downloads.actions.redownload",
"downloads.actions.shareManage",
"common.copy.title",
"common.copy.link",
"downloads.actions.delete",
]);
});
it("copies the share link from the row's own Share button", async () => {
mocks.jobs = [job(2, "done")];
vi.spyOn(api, "downloadLinks").mockResolvedValue([] as never);
const create = vi
.spyOn(api, "createDownloadLink")
.mockResolvedValue({ id: 1, url: "/s/tok" } as never);
mount("done");
await screen.findByText("Clip 2");
fireEvent.click(
rowOf("Clip 2").querySelector<HTMLElement>('button[aria-label="downloads.actions.share"]')!,
);
await waitFor(() => expect(create).toHaveBeenCalledWith(2, {}));
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0);
});
it("saves the file through the same link as the row's save button", async () => {
mocks.jobs = [job(2, "done")];
const followed: string[] = [];
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (
this: HTMLAnchorElement,
) {
followed.push(this.href);
});
mount("done");
await screen.findByText("Clip 2");
await openMenu(() => fireEvent.click(moreOf("Clip 2")));
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "downloads.actions.download")!,
);
const button = rowOf("Clip 2").querySelector<HTMLAnchorElement>(
'a[aria-label="downloads.actions.download"]',
)!;
expect(followed).toEqual([button.href]);
});
it("leaves out what a file or a clip cannot do, and no rule is left behind", async () => {
mocks.jobs = [job(3, "done", { job_kind: "upload", asset: { media_kind: "file" } } as never)];
mount("done");
await screen.findByText("Clip 3");
await openMenu(() => fireEvent.click(moreOf("Clip 3")));
expect(menuRows()).toEqual([
"downloads.actions.share",
"downloads.actions.download",
"downloads.actions.shareManage",
"common.copy.title",
"downloads.actions.delete",
]);
});
it("gives a link inside the row the browser's own menu", async () => {
mocks.jobs = [job(2, "done", { source_url: "https://youtu.be/x" })];
mount("done");
const link = await screen.findByText("youtu.be/x");
const ev = new MouseEvent("contextmenu", { bubbles: true, cancelable: true });
act(() => void link.dispatchEvent(ev));
expect(ev.defaultPrevented).toBe(false);
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0);
});
});
describe("DownloadCenter · the shared row", () => {
it("holds play, save, edit, the copies and Remove from my list", async () => {
mocks.jobs = [];
vi.spyOn(api, "downloadsShared").mockResolvedValue([
job(4, "done", { can_download: true }),
] as never);
const remove = vi.spyOn(api, "removeSharedDownload").mockResolvedValue({} as never);
mount("shared");
await screen.findByText("Clip 4");
await openMenu(() => fireEvent.click(moreOf("Clip 4")));
expect(menuRows()).toEqual([
"downloads.actions.play",
"downloads.actions.download",
"downloads.actions.edit",
"common.copy.title",
"downloads.actions.removeShared",
]);
fireEvent.click(
screen
.getAllByRole("menuitem", { hidden: true })
.find((b) => b.textContent === "downloads.actions.removeShared")!,
);
await waitFor(() => expect(remove).toHaveBeenCalledWith(4));
});
});
describe("menuGroups", () => {
const row = (id: string) => ({ id, label: id, onSelect: () => {} });
it("rules between non-empty groups only", () => {
const out = menuGroups([], [row("a"), row("b")], [], [row("c")]);
expect(out.map((r) => [r.id, !!r.separatorBefore])).toEqual([
["a", false],
["b", false],
["c", true],
]);
});
});
+114 -205
View File
@@ -1,25 +1,21 @@
import { lazy, Suspense, useState } from "react";
import { qk } from "../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import {
Ban,
ClipboardPaste,
Download,
Link2,
Pause,
Play,
PlayCircle,
Plus,
RotateCcw,
Scissors,
RefreshCw,
Settings2,
Share2,
Pencil,
Trash2,
UploadCloud,
} from "lucide-react";
import Tabs from "./ui/Tabs";
import { useTabPanel } from "./ui/tabPanel";
import { PageToolbar } from "./ui/PageShell";
import { LoadingState, StateMessage } from "./ui/QueryState";
// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
@@ -30,28 +26,32 @@ const ProfileEditor = lazy(() => import("./ProfileEditor"));
const VideoEditor = lazy(() => import("./VideoEditor"));
const ShareDialog = lazy(() => import("./ShareDialog"));
const LibraryPlayer = lazy(() => import("./LibraryPlayer"));
import { useConfirm } from "./confirmContext";
import { useLiveQuery } from "../lib/useLiveQuery";
import { useMe } from "../lib/useMe";
import { api, type DownloadJob } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
import { inputCls } from "./ui/form";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
import { MetaEditModal, ErrorModal } from "./downloadModals";
import { AdminSystem } from "./downloadAdmin";
import NavGrid from "./ui/NavGrid";
import { Input } from "./ui/Input";
import { IconLink } from "./ui/LinkButton";
import { useContextMenu } from "./ui/useContextMenu";
import { ContextMenu } from "./ui/ContextMenu";
import { MoreButton } from "./ui/MoreButton";
import { useDownloadActions, type DownloadTab } from "./useDownloadActions";
const jobKey = (job: DownloadJob) => job.id;
export default function DownloadCenter() {
const me = useMe();
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
// Always land on the queue tab (not a persisted last-tab) — the queue is where new work and
// any failures are, so it's the useful default every visit.
const [tab, setTab] = useState("queue");
const panel = useTabPanel();
const [addUrl, setAddUrl] = useState("");
const [addSource, setAddSource] = useState<string | null>(null);
const [upload, setUpload] = useState(false);
@@ -78,56 +78,35 @@ export default function DownloadCenter() {
const queue = jobs.filter((j) => ["queued", "running", "paused", "error"].includes(j.status));
const library = jobs.filter((j) => j.status === "done");
const invalidate = () => invalidateDownloads(qc);
const act = useMutation({
mutationFn: ({
id,
op,
}: {
id: number;
op: "pause" | "resume" | "cancel" | "delete" | "redownload";
}) =>
op === "pause"
? api.pauseDownload(id)
: op === "resume"
? api.resumeDownload(id)
: op === "cancel"
? api.cancelDownload(id)
: op === "redownload"
? api.redownloadDownload(id)
: api.deleteDownload(id),
onSuccess: invalidate,
// A 400/409/422/500 opens the global error dialog with the backend's own message (and is then
// skipped here); a job that is gone (404) or a lost connection is said here, naming the action.
onError: (e, v) => notifyActionError(e, t(`errors.writeFailed.job.${v.op}`)),
// --- The rows' ONE menu (E28 C1) -------------------------------------------------------------
// Keyed by the job's id: the queue polls every 2 s while a job runs, replacing the object under an
// open menu, and its "…" must still close it. The tab is the one on screen.
const rowMenu = useContextMenu<DownloadJob>({ keyOf: jobKey });
const actions = useDownloadActions({
onPlay: setPlayJob,
onEdit: setEditJob,
onEditDetails: setMetaJob,
onManageShares: setShareJob,
});
// Remove a shared-with-me item from your list (only your grant; the owner's file is untouched).
const removeShared = useMutation({
mutationFn: (id: number) => api.removeSharedDownload(id),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.downloadsShared() }),
meta: { failTitle: "errors.writeFailed.removeShared" },
});
// The confirm button names the ACTION ("Remove"), never the dialog's question: labelled with the title
// it read "Re-download this file?", and the cancel dialog's own "Cancel" would sit beside "Cancel".
const confirmThen = async (title: string, body: string, action: string, fn: () => void) => {
if (await confirm({ title, message: body, confirmLabel: action, danger: true })) fn();
};
// One-click share (E7 S3 · D2-A): ensure a downloadable link, copy it, toast with "open in new tab"
// (see quickShareLink). Re-clicking reuses the existing link; a per-job in-flight guard also stops a
// double-click from racing two POSTs into two links before the cache is primed. Advanced controls
// (password, expiry, revoke, share-with-a-user) live in the Share dialog, via the manage-share button.
const [sharingId, setSharingId] = useState<number | null>(null);
const quickShare = async (job: DownloadJob) => {
if (sharingId !== null) return;
setSharingId(job.id);
try {
await quickShareLink(qc, job, t);
} finally {
setSharingId(null);
}
const menuTarget = rowMenu.target;
const listTab: DownloadTab | null =
tab === "queue" || tab === "done" || tab === "shared" ? tab : null;
const menuItems = menuTarget && listTab ? actions.items(menuTarget, listTab) : [];
// The row's menu triggers and its "…".
const rowMenuProps = (job: DownloadJob) => {
const mine = rowMenu.open && rowMenu.target?.id === job.id;
return {
rowProps: rowMenu.triggerProps(job),
more: (
<MoreButton
label={t("downloads.rowMenu")}
size="md"
expanded={mine}
menuProps={rowMenu.buttonProps(job)}
className={mine ? undefined : "reveal"}
/>
),
};
};
// Pull a link off the clipboard into the add-URL box. Clipboard reads need a secure context +
@@ -158,56 +137,57 @@ export default function DownloadCenter() {
if (!tabs.some((tb) => tb.id === tab)) setTab("queue");
const saveBtn = (job: DownloadJob) => (
<a
href={api.downloadFileUrl(job.id)}
title={t("downloads.actions.download")}
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg transition"
>
<Download className="w-4 h-4" />
</a>
<IconLink href={api.downloadFileUrl(job.id)} label={t("downloads.actions.download")} size="md">
<Download />
</IconLink>
);
return (
<>
{/* Page title + subtitle + tabs are fixed chrome; the tab content scrolls under them. */}
{/* The tabs are the page's band (B4); the tab content scrolls under them. No visible title: the
nav rail and the tab title name the page, the shell's hidden h1 names it to a screen reader. */}
<PageToolbar>
<div className="px-4 pt-2 max-w-4xl w-full mx-auto">
<div className="flex items-center justify-between gap-3 mb-1">
<h1 className="text-xl font-semibold">{t("downloads.page.title")}</h1>
<button
onClick={() => setManage(true)}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
>
<Settings2 className="w-4 h-4" /> {t("downloads.page.manage")}
</button>
</div>
<p className="text-sm text-muted mb-4">{t("downloads.page.subtitle")}</p>
<Tabs tabs={tabs} active={tab} onChange={setTab} />
<div className="px-4 pt-2 pb-2 max-w-4xl w-full mx-auto flex flex-wrap items-center justify-between gap-2">
<Tabs
idBase={panel.idBase}
tabs={tabs}
active={tab}
onChange={setTab}
className="flex flex-wrap gap-1"
/>
<Button
variant="ghost"
size="sm"
icon={<Settings2 />}
onClick={() => setManage(true)}
className="shrink-0"
>
{t("downloads.page.manage")}
</Button>
</div>
</PageToolbar>
<div className="px-4 pb-4 pt-3 max-w-4xl w-full mx-auto">
<div {...panel.panelProps(tab)} className="px-4 pb-4 pt-3 max-w-4xl w-full mx-auto">
{tab === "queue" && (
<>
<div className="flex gap-2 mb-4">
<input
{/* The field takes the whole line when the buttons would squeeze it (at 375 it had 57px). */}
<div className="flex flex-wrap gap-2 mb-4">
<Input
className="min-w-0 flex-[1_1_16rem]"
value={addUrl}
onChange={(e) => setAddUrl(e.target.value)}
onFocus={() => pasteFromClipboard()}
onKeyDown={(e) => e.key === "Enter" && addUrl.trim() && setAddSource(addUrl.trim())}
placeholder={t("downloads.page.addUrl")}
className={inputCls}
/>
<Button
variant="ghost"
size="sm"
iconOnly
icon={<ClipboardPaste className="w-4 h-4" />}
<IconButton
label={t("downloads.page.paste")}
size="sm"
variant="ghost"
onClick={() => pasteFromClipboard(true)}
className="shrink-0"
/>
>
<ClipboardPaste className="w-4 h-4" />
</IconButton>
<Button
variant="primary"
size="sm"
@@ -230,41 +210,38 @@ export default function DownloadCenter() {
</div>
<NavGrid className="space-y-2">
{queue.map((job, i) => (
<DownloadRow key={job.id} job={job} navIndex={i} onShowError={setErrorJob}>
{(job.status === "queued" || job.status === "running") && (
<DownloadRow
key={job.id}
job={job}
navIndex={i}
onShowError={setErrorJob}
{...rowMenuProps(job)}
>
{/* The queue's primary action stays in sight; cancel moved into the "…". */}
{job.status === "queued" || job.status === "running" ? (
<IconBtn
onClick={() => act.mutate({ id: job.id, op: "pause" })}
onClick={() => actions.run(job, "pause")}
title={t("downloads.actions.pause")}
>
<Pause className="w-4 h-4" />
</IconBtn>
)}
{(job.status === "paused" || job.status === "error") && (
) : (
<IconBtn
onClick={() => act.mutate({ id: job.id, op: "resume" })}
onClick={() => actions.run(job, "resume")}
title={t(
job.status === "error"
? "downloads.actions.retry"
: "downloads.actions.resume",
)}
>
<Play className="w-4 h-4" />
{/* Retry is "do it again", not playback: the Play glyph promised a video. */}
{job.status === "error" ? (
<RefreshCw className="w-4 h-4" />
) : (
<Play className="w-4 h-4" />
)}
</IconBtn>
)}
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.cancelTitle"),
t("downloads.confirm.cancelBody"),
t("downloads.confirm.cancelAction"),
() => act.mutate({ id: job.id, op: "cancel" }),
)
}
title={t("downloads.actions.cancel")}
danger
>
<Ban className="w-4 h-4" />
</IconBtn>
</DownloadRow>
))}
{queue.length === 0 &&
@@ -273,7 +250,7 @@ export default function DownloadCenter() {
{t("common.loadError")}
</StateMessage>
) : jobsQ.isLoading ? (
<LoadingState />
<LoadingState shape="list" />
) : (
<div className="text-sm text-muted py-10 text-center">
{t("downloads.empty.queue")}
@@ -287,82 +264,31 @@ export default function DownloadCenter() {
<>
<UsageBar />
<NavGrid className="space-y-2">
{library.map((job, i) => {
// Row actions gated on the stored media_kind + job_kind (see downloadActions).
const a = libraryRowActions(job);
return (
<DownloadRow key={job.id} job={job} navIndex={i}>
{a.play && (
<IconBtn
open
onClick={() => setPlayJob(job)}
title={t("downloads.actions.play")}
>
<PlayCircle className="w-4 h-4" />
</IconBtn>
)}
{saveBtn(job)}
{a.edit && (
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
<Scissors className="w-4 h-4" />
</IconBtn>
)}
{a.editDetails && (
<IconBtn
onClick={() => setMetaJob(job)}
title={t("downloads.actions.editDetails")}
>
<Pencil className="w-4 h-4" />
</IconBtn>
)}
<IconBtn onClick={() => quickShare(job)} title={t("downloads.actions.share")}>
<Share2 className="w-4 h-4" />
</IconBtn>
{library.map((job, i) => (
<DownloadRow key={job.id} job={job} navIndex={i} {...rowMenuProps(job)}>
{libraryRowActions(job).play && (
<IconBtn
onClick={() => setShareJob(job)}
title={t("downloads.actions.shareManage")}
open
onClick={() => setPlayJob(job)}
title={t("downloads.actions.play")}
>
<Link2 className="w-4 h-4" />
<PlayCircle className="w-4 h-4" />
</IconBtn>
{a.redownload && (
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.redownloadTitle"),
t("downloads.confirm.redownloadBody"),
t("downloads.confirm.redownloadAction"),
() => act.mutate({ id: job.id, op: "redownload" }),
)
}
title={t("downloads.actions.redownload")}
>
<RotateCcw className="w-4 h-4" />
</IconBtn>
)}
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.deleteTitle"),
t("downloads.confirm.deleteBody"),
t("downloads.actions.delete"),
() => act.mutate({ id: job.id, op: "delete" }),
)
}
title={t("downloads.actions.delete")}
danger
>
<Trash2 className="w-4 h-4" />
</IconBtn>
</DownloadRow>
);
})}
)}
{/* Sharing the link is the library's main errand: in sight, ahead of save. */}
<IconBtn onClick={() => actions.share(job)} title={t("downloads.actions.share")}>
<Share2 className="w-4 h-4" />
</IconBtn>
{saveBtn(job)}
</DownloadRow>
))}
{library.length === 0 &&
(jobsQ.isError && !jobsQ.data ? (
<StateMessage tone="error" onRetry={() => jobsQ.refetch()}>
{t("common.loadError")}
</StateMessage>
) : jobsQ.isLoading ? (
<LoadingState />
<LoadingState shape="list" />
) : (
<div className="text-sm text-muted py-10 text-center">
{t("downloads.empty.done")}
@@ -378,7 +304,7 @@ export default function DownloadCenter() {
// Same media_kind + can_download gate as the library, via the shared helper (S5b).
const a = sharedRowActions(job);
return (
<DownloadRow key={job.id} job={job} navIndex={i}>
<DownloadRow key={job.id} job={job} navIndex={i} {...rowMenuProps(job)}>
{a.play && (
<IconBtn
open
@@ -389,25 +315,6 @@ export default function DownloadCenter() {
</IconBtn>
)}
{a.save && saveBtn(job)}
{a.edit && (
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
<Scissors className="w-4 h-4" />
</IconBtn>
)}
<IconBtn
onClick={() =>
confirmThen(
t("downloads.confirm.removeSharedTitle"),
t("downloads.confirm.removeSharedBody"),
t("downloads.actions.removeShared"),
() => removeShared.mutate(job.id),
)
}
title={t("downloads.actions.removeShared")}
danger
>
<Trash2 className="w-4 h-4" />
</IconBtn>
</DownloadRow>
);
})}
@@ -417,7 +324,7 @@ export default function DownloadCenter() {
{t("common.loadError")}
</StateMessage>
) : sharedQ.isLoading ? (
<LoadingState />
<LoadingState shape="list" />
) : (
<div className="text-sm text-muted py-10 text-center">
{t("downloads.empty.shared")}
@@ -428,6 +335,8 @@ export default function DownloadCenter() {
{tab === "system" && me.role === "admin" && <AdminSystem />}
<ContextMenu menu={rowMenu} ariaLabel={t("downloads.rowMenu")} items={menuItems} />
<Suspense fallback={null}>
{addSource && (
<DownloadDialog
@@ -447,7 +356,7 @@ export default function DownloadCenter() {
{errorJob && (
<ErrorModal
job={errorJob}
onRetry={() => act.mutate({ id: errorJob.id, op: "resume" })}
onRetry={() => actions.run(errorJob, "resume")}
onClose={() => setErrorJob(null)}
/>
)}

Some files were not shown because too many files have changed in this diff Show More