Commit Graph
100 Commits
Author SHA1 Message Date
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