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.
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.
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.
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).
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).
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.
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.
- 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.
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.
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.
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.
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.
- 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.
- 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.
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.
- 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.
- 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.
- 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.
- 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.
- 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.
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).
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.
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).
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.
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.
Both refused an out-of-range value by discarding it: the entry vanished, the box
snapped back to the server's number, and nothing said whether the save had
happened, been rejected, or been ignored. That is the house rule about honest
states, applied to a write instead of a read. They now keep the draft, mark the
field aria-invalid and name the range (HU+EN), and the Save control is disabled
rather than silently inert.
The interval editor also dropped a keyboard user's focus on every exit — Escape,
Cancel and Save all unmount the focused input — so it hands focus back to the
pencil that opened it, the pattern IssueComposer already uses.
Tests: the four interval-editor cases added in S2 are relabelled CHARACTERIZATION,
because they pass against the pre-sweep code too (the old effect was already
guarded by `editing`). The editor that genuinely lost typing to a poll is the
maintenance batch box, whose old effect had no guard at all, and it had no such
test — it does now, anchored on a value that follows the server so it cannot pass
on a page that simply has not polled yet.
Round 2 made the step prompt a blocking layer so that `j` and the `g` chords
would not reach the page behind it. The dialog it sits in gave up its Tab trap
because of that, while the prompt's own replacement trap covered only its own
two controls — so Tab from anywhere else in the dialog (the bar's Screenshot or
Stop, the dialog's own buttons) walked out behind the scrim. `layerStack` now
separates the two questions it had folded into one flag: a layer can own the
keyboard and still leave the Tab trap to the dialog below it. The mirror case,
a dialog opening over the prompt, falls out of the same split.
A send's claim is now a lease with an owner. Every progress report re-stamps
it, so a send slower than the lease cannot lose it while it is still running,
and only the send that took a claim can give it back. The issue_create audit
row is staged inside the send and rides the same checkpoint as `issue_url`, so
the trail cannot be missing for an issue that exists. The attachment-name
prefix has one definition, shared by the producer and both readers.
A running send is reported to the UI. A reload or a second tab used to render
it exactly like a send that STOPPED, and offered a retry the server was certain
to refuse; the composer now says the report is being sent, holds the draft, and
puts both the retry and Create issue out. The past-sessions list says the same
for the same reason — it is the list a user opens to check on a send, and both
"draft" and "half sent" describe one that died. `/preview` and the retry path
got a pace of their own, and the per-user byte cap took the user-scoped lock
that makes it a cap instead of a guess.
Verified in a real browser on localdev: Tab cycles the whole dialog with the
prompt open, the prompt still swallows the page's keys, and the issue link is
focusable where the composer puts it. All 24 regression tests mutation-checked.
The send no longer holds the session's row lock while it talks to the tracker:
it claims the session, gives the lock back, and checkpoints each step. A Stop
is never held behind a send, while a draft, a screenshot or a second send are
refused at once instead of queueing. The claim ages out, so a request that
died mid-send does not strand the session.
What the composer says is what happened: an attachment that went is no longer
named as one that did not (the missing body edit is its own line), and the
issue's screenshot count comes from the server when the list cannot be read,
so a failed read no longer blocks saving the draft. The zip is fetched as a
request, so its pace is a message and not a saved error page. Screenshots of a
finished report can be removed again; only one still being sent is held.
A recording's log elevation can no longer outlive it. The step prompt keeps
the keyboard (the page's keys stay quiet) and traps Tab itself. An image
pasted into a dialog opened over the composer stays that dialog's.
The send, the draft and the screenshots take the session's row lock, so an
autosave or a paste during a send waits for it and then sees it, and two sends
cannot open two issues. A save keeps the delivery's progress; a failed edit
that shows the screenshots is finished by the retry; an answer that is not the
tracker's API (a redirect, a proxy page) is refused, not a crash that loses the
issue's record. Only an attempt that opened an issue counts toward the pace.
Screenshots: deleting one after the send is refused, a user's storage is
capped across sessions, new sessions and zip exports are paced, images are
stored uncompressed in the zip. One liveness rule, one session view, one image
type table.
Logging: a session's elevation is tracked apart from the user's own, so the
browser's own requests (no header) no longer lower the floor a session needs;
Stop ends it at once.
Composer: an image pasted anywhere is taken; what is still unsaved is saved on
close, and "saved" is shown only when it is; the draft is held while it is
sent; a failed screenshot read says so instead of reading as none; focus moves
to what stays; the preview follows the text size. Pill: the step prompt and a
running Stop survive the pill moving between hosts, the prompt keeps the
dialog's Tab trap, the toast region lets clicks through, other tabs hear of a
Start or Stop, the last seconds are sent before the cap, a capture with no
frame is an error.
- admin config group "Bug reports": Gitea address, repository, token (secret),
and "Check the connection" — the repository must answer, take issues and
be PRIVATE (a report carries its reporter's log); checked on every send too
- POST /api/debug-sessions/{id}/issue sends the SAVED draft: create the issue,
upload each file on its own request (the measured API), then show the
screenshots inline; progress is kept in the draft, so a send that stops
half-way keeps its issue and the retry sends only what is missing; 5 new
issues an hour per user; audited as issue_create
- the composer: Create issue (only where a tracker is set up, naming the
repository), a partial-send banner with retry, and a Preview — the tracker's
own rendering in a sandboxed frame, or the zip's markdown without one
- the recorder's refusals answer in the localized family (debug_session_*,
screenshot_*, issue(s)_*), not bare codes the UI could only call generic
- a tracker refusal logs the status only: its message can quote the title back
After Stop (or at the cap, or from Settings → Diagnosztika's past sessions)
one dialog drafts the issue: title, description, the steps drafted from the
session's markers and page changes (a marker without a label is named by its
time), the environment (version, browser, system, window, language). Beside
it, what goes with it: the browser and server logs — each reviewable line by
line, an unticked line is not sent — and the screenshots, removable, with
paste / drop / pick to add more.
- it saves as it goes (debug_sessions.draft, migration 0090): the rendered
markdown in the user's language, the excluded rows, and its own fields to
reopen as it was; a sent session is read-only
- the zip export (issue.md, client.log, server.log, screenshots) is built
from the saved draft by app/issues/bundle.py — the one definition the Gitea
delivery (S4) will share; .log, not .jsonl, which Gitea refuses
- the log line's level tags and clock move to components/logLineParts so the
review list draws a level the way both log viewers do
- the start dialog is the consent: level (Részletes/Teljes), what is recorded,
retention and the cap (now served as diagnostics.session_minutes)
- the pill docks under the toasts: elapsed time, Step (an optional label,
its own Escape layer), Screenshot (tab capture where the browser has it),
Stop; compact on a phone
- while recording, lib/log tags every event and every request path (req,
beacon, uploadReq via one identityHeaders) names the session; applyDiagnostics
raises the level for the session's run and never lowers a raised one, and
lapses at whichever ends first
- Stop sends what is waiting before the server closes the session; the cap
says so and opens the composer
- Settings → Diagnosztika and the command palette start/step/stop it
- the evidence leaves the log upload's own request lines out
- the step shortcut is deliberately palette-only: the keymap's D1 rule bars a
new modifier chord, and a bare letter collides with the players'
A session is one recording, capped at 60 minutes by the server. While it is
open, a request naming the caller's own session in X-Debug-Session is logged
at the session's level with every line tagged; a header naming anything else
is ignored. The browser's batches count a session as consent to its level and
keep the tag only on its own events. Screenshots are judged by their bytes,
capped in size and count, and served back to their owner only.
- debug_sessions / debug_screenshots tables, one open session per user
(partial unique index), log_events.debug_session_id indexed
- /api/debug-sessions: start (joins a running one), stop, list, get, events,
screenshots; /api/me reports the live session
- the hourly log job closes expired sessions and drops them with the logs
- logsink.serialize and routes._common.capped_body shared by their readers
- The palette launcher's name replaced its visible "Jump to…" (and the key cap's its "Ctrl K"):
a voice-control "click Jump to" matched nothing (WCAG 2.5.3). Each name contains its text now.
- SegmentedToggle's `title` was only a tooltip on a non-focusable box — the live search's "how
many per batch" hint reached no keyboard or screen-reader user. It is the group's accessible
description, standalone and captioned alike.
- The unused-key contract let a template like `card.${status}` cover its whole namespace, which hid
five dead keys (deleted). Dynamic keys are matched by shape (`editor.*.hint`); a bare namespace
shape covers nothing, and the audit/quota namespaces are now held both ways by backendIds.
- CLAUDE.md's "FE control" row matches the lint rule it cites; each no-restricted-syntax entry has
its reason directly above it.
The module shows fewer lists than youtube.com, and without a word about it that read as a Siftlode
bug (the owner's ask, 2026-09-03). Owner's pick from the S7 mockups (2026-09-19): one quiet line at
the foot of the rail, "Missing a list? Why?", whose Why? opens three facts — YouTube's own Watch
later and History are closed to its API (the Watch later list here is Siftlode's own), only lists
you own sync, and an account with no YouTube channel has no playlists at all.
Ctrl/Cmd+K and the shortcut sheet had no entry point but a palette row, behind the other
undiscoverable chord. Owner's pick from the S7 mockups (2026-09-19): a key cap at the end of the
header's search field opens the palette, and a page with no search field shows a small "Jump to…"
pill with the same key cap in its place, so the entry is on every page. The cap reads the chord the
palette row binds from the keymap table (Ctrl K, or ⌘ K on a Mac) and stays out of a phone's
header, where there is no keyboard to press it on.
dnd-kit's screen-reader text is hard-coded English and names an item by its id ("Draggable item
10357 was dropped", a role of "sortable"). Only the YouTube playlist replaced it; the Plex playlist,
the saved-views list and the side-panel groups read it out under a Hungarian UI — found while
checking the Plex playlist's keyboard reorder on localdev.
`ui/sortableA11y` is now the one place: `useSortableA11y` gives a DndContext its instructions and
announcements (HU/EN, `common.dnd.*`), `useSortableItem` announces the role. The YouTube playlist's
own announcements move onto it, which also fixes them: its lists reorder on drop, so reading the
dragged row's own index said "moved to position 1" at every step and "dropped at position 1"
wherever the row went. The place announced is now the slot the row would land in.
A `local/sortable-a11y` lint rule (with its contract test) refuses a DndContext without
`accessibility` and a direct `useSortable`.
A contract test scans the source (tests excluded) for each key as a whole literal, or under a
dynamic prefix like `plex.sort.${k}`, with plural forms counted through their base key. It found 75
keys that had outlived the code showing them (the old sidebar sort vocabulary, the old login page, a
pager the DataTable replaced, …); they are gone from EN and HU. A key read in a way the scan cannot
see goes in an explicit ALLOWED map with its reason.
- The Plex playlist rows' "reorder"/"remove", the Plex filter chip's "remove" and the player
slider's "reset" were English literals; each now names its row or setting in HU/EN.
- The playlist show header's chevron had no accessible name; its decorative thumbnail button left
the tab order.
- The opt-in Glass tuner panel was English only: every word is translated, and each slider's label
now names its number box and range.
- New `local/literal-a11y-text` lint rule (with its contract test): a literal word in aria-label,
title, placeholder, alt and the other spoken/shown attributes fails lint.
- During a budget switch the rail shows the previous budget's payload
(keepPreviousData) while the new key is empty; the move read that empty
cache, found no tile and silently did nothing. It now acts on the order
the rail rendered.
- A refused move was undone only by a re-read; when that read failed too,
the shelf kept showing the order the user had just been told was not
saved. Moves are now written one at a time (the move rows wait, with a
"saving" note), which makes restoring the shown order correct — the
overlapping-moves problem that ruled a rollback out cannot occur.
- PriorityStepper labelled itself with channels.row.* and told every caller
it orders the Feed; Combobox said plex.loading / plex.filter.moreRefine /
sidebar.match on the feed sidebar. The generic strings moved to common.*,
and what a priority MEANS arrives as the stepper's `hint` prop.
uiI18n.contract.test.ts pins components/ui/ to the platform namespaces —
the import boundary could never see an i18n key.
- 32 px-frozen box utilities (w-[150px], max-w-[1600px], left-[18px], …)
became spacing-scale steps (w-37.5, max-w-400, left-4.5): identical at the
default text size, and now they grow with the Settings slider instead of
clipping or re-wrapping their own labels. An eslint rule refuses the px
form beside the older text-[Npx] one, pinned by pxBox.contract.test.ts;
measured in the browser that every new step is generated at its old px.
- chipLane's import-strip regex ran past a side-effect import into the next
statement's `from`, hiding a lane between them; it now stops at the
import's own `;`.
~150 HTTPExceptions carried a hand-written English sentence as their detail,
shown verbatim under the Hungarian UI — in the blocking error dialog and on
every inline surface that printed HttpError.detail (sign-in, setup wizard,
change password, the passphrase gate, live search). Four also quoted an
upstream exception's text back to the user.
- app/refusal.py: `refused(status, reason, **params)` → {code: "refused",
reason, …params}; localizeDetail translates errors.refused.<reason> with the
params interpolated. "Unknown X" 404s use the existing not_found(what).
- sysconfig.set_value raises InvalidSetting (reason + bounds) instead of an
English ValueError; the three writers answer it through setting_refused.
- The Plex connection/write/image failures log what the upstream said and
answer a reason; the search scraper's failure is logged and answered as
youtube/unavailable (it crashed refusal_reason before).
- HttpError.detail now only ever holds a translated reason: a string detail
(only the framework's own can remain) is left out, so reading .detail is
safe everywhere — including destructured, the hole the raw-detail source
scan could not see. That scan and its five-file exception list are gone.
- Enforced: test_http_details.py fails on any HTTPException with a sentence
for a detail; the contract test requires every refused reason and every
not_found "what" in both languages, and no key for one that is gone.
- local/jsx-comment-text: a // inside JSX children renders as text; the rule
is ten lines in eslint.config.js rather than eslint-plugin-react's ~1800
lockfile lines.
HLS sessions were keyed by the item, so a second account opening the same
film killed the first one's session; the displaced player reclaimed at its
playhead and evicted the other in turn. The registry is now keyed by
(viewer, rating_key), segment requests resolve by both, and the session
directory carries the viewer (the orphan sweep still recognises the older
shapes).
Only transcodes counted toward a cap, so remuxes — each a whole segmented
copy of the film on the scratch volume — were bounded by the library size.
Three bounds now apply after every start, each dropping the least recently
used: two sessions per account, the transcode cap, and a new admin knob,
plex_max_sessions (default 8), over every session. A superseded session
request returns at once, since it waits on the killed ffmpeg, so an account
cannot park more request workers than it may hold sessions.
The subtitle extractor's full-pass slots were global and minutes long each;
one account opening subtitles on film after film could hold them all. An
account may now hold three of the six, and still gets windows past that.
move_item checked "already in the target?" and then re-parented without a
lock, so a second tab's add or the scheduler's YouTube merge committing the
same video in between hit uq_playlist_video and answered a raw 500. The
re-parent now runs in a savepoint and a conflict falls back to the
already-present outcome. The target is touched only when the row landed in
it (an untouched list was promoted in the rail's "recently changed" sort),
and _touch stamps the database clock like the mixin's own onupdate.
The canonical-patterns table names owned_or_404 against ad-hoc ownership
helpers; six were left (_own_playlist, _own_playlist_or_404, _own_view,
_own_tag, notifications._owned, downloads._own_job), two of them
hand-rolled with a bare English 404. All call sites now use owned_or_404,
and the download/link/notification 404s are structured like the rest. The
contract test reads every "what is gone" the routes name, and the YouTube
refusal reasons, and requires both languages to name each.
The two manual syncs (subscriptions import, playlist pull) had no YouTubeError
handler, so a quota, token or transport failure was a raw 500 and the blocking
"Server error" dialog (E9 F1). The other YouTube routes answered either the
upstream's English or a playlist-scoped "youtube_failed".
One helper, routes/_common.youtube_refusal: 422 {code: "youtube", reason} with
the reason sorted by what the user can do — quota (wait for the reset),
reconnect (the token step refused), unavailable (retry). Used by every route
that surfaces a YouTube failure; localizeDetail translates it.
"Reconnect" had no path: a grant Google revoked still read as granted, so
Settings and the wizard offered no connect button while every call failed.
An invalid_grant from the token endpoint now clears the stored grant.
The card menu's reset read 'Clear watch progress' but also un-hides and un-watches; it now says so.
The resettable rule, the demo-first role key and the display-name fallback each lived in several
components tied by comments; they are shared helpers now, and the menu reuses the card's own
strings instead of duplicate keys.