Commit Graph
966 Commits
Author SHA1 Message Date
peter ceadf69e43 fix(i18n): hold every refusal reason by type, and refuse what the scan cannot read
Review round 1 of the refusal contract: it claimed more than it held.

- The other reason carriers get their Literal too (BlockedUrl, ClipError,
  FolderError, the playlist and sign-in refusal helpers), so the type
  checker refuses a reason passed by variable or an unknown literal, and
  the contract reads one shape.
- A literal reason in a detail dict built anywhere counts as a reason of
  its code; a code the scan cannot read, a refused() whose reason it
  cannot read, and a new reason passed by variable each fail the contract
  until listed with what holds them.
- A sign-in refusal key the backend no longer sends is caught too.
- localizeDetail's numbers (download cap in GB, upload cap in bytes) and
  the shared-sentence fallbacks are tested in both languages.
- One Literal parser in the contract; a stale comment fixed.
2026-10-31 01:00:26 +01:00
peter 4379f76747 feat(i18n): every server refusal is said by a record the contract holds
A refusal code or reason with no sentence fell through to the generic
"couldn't carry out that action" unnoticed: the parity test is green when
EN and HU miss the same key. One did (an edit refused for a file that is
not video or audio).

- lib/api/refusalCodes.ts: which sentence answers each code, as data;
  localizeDetail says a refusal through it, unchanged for every reader.
- backendIds contract: every detail code the backend sends is in exactly
  one record, every reason it can carry has its sentence in EN and HU,
  and no family names a reason the backend cannot send.
- The reasons that travel in a variable are Literal types (edit, move,
  cookies, quota), so the type checker refuses an unknown one too.
- downloads.errors.edit.not_media in EN and HU.
2026-10-30 23:36:25 +01:00
peter 2bdcebdb34 fix(notifications): a confirm names its user and channel again; t() and a notice's text are kept apart by a contract
The value sweep wrapped two plain t() values in raw(), so the delete-user and the YouTube subscribe
confirms read "[object Object]" where the email or the channel was. tsc cannot see that seam, so a
syntax contract now refuses a notice value handed to t() and an already-said text kept in a notice.
The raw() doc says what it is: text kept as fetched (a Plex title in the language it was fetched in).
seasonTitleText's no-number branches are tested.
2026-10-30 01:54:00 +01:00
peter 046dde44a4 test(notifications): the center re-says its rows after a switch, a repeat coalesces by content; e2e helper fails with a cause
The center's rows (title, message, refusal, live button) were never rendered across a language
switch, and nothing held the structural compare that coalescing now needs. The channel-link helper
swallowed whatever ended its poll into a bare false: a link that goes away mid-look now counts as
not hit, and no hittable link fails with the poll's own message.
2026-10-30 00:17:46 +01:00
peter 74f58ea001 fix(notifications): the English log formats its numbers in English; the ui/ contract sees msg() keys
A formatted value took the reader's language even when the notice was said in English for the
log. The platform-namespace contract matched only t("…"), so a domain key passed through msg()
in components/ui/ went unseen. Three comments pointed at localizeDetail's old home.
2026-10-30 00:15:22 +01:00
peter b515c16117 fix(notifications): a notice's button is said when shown, like its text
An action label was translated when the notice was raised, so after a language switch a live
toast or center row read in one language with its button in the other. The access-requests nudge
no longer re-issues on a switch for it. New tests: the button and a server refusal follow a
switch.
2026-10-30 00:10:38 +01:00
peter 4a0d4b0964 fix(notifications): a notice value is no plain string, so a translated name cannot freeze in one language
A name goes in as raw(), a translated word as its own msg(): tsc now lists every value, and the
ones that froze are said when shown — Watch later and an untitled channel in the pin and channel
notices, a Plex season's name in its removal toast (a new seasonTitleText, which seasonTitle says).
2026-10-30 00:03:48 +01:00
peter cb8b99e0e7 fix(notifications): keep the history under a key of its own, so an older build never reads its new shape
A build from before the change renders history rows as text; an object there crashed its
Notifications page after a rollback. The old key is read once while the new one does not exist,
and never written.
2026-10-29 23:54:48 +01:00
peter 4ef772d37b fix(notifications): a notice keeps what to say and says it in the reader's language when shown 2026-10-29 22:37:14 +01:00
peter 91256b10b0 test(nav): pin the restore's last look and the clearing of the name it looks for 2026-10-29 01:31:41 +01:00
peter e3a4593acf fix(nav): Back looks for a moved item only in the page's lists, once its own list has had its chance, and its reveal stops the scroll restore
The cross-list match took a same-named row of the app-wide download tray, and took it at the first look while a virtualized list had yet to render the item. The reveal of a moved item ran before the scroll restore's first frame, which put the old offset back over it. The pending restore's name is readable, for a page that folds rows away.
2026-10-29 00:47:52 +01:00
peter fa271a31b9 fix(notifications): Mark all read waits for the fresh list, is not offered where it cannot act, and a row says it is unread
The mask over a stale read lifted when the write settled, before the refetch landed, and the band took the rows back. A band holding only what asks by its kind (an access-requests nudge) left the button enabled with nothing to do. The unread dot was only drawn: the spoken label now says it, apart from the name Back finds the row by, and follows the row while it holds the focus.
2026-10-29 00:38:34 +01:00
peter 22d873ed49 fix(nav): an item Back finds in another list is brought into view 2026-10-28 22:04:07 +01:00
peter c86ec7f7a5 fix(nav): Back finds an item that moved to another list by its name 2026-10-28 21:54:00 +01:00
peter f49dc6532f feat(notifications): what asks for you comes first, then one stream; old read rows fold
The center showed two fixed sections, the server's above this browser's, so an
admin's "access requests waiting" sat under every server row, week-old read
"Download ready" notices among them. One order now, for both sources
(lib/inboxOrder): "Needs you" (unread, an access-requests nudge, a notice
waiting on an interaction), then everything else newest first, and read rows
older than a week fold under "Earlier (n)", closed by default.

A row that asked in this visit keeps its place once read: marking it read no
longer moves it to another list under the reader. Mark all read empties the
band, the client notices of the visit included (the server rows read at once,
not when the write lands). Marking a row read from its button hands the focus
to the row instead of dropping it on <body> as the button goes.
2026-10-28 20:01:21 +01:00
peter 0d410592e2 fix(plex): a stale finish refusal no longer sticks, and a refusal keeps the player's detail
A refusal that landed after a replay raised the refusal flag for the finish that watch made later,
so leaving skipped the watched patch. Each finish now resets the flag and a refusal acts only for
the finish it answers.

A refusal while the player still sat on the item (the up-next card) removed the detail query it
observes, dropping the card and restarting its countdown, or losing it if the refetch failed. It is
now refetched under the player and dropped only when the player leaves.
2026-10-28 19:05:24 +01:00
peter 5686759bca fix(nav): a page settling keeps a reload's overlay entry; an old entry's sub-view goes to its own (review round 4)
- useUrlMirror: a change before the user has done anything since the load is the page settling (the
  rail stepping off a playlist deleted since, Files off a folder gone), not another view: the reload
  mark stays, and the player that reopens takes its entry over — one Back closes it, as before.
- useHistorySubview: an entry an older build wrote is a module's only when its _sub is the view the
  address names under that module's key (the card's push kept the season's `v` beside its `m`).
- Playlists: the rail's default selection and the page's "the rail moved there" read one helper.
- moduleUrl contract: writer literals in a choice's branches are scanned too; the sub-view census
  walks type arguments of any depth and refuses an aliased import.
- A stale comment on the nav press over a live search.
2026-10-28 01:39:26 +01:00
peter 99a33e7fd1 fix(history): a sub-view reads only its own module's from the entry it mounts on
The entry's _sub now names its owner (_subOf, the module's address key). Signed out on a Plex season,
the sign-in card took the season for one of its modes and rendered a card with an empty button; the
other way round, Plex took the card's register view for one of its own. An entry an older build wrote
is read as the module's only when its address names one of that module's views.
2026-10-28 00:29:40 +01:00
peter 02906d0acb fix(nav): the address keeps what the app rewrites around it (review round 3)
- The Feed press over a live search ends only the search: the feed's own view stays named.
- Leaving a search steps back only off an entry the app pushed (_ytOwn); one a link or a reload
  opened is cleared in place, also where the Navigation API is missing.
- useUrlMirror rewrites a changed view through replaceView, so a reload's overlay marks go; its
  first write and its landing writes keep them for an overlay that reopens.
- Files and Playlists: a box emptied and filled again settles at once, never naming the ended search.
- Playlists: a linked player is scoped to the playlist the store started with, and only the rail's
  own move to the first playlist is no pick.
- Contract: writer objects are scanned to their matching brace, a computed or shorthand key is
  refused, and every useHistorySubview call hands a named definition.
- Lint: playwright/no-skipped-test also refuses fixme (gate snapshot regenerated).
- FEED_DEFAULT_SORT at the three remaining sites; linkedNotListed says to search by name.
2026-10-27 23:33:22 +01:00
peter 6d57d62ac8 fix(nav): a linked player, file and search survive what the app does by itself (review round 2)
- Playlists: a link over a playlist the account does not hold plays alone; the rail's own
  fallback no longer counts as a pick that gives the player up.
- The linked opener opens or clears nothing once the address names another player, and stops
  looking (and fetching its row) once it settled.
- Files: a list the server's cap cut — the sorted or searched one too — keeps the link, with a
  notice of its own (EN/HU).
- History: an in-place view rewrite (search refine, count, channel to channel, sub-view)
  drops a reloaded entry's overlay marks (replaceView); a landing on a channel opened from a
  search keeps the search; the Feed press on a search opens the plain feed.
- Files/Playlists: an emptied search is written at once.
- Client log: plf/cf (column text filters) are typed keys; the shell has none of its own.
- Contract: writers outside lib/ take a literal or a lib/ function's result; every URL key is
  sorted typed or not; the writer regexes are held by a fixture; sub-views named; the bookmark
  net reads page tags.
- Feed defaults in one constant; one live-search queue helper; Scheduler writes through the
  Activity codec; notRun copy (EN/HU) names Forward and a plain "Search YouTube" button.
2026-10-27 16:08:52 +01:00
peter 0d5c6273f1 fix(nav): the address follows the view through overlays, reloads and Back (review round 1)
- A URL mirror names the view again on every entry Back or Forward lands on, so a change made
  under a dialog, a drawer or a sub-view is not undone by the next reload.
- A view pushed from the entry a reload under a dialog started from no longer inherits its overlay
  mark; a reload under two overlays closes onto the page in one step.
- The linked player reopens over a live search, gives itself up when its list moves first, and
  plays alone over an emptied playlist; a capped Files list keeps the link it cannot resolve.
- The Feed press over a channel ends the search; leaving a search ends its asked mark; Files and
  Playlists searches end with their page; the feed always names its view and debounces its search.
- Search-box keys are declared per module and kept out of the client log below TRACE.
- Writers outside lib/ go through codecs; the URL contract scans computed keys, overlay writers and
  two-line reads, and holds every page to a bookmark round trip (Config, Activity, Playlists and the
  Files sort and viewer added).
2026-10-27 13:52:42 +01:00
peter 7b4e007680 feat(nav): the live YouTube search and Home's budget are named in the address
A YouTube search is `?yt=<term>` (+ `ytn` for a non-default count), so a reload, a link
or Back/Forward reopens it. It runs by itself only where it costs no quota: the free page
scrape, a search asked for in this document, or results still cached. An API search
reopened any other way shows its term and waits for "Search YouTube again".

Home's time budget is `hb`; the question's draw stays on the history entry.

An e2e spec opens every page's bookmark in a fresh browser context; CLAUDE.md and
docs/development.md name the module URL codec as the place for view state.
2026-10-27 11:51:29 +01:00
peter 3d26ca0efa feat(player): a bookmark reopens every player as it was, once
The Plex player names the playlist it plays through (v=player:<id>:playlist:<pid>), so a bookmark
rebuilds the queue from the playlist's own order while the item is still in it.

Found in the browser and fixed:
- after a reload the shell's player opened a second one over Home and the feed: it read the catalog
  row the page had fetched (a disabled query still reads the shared cache); it now waits for ready.
- every reload under an open overlay left a dead history step: the first overlay to open takes over
  the entry the document was loaded on.
- the feed's address was implicit, the same as the start-page landing, so a reloaded or bookmarked
  feed opened as Home: the feed now names itself (page=feed) like every page.
- a linked file or video that is gone was said, but stayed in the address and was said again on
  every reload: its keys are taken off.

The overlay address is written in one place (the entry is named once it is current), and the player
keys only the codec reads are no longer exported.
2026-10-27 07:33:55 +01:00
peter fa893c73a3 feat(player): the YouTube player and the file viewer are named in the address
The player names what it plays in its own history entry (watch, wrel, wtab): a reload or a
bookmark reopens it on the page that hosts it, with that page's list as the queue, or alone
from the catalog (GET /api/videos/{id}/row); a page that hosts none gets the shell's. Back
lands on the page's address without it (useBackToClose's url; ui/Modal passes it on). The
Files viewer and player do the same (fview, fplay). The Plex and channel-manager views are
always written in full, as their fallback is remembered state.
2026-10-27 05:29:49 +01:00
peter d4f24de4ba feat(nav): tabs, list views and name searches are named in the address
Settings, Configuration and Users name their tab (stab/cfgtab/utab); a link's tab wins and a tab pre-selected
while its page is on screen now switches it. A playlist's sort, filters and grouping (plsort/plf/plgroup), the
Files sort (fsort, always written) and the name searches of Channels, Files, Playlists and Plex (cq/fq/plq/pq+pqf)
reopen from the address; the searches are written once the typing pauses.
2026-10-26 01:13:39 +01:00
peter 91d36b3357 fix(channel): the channel page's address names the channel and its own view, not the page under it
Opening a channel kept every key of the page under it (`?q=music&sort=oldest&chan=…`, a sort the
channel did not show), and the channel's own tab, sort and content types lived in its state alone, so a
reload or a bookmark lost them. Now the channel's address is `channelUrl`: the shell's keys and `chan`,
shared by `openChannel` and the channel link's href. The page writes its view (`chtab`, `chsort`,
`chtypes`, lib/channelPageUrlState) and reopens it; these are shell keys, cleared by a page switch and
by closing it in place. While a channel stands over a page, no module store writes its keys (`urlPage`);
Back finds them on the page's own entry. A link written by an older build opens without the page's keys.
2026-10-26 00:19:02 +01:00
peter 9bd7304bb9 fix(plex): a reload during a binge reopens the episode playing now
The player stepped to the next item in its own state while the history entry still held the item it
opened with, so F5 restarted the first episode. The player now says every item it moves on to, and the
page rewrites its entry with it (useHistorySubview's `follow`: no navigation, the overlays over the
player stay open, and the entry under an overlay is rewritten when a step lands back on it). The
player's URL is the item playing (`?v=player:<id>`), so a bookmark opens it; the list it plays from
stays in the history entry.
2026-10-25 23:24:29 +01:00
peter b30c60d1a5 feat(nav): each module declares its URL keys; a page switch clears them and a page opens without another's
The shell cleared a hand-kept key list on a page switch (it missed the Files and feed keys) and cleared
nothing when a page opened from a link carrying another page's keys. ModuleDef.urlKeys now names every
key a page's view writes, the shell derives both from the registry, and moduleUrl.contract.test holds the
declarations true: one owner per key, no shell key, exactly what each codec writes, and no undeclared
key literal in the app.
2026-10-25 20:48:14 +01:00
peter 0bc1024287 fix(player): marks are weighed when answered, lit and pressed hold under the pointer, and Restart is one glyph
The watched toggle weighed its own click at click time against marks stamped when the server answered, so the late answer of its previous click overruled the newer one; it now holds its word while any of its writes is on the way, is stamped at the answer, and drops a refused write. The hosts return the write. In a live search the feed's own answered writes stand down over the rows, which carry the marks, so a newer mark from the related list reaches a card the feed touched earlier.

A lit mode cycle got the hover layer, and a variant's hover colour outranked the pressed and lit colours: the state layer stands down on [data-lit] and those colours are important. The menus' Play from the beginning wears RotateCcw like the card's Restart, Reset wears Eraser and Redownload the retry arrow (DESIGN decision log). Tests pin the toggle's orders, the Watch later mark at its call sites, the live-search overrides, the hover exemptions and the menu glyphs.
2026-10-25 13:51:26 +01:00
peter 84db570951 fix(player): marks give way to newer lists, Watch later items mark, and auto-advance has its own glyphs
A session's marks are weighed per field against when each page of a never-refetched list was read:
a page read before the mark (a Show more / Load more on its way) takes it, one read after keeps the
server's word. The live search lays them over its rows too, not only the related list.

Items of the built-in Watch later list ARE the bookmark, so adding, removing and moving them go
through features/video/videoWrites and mark the video; the contract test covers those writes and
catches a write that is named without being called on the spot.

The player's watched toggle follows a mark confirmed elsewhere since it opened (the related row's
menu, a notice's Undo); Restart on the related row after a description link goes back to it.
The file viewer tells the help sheet its keys wait under a layer; refusalReason replaces the last
two open-coded reads.

Auto-advance wears list glyphs of its own (the skip arrows are Previous / Next beside it), and the
auto-advance and loop cycles are `lit`, not toggles: no aria-pressed, the name says the mode. Both
places render from one list of controls, so the docked tooltips name the mode too. The English cap
message and admin hint quote "Show more"; the hint says refunds are limited.
2026-10-25 04:01:43 +01:00
peter 4b5b66343d fix(player): related marks survive, Restart restarts, and refused pages offer nothing to click
- Every state and Watch-later write goes through features/video/videoWrites,
  which marks the never-refetched lists (related lists, live searches) once
  the server has it; a contract test holds it. The Notification Center's
  undo, the feed's reset and the Watch-later toggles skipped it before.
- Confirmed marks are also kept apart (qk.videoMarks) and laid over the
  related rows: a Show more page landing later rebuilt them from the pages
  it read when it started, and dropped a mark made meanwhile.
- Restart on a related video starts at 0 (the session's stored position
  won before), and on the one already playing it seeks.
- The watched toggle remembers each video's mark: Back showed the opened
  video's launch-time state after a related one was marked.
- The help sheet leaves out the player's keys while a layer above it has
  them waiting.
- After a refusal a retry cannot answer, neither Show more (related) nor
  Load more (live search) is offered; a failed live-search page keeps the
  results already read and says why under them.
- The related cap message counts requests, as the cap does.
2026-10-25 02:50:34 +02:00
peter b5045165c0 fix(player): a related video played is tracked, and its list is honest about its state
A related video played in place is the player's item now: its title and its own watched /
playlist / download controls, its progress checkpointed and resumed, marked watched at its end —
and its end does not step the queue. Back returns to the opened video, where it was left.

The related list:
- its marks announce through the shared notice (the Notification Center finds them), join the
  host's player session (the feed refetches on close), and stay per write, so an earlier answer no
  longer undoes a later mark still on its way;
- every host's state write patches the cached related lists, which are never refetched;
- kept for the session (gcTime), a retry asks for the page that failed, a list read so far with
  nothing unseen says so, a refusal a retry cannot answer offers none, and the followed mark names
  itself on hover.

Turned off on the instance (a limit of 0), the tab, the drawer button and the live-search buttons
are left out.

Keys: the player's keys and its focus reclaim defer to a layer above it (the drawer); going wide
moves the drawer's list into the panel's tab; `j` goes on from the slot of a row removed inside the
top layer; the help sheet lists the list keys over a layer that holds a list.
2026-10-25 00:32:28 +02:00
peter 7d45c4f59f fix(keys): a list inside the topmost layer walks by keyboard
The list keys are the page's and an open layer silences them, so the
player's related videos could only be reached by Tab. A layer now names
its root element; with the focus on a row inside the topmost one, j/k,
the arrows and Enter walk that list, the walk stays inside the layer,
and the player's seek and volume arrows leave a focused row alone.
2026-10-24 22:45:26 +02:00
peter 686c673fb2 feat(player): a video's related videos, on demand beside it
The player shows what YouTube lists beside the video, without what the
user has watched or hidden. Wide, a Related tab beside the description;
stacked, a button beside the info opens them in a drawer. A row plays in
the same player (Back returns) and the list stays the opened video's.
A switch picks on-topic or Hungarian-trending lists, kept on the account.

Each related row says whether the user follows its channel; the row is
the video card's narrow form with its whole menu. VideoMenu moves to
features/video, where the player can reach it.
2026-10-24 16:57:52 +02:00
peter 33dd9ff054 fix(reader): the watched-channels list keeps its channels, its shares and its retries
- A channel someone follows: the videos its exploration brings in (the list's
  dialog, the channel page's explore) come in as the catalog's own, not
  flagged via_explore, which subscribing would never clear and which kept
  their watched marks waiting for good.
- The explore cleanup keeps the row of a channel a Takeout history shows
  watched (it still reclaims the videos), so a looked-at channel no longer
  drops off the list.
- The Shorts job takes up only users whose list still has lengths to ask or
  channels to name, by each user's own follows, blocks and dismissals; a
  naming that failed is tried again, and the list says it is not complete.
- The length lookups have a daily share of their own: a large history no
  longer leaves the export's Watch later file waiting a day.
- Only a channel of the user's own history can be put out of the list.
- The dialog's videos have a query root of their own: the list's
  invalidations no longer re-ask (and re-explore) under an open dialog.
2026-10-24 01:10:29 +02:00
peter b5f9cf14db feat(channels): a "Discover from your history" tab for the channels watched but not followed
The Channel manager lists the channels the Takeout history shows watched
often but not followed, the most watched first: twenty at a time, the
channel's About on hover, its name to its page, Follow, and Not interested
with an Undo. A row's count opens the videos it counts, ready to play one
or all. Before a history is uploaded it points to the upload; while the
import still asks YouTube it says more channels may join.
2026-10-24 00:00:50 +02:00
peter 9f580fd8c8 fix(reader): the Takeout import holds up under abuse, races and a run it cannot see
- Uploads: one at a time per user and ten an hour, each refused only after its body arrived;
  the history read an entry at a time (32 MiB cap), a Watch later list capped at YouTube's 5000,
  a user's kept videos at 200k; any unreadable shape is a 422, not a 500.
- Quota: Watch later lookups stop at the backfill reserve and at a per-user daily share; what is
  not asked about waits, known videos still go in, and the shorts job retries the rest.
- Staging and settling share one per-user advisory lock (no deadlock); a run settles only the
  rows it read, so a row staged meanwhile is never called gone.
- A failed run's notice names a translated failure, never the upstream's text.
- The status says when an import is queued; the page watches it to its end even when left, then
  reads the feed, its counts and the playlists again.
- Settings: one live region, the zone kept (unavailable) while a run goes, focus handed back,
  a missed drop kept from the browser, an oversize file refused before it is sent.
- Scheduler and run lists show Takeout runs by the importing user.
2026-10-23 20:48:23 +02:00
peter e0b02a0c14 fix(inbox): a Takeout import's notice opens the user's own import
The import's notice reaches a user who may not see the Scheduler, an
admin page, yet its link opened it. It now opens Settings › Account,
where the import's status is; any other run still opens the Scheduler.
The job id is held to the server's by the backend-ids contract. The
notice's Hungarian names the list as the app does elsewhere.
2026-10-23 19:15:06 +02:00
peter 3553eac8c4 feat(settings): upload a Google Takeout export in Settings › Account
A "YouTube history" section under YouTube access: the watch history and
the Watch later csv are dropped or picked (one request each, each file's
outcome said on its own), and the section then says what the import did
- only the counts it produced - or that it is running, failed or was cut
off by a restart. While it runs it asks again until it ends; a failed
re-read shows the failure, not the previous answer.

The drop zone the Files upload had inline is now ui/DropZone, used by
both. The e2e seed clears the account's kept Takeout videos.
2026-10-23 18:51:05 +02:00
peter 38c71b7b4f fix(plex): a server that restarts mid-episode is waited out, not reported as a stream that cannot play
During a deploy the gateway answers 502 (or nothing answers at all) until the app is back. The
player took the first such answer for a broken stream: it said "Playback couldn't start" over a
picture still playing from its buffer, stopped loading, and never asked again, so the episode froze
where the buffer ended with the error still up. Now a 502/503/504 or no answer is the server being
away: the buffer keeps playing undisturbed, the session is asked for again where the viewer is
(2, 4, 8, then every 15 s), and the error is said only after three minutes away. A seek or another
item replaces a pending retry. Measured on localdev: a 45 s API stop now resumes at the viewer's
position, where it froze with the error before.
2026-10-23 14:42:17 +02:00
peter b443ac0c84 fix(frontend): the layer gates cover what the move left outside them, and each page keeps its own logic
Gates the folder move had narrowed or left open: the MenuCheck contract scans the whole app again (it
saw only components/); local/layers refuses an import of an entry (main.tsx, the /watch page) from
any layer, and reads a backtick import() as the path it is; the /watch contract forbids the shell,
the modules and the features by folder (its one path to App had gone stale); chunkGuard's own test
gets the vitest focus/skip rules. The root tests' exemption from local/layers is in the gate
register, with its three imports.

Placement: Plex's chip builder moves into lib beside the feed's, the folder names into
features/folders, and three files only one page uses into that page. ui/FilterRail takes its reset
from useRail instead of a page id. The channel page reads the feed's view and builds "Show in feed"
itself, and the feed's scroll key is the feed's hook, so the shell holds no feed logic.

The floating header re-measures when one page's pills replace another's in the same commit (the
claim count stayed equal, so only the observer caught it). A Plex view applied and edited now stays
across a visit to another page, as the feed's does: pinned by a test. docs/development.md says what a
page with a rail adds in lib.
2026-10-23 13:45:40 +02:00
peter ad05932217 docs(frontend): the canonical table and the ui README name the module shape; adding a page is written down
CLAUDE.md gains the header-content, module-store and saved-views rows. docs/development.md says what a
new page is and which tests hold it. components/ui/README.md names every file in the directory, and a
contract test keeps it that way: seventeen had slipped in unlisted. copyItems' comment still claimed it
was a feature file.
2026-10-23 12:52:04 +02:00
peter 6ae7825a14 refactor(frontend): the module stores mount from the registry, and the feed and Plex each own their saved views
A module's definition names its store (feed: filters, view, saved views; Plex: browse state, saved
views; channels, files, playlists their provider), and ModuleStores mounts them at the root in
registry order instead of a hand-nested tree in main.tsx. A contract test holds that every store's
read side is in reach and that no store's static imports leave its own module, so the order cannot
matter.

Saved views were one provider that switched between the feed and Plex by the active page and reset
on each switch. The logic is now one hook (useSavedViewsStore) with an instance per module; the
shared chip, popover and modal read the views their page or rail hands down. A feed view applied
and edited now survives a visit to Plex. Plex's views are still asked for only on its page, and a
module's manage modal still closes when its page is left. The feed's URL-override check applies
only on the feed page, where its filter params live.

The Channels store's tab type moves from the page into the store's context.
2026-10-23 04:59:10 +02:00
peter cde5a98fd2 refactor(frontend): each module defines itself in its folder; rails load lazily and keep their own state
A page used to be listed three times (lib/modules for its label, icon and gate; shell/moduleRegistry for
its page and rail; lib/pageMeta for its title), and App held every rail's layout and collapse flag.
Now each module's `module.ts` says it all — label, icon, gate, its lazy page and rail, its width —
handed out through its index.ts, and features/navigation/moduleRegistry lists them in nav order for
the rail, the palette, the tab title, the reachable set and the shell's render. A contract test holds
the registry to the page ids and each definition to its own folder, with no static import of a page.

Every rail is lazy now, loaded together with its page so a pinned rail never arrives after it and
shoves the content aside; the feed's and the playlists' rails took @dnd-kit off the boot path (boot
428.5 → 393.9 KiB gzip, the App chunk 122 → 32 KiB). A rail reads its own layout and collapse flag
(lib/useRail) and writes a layout change to the `me` cache too, since it now mounts anew on each
visit. App's session effects, its one-shot entry-URL handlers and the admin nudge move into shell
hooks; App is layout. The registry's three layer suppressions are gone: none is left.
2026-10-23 04:00:58 +02:00
peter 4ef963b005 refactor(frontend): each page fills the floating header itself; the header knows no page
The header used to pick its pills by `page === …` and import five module controls, and Plex's
Discover/Browse toggle needed a context bridge to reach the sub-view that lives in PlexBrowse.
Now the header is an empty band: PageShell hands its root box out as a slot, a page portals its
own pills into it (PageHeader), and the palette's Jump-to pill shows where no page does
(PageHeaderDefault, rendered inside the page's Suspense so a loading page shows no stand-in).
Plex's toggles take the sub-view as a prop; PlexViewBridge and its context are gone, and with
them the header's five layer suppressions.

No visible change: every header pill's box measured identical against the previous build on eight
pages, in Hungarian and English.
2026-10-23 00:23:38 +02:00
peter 0b24f2d8fa fix(frontend): the chunk guard's logic lives in the guard, so no file sits in two TypeScript projects
The guard's logic was a src/lib file the config project also listed. The app project, which
references the config project, then refuses that source on a clean build (TS6305) — while every
local run reused the referenced project's cached output and stayed green; only the Docker image
build failed. The logic is back inside chunkGuard.ts, its test beside it at the frontend root
(vitest now also collects `*.test.ts` there), and a gate contract fails when the config project's
include reaches into src/ again. The guard was re-proved on a build with a leaking re-export.
2026-10-22 23:48:16 +02:00
peter d5867bacca feat(frontend): the folders are layers — an import only goes down, a module is reached through its index.ts
`local/layers` (eslint.config.js) resolves every import to its file and refuses one that goes up
(lib/ → components/ui/ → features/ → modules/ → shell/) or that reaches into another module's own
files. A module's index.ts is its public face: the stores, mounted at the root and read app-wide,
which features/ may read too; a page is still opened lazily by import(). Its contract test probes
every edge, and three mutants of the rule (off, no face check, a door for everyone) each turn it red.

Six modules got that face; the shared channel UI (about, tags, tag manager) moved to
features/channel, the filter chips and the saved-views context to features/filters, and the prefs
store's controller type moved next to the store instead of importing it from its own page. The
header's per-module controls and the eagerly loaded rails keep eight inline suppressions, each
naming why: they go when the header gets slots and every rail loads lazily.

`chunkGuard` fails the production build when a lazily imported module lands in a boot chunk (the
entry, the shell, what they import) — e.g. a page re-exported from an index.ts, which makes its
import() a silent no-op. Proved on a build with that mutant; today's build has none.
2026-10-22 23:30:27 +02:00
peter 63a0fecc8b refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/
A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform
layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each
page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in
features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/
(ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows).

Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the
contract tests that scan sources now scan the whole app source, not only components/ — each glob
was counted before and after so none went green over fewer files. The locale-format rule matched
`/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton
check; it now covers every folder that holds components, with a test per folder.
2026-10-22 23:12:45 +02:00
peter 3c6c23507b fix(frontend): a page opened at a //path boots, and the account-switch copy reads right in Hungarian
Every address-bar writer glued `pathname + query`, so a path of `//evil.com/x` named another host:
history refused it (SecurityError) and the first nav mirror threw the app into its crash screen. One
builder, `hrefHere`, now makes the address from the URL itself and stays on this origin.
2026-10-21 02:51:48 +02:00
peter ef99f7b5c7 fix(auth): a Google connect names its tab's account too, and a refused tab says why
- /auth/google/connect is a navigation and carries no headers: the tab names the account it
  was built for in `expect`, checked by the same comparison as the request header
  (built_for_another_account); a mismatch lands on /?account=changed
- the link marker names the OAuth state of its own flow: an older flow coming back no longer
  spends the marker of the connect still open (which then returned as a plain sign-in)
- a 401 account_changed reloads the tab with a one-time flag, and the reloaded tab says it
  now shows the account signed in
- a refused switch to a suspended or not-yet-approved account says so in the signed-in
  app's words, naming the address a request was recorded for
- the prefs merge locks FOR NO KEY UPDATE, so a transaction that only added a row referring
  to the account does not hold the save up; a test pins the lock
- giving up a lost page closes a channel page open over it
- e2e README names the chromium-only specs
2026-10-21 02:05:33 +02:00