Files
iptvnator/docs/architecture/workspace-shell.md
T
4grayandClaude Opus 5.5 8098ea8bb8 feat(self-hosted): keep playlists on the server for every device (#1903)
* feat(services): server storage connection state and access token

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(services): server kind for the playlist storage backend

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(services): server storage API client

The @iptvnator/services/server-storage sub-entry keeps server mode out of
the eager services barrel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(services): playlist document store over the server API

Full documents are cached by ETag (weak tags included) and served as
private copies; an upsert whose payload is the one the store handed out
sends keepPayload=1 without it and retries in full on payload_missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(services): server-synced user data storage

Synced Xtream maps live in a write-through cache under the
iptvnator-server: prefix; writes send only the playlist entries that
changed, debounced, and flush with keepalive when the page hides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(services): server storage session and token interceptor

The session plugs the HTTP document store into PlaylistStorageBackend,
hydrates synced user data before the first read (bounded to 3 s), retries
while offline, flushes on hide, and routes to sign-in when the server
answers 401. The interceptor adds the access token to HttpClient calls to
the backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(services): move browser playlists to the server

An empty server gets the browser playlists with their ids plus the
legacy Xtream user-data maps; a server that already has playlists goes
through the backup export and import, so duplicates merge. Browser data
is never deleted; the decision is remembered per server instance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(pwa): probe for server storage and wire server mode

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(pwa): server sign-in page and migration dialog

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(settings): server section for self-hosted storage

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(workspace): server status in the rail and an offline banner

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(settings): warn that delete-all clears the server

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(i18n): server storage strings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(e2e): server storage across browsers and the migration dialog

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(self-hosted): server storage mode, sign-in and the release note

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(pwa): stay in server mode when a known server does not answer

A failed start-up probe used to fall back to browser mode, which showed an
empty library while the backend restarted and let new playlists land in the
browser. The browser now remembers the server; without an answer it starts
in server mode, offline, and reloads once the server is back. Settings →
Server shows the backend address instead of the page's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): keep Xtream data, ordered sync, sign-in and stale-cache fixes

- Moving browser playlists matches them to the server's by id or source and
  carries the legacy Xtream maps under the server id; it no longer goes
  through the backup format, whose PWA export has no Xtream user data. A
  partial move stays on offer.
- User-data batches go out in order, and a re-read keeps entries edited
  while it was in flight.
- A keepPayload write keeps its cached copy only when the server replaced
  the version its payload came from (new X-Replaced-ETag header).
- 502/503/504 from the proxy count as offline.
- The start-up screen lets the sign-in page through while a token is
  required.
- Settings → Server offers a reload after a move.
- The probe detects Electron through RuntimeCapabilitiesService.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): merge user data on move, keep failed writes, sign-in route gate

- A merged playlist gains the browser document's favorites and history;
  Xtream values for one server entry are combined (lists unioned, maps
  merged, the server winning conflicts) and written once.
- A failed user-data write stays queued and survives a re-read; the
  page-hide flush waits for a batch still travelling.
- The start-up screen lets the sign-in route through whatever its check
  reports.
- The backend refuses IPTVNATOR_ACCESS_TOKEN without server storage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): positions by content, acknowledged keepalive, serial re-reads, token changes

- Moved playback positions are matched by content type and Xtream id and
  name the server playlist.
- Page-hide writes report whether the server took them; refused ones go
  back into the queue. Re-reads run one at a time.
- The access guard's 401 carries WWW-Authenticate: Bearer
  realm="iptvnator"; the interceptor and PwaService's raw fetches turn it
  into sign-in, so a token changed on the server is noticed on provider
  calls too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): Stalker keys by category, keepalive ordering, cut-off probe

- Moving Stalker favorites and history keys items by id and category, so
  a channel and a movie with one id both survive.
- Writes after a page-hide flush wait for its keepalive requests.
- A probe whose body is cut off counts as unreachable and keeps the
  remembered server.
- Settings → Server renders without @defer so settings search can reveal
  its rows on the first visit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): cache baseline before first read, versioned retries, M3U favorite ids

- Before the first user-data read finishes, an edit is compared with the
  cache, so it sends only its own playlist entry.
- Queued entries carry their edit number; a failed write is retried only
  when nothing newer replaced it.
- Moving M3U favorites and history translates channel ids through the
  stream URL (ids are random per import).
- The server-storage E2E runs in Chromium only: one shared database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): keep URL favorites, flush before moving, cache-full and cut-off bodies

- Moving M3U favorites keeps URL favorites and anything the server cannot
  resolve; only older channel ids are translated.
- Queued user-data edits are sent before the move writes combined values.
- A re-read that does not fit the cache keeps the old cache and baseline
  together.
- A response body cut off after its headers counts as offline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): unsent writes survive reloads, guarded move, lazy storage

- User-data writes the server has not confirmed are kept under
  iptvnator-server:unsent-writes and sent first on the next visit.
- Moving browser data does not start while user-data writes are unsent,
  and a duplicate of a failed upload counts as failed, not merged.
- The session reaches localStorage through the lazy adapter, so a browser
  that denies storage still boots.
- Raise launch/renderer.domMutationsToFirstCard by 2 (569 → 571): the rail
  server shortcut and the offline banner each leave an @if anchor in every
  build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): locks follow merged playlists, typed legacy Xtream ids, instance-scoped cache

- Moving a playlist under another server id takes its parental locks along
  (they stay in the browser).
- Bare legacy Xtream ids in each copy are typed from that copy's own
  snapshots before favorites, history and snapshots are combined, so a
  live channel and a movie sharing an id both survive.
- Writes still travelling are saved on page hide too; saved writes and the
  cache belong to one server database (cache owner = instanceId).
- A late successful answer no longer turns unauthorized back to online.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): apply early edits to the server value, newest unsent write, sticky sign-out

- Edits made before the first successful user-data read are kept as
  before → after and applied to the server's value once it arrives
  (applyEdit), so a new device or a stale cache adds a favorite instead of
  replacing the server's list.
- Saved unsent writes keep only the newest per entry; an answered older
  write no longer retires a newer one still travelling.
- A late failure after sign-out no longer turns unauthorized into offline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): keep early edits across reloads and tabs, numeric-string legacy ids

- Edits made before the first read are saved (before → after) and
  restored after a reload; a second early edit keeps the first one's
  baseline, even an empty one; a cache-full first read still applies them
  to the server's value.
- Queued writes are saved as soon as they are queued, and a re-read keeps
  entries another tab has not confirmed yet.
- Legacy Xtream ids stored as numeric strings are typed too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): per-tab saved writes, memory overflow cache, snapshot aliases

- Unconfirmed writes and early edits are kept in the tab's own
  sessionStorage, so tabs never clear each other's; a shared registry of
  unconfirmed entries (one record per tab, ignored after ten minutes)
  keeps a re-read in one tab from replacing another tab's values.
- A cache value too big for localStorage stays readable from memory, so
  later edits are diffed against what the app reads.
- Typing legacy Xtream ids keeps each snapshot's bare key as an alias, so
  a reference not yet rewritten still resolves and a retry can type it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): conditional user-data writes, scoped token, deletes

- User-state entries carry a content tag; PUT/DELETE accept If-Match or
  If-None-Match: * and answer 412 with the current value and tag. The PWA
  sends each edit on the tag it read and replays it (before → after) on
  the server's value after a conflict, so another device's change to the
  same playlist's favorites or positions is kept instead of overwritten.
- The access token goes only to the backend URL (same origin and path),
  never to another host or a sibling path.
- Deleting a playlist (or all) first drops its queued user-data edits and
  waits for writes in flight, so none recreates deleted data.
- The migration copy writes on the tags it read.
- The write queue moves out of ServerUserDataStorage into
  UserDataWriteQueue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(server-storage): conditional user-state writes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): keep tags with their values, rebase page-hide writes, hold deletes

- A re-read that keeps an entry from the cache (another tab's or this
  tab's unconfirmed value) no longer takes the server's tag for it, so the
  next edit conflicts and is replayed instead of overwriting another
  device's change.
- Page-hide writes behind a travelling one are taken from the queue when
  it is answered, so its new version becomes their baseline.
- A playlist delete holds that playlist's user-data edits instead of
  discarding them up front; only a successful delete drops them, a failed
  one sends them as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): replay edits behind travelling writes in order, hold writes after an offline start

- An edit queued behind a travelling write keeps its own before → after
  and carries the travelling write's edit as `prior`, also in the saved
  writes. A conflict replays both in order, so adding and then removing a
  favorite removes it whether or not the first write landed, and a lost
  write is not dropped.
- Page-hide writes now report their new tag or a 412 like normal writes:
  a confirmed one rebases the next edit, a conflict is replayed.
- After an offline start from a remembered instance, writes are held and
  the retry only checks /instance before reloading, so saved writes never
  reach another database behind the same address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): durable per-page journal, conflicts for edits on other tabs' changes, lock readability

- Unconfirmed writes and early edits are saved in localStorage per page
  load; the tab remembers its page load in sessionStorage, so a reload
  takes them over, and another tab replays a closed tab's writes once its
  record is stale. Live tabs renew their record every 30 s.
- An edit made on another tab's unsent change, and an entry a re-read
  keeps from the cache, are sent on a version no entry has, so the write
  conflicts and is replayed instead of carrying another tab's change past
  a later removal.
- Moving browser data counts the parental-lock step as failed while the
  lock store cannot be read, so the move stays on offer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): keep edits as ordered steps, treat probe refusals as transient, read the cache through memory

- A queued write keeps its edits as steps: an edit continuing the last one
  joins it, one made behind a travelling write or on another tab's change
  stays apart, and a conflict replays them in order. Saved writes and
  early edits store the steps too.
- The instance probe treats any refusal but 404 (a proxy's 429 or 403) as
  unreachable, so a browser keeps its remembered server.
- Cache reads that browser storage refuses fall back to memory, so a
  re-read and later edits still work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): keep steps of possibly-landed writes apart, queue closed tabs' edits even when the cache shows them

- Steps of a write whose answer was lost, of saved writes taken over after
  a reload, and of a closed tab's saved edits are marked uncertain, so a
  later edit never joins them: adding and then removing an item after a
  lost answer still removes it.
- A closed tab's saved edits are queued ahead of this tab's own even when
  the cache already shows them, so a conflict replays them too.
- The tag map moves into UserStateTags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(workspace): style the server banner from the shell, baseline 570

The offline banner's own stylesheet added a <style> element to every
launch, one of the two mutations the launch journey counted on top of
master. Its rules move into the workspace shell's stylesheet.

The other one is a parser-inserted modulepreload link: the new lazy
server-storage chunks split one shared initial chunk in two. A/B on
macOS: master 555, this branch 556 (was 557); the baseline note now
says so instead of blaming @if anchors, which cost nothing here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): apply a caller's edits to the cache as it is now

ServerUserDataStorage remembers the map getItem handed out. When the
cache changed since (another tab saved, a re-read), a write applies only
the entries the caller changed to the current cache, so a map read
before another tab's save no longer reads as deleting that save. The
cache moves into UserDataCache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): retry unsynced user data while online, keep newer typed snapshots, explain a refused token store

- The 30 s retry also re-reads and flushes while online when the first
  user-data read failed or writes the server refused (a 5xx) are queued;
  the same failure notice shows at most every five minutes.
- Moving browser data adds a legacy snapshot alias only where no typed
  snapshot exists, so a newer typed copy is not overwritten.
- writeServerAccessToken reports whether storage kept the token; the
  sign-in page says so (SERVER_STORAGE.SIGN_IN_STORAGE, 19 locales)
  instead of calling a right token rejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): conditional playlist metadata writes, retried on a fresh read

A favorite or rename (keepPayload PUT) is sent with If-Match on the
version it was read at. The server answers 412 when another device wrote
since; the store throws PlaylistWriteConflict and PlaylistsService's
per-playlist write queue runs the mutation again, which reads the
playlist afresh, so two devices' metadata edits both survive. A retry on
the same unread copy writes unconditionally, as before; refresh and
import writes stay last write wins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): conditional writes for every playlist mutation, ordered adoption, keep journals until copied

- Every playlist mutation is sent on the version it was read at: the
  payload's ETag, else the playlist's last read (portal documents have no
  payload). Refused versions are tracked per playlist, so a conflict on
  one never disables the check on another at the same revision. Imports
  and start-up migrations (dbUpsertAppPlaylists) stay unconditional.
- A closed tab's adopted edits replay before this tab's queued ones, from
  the base of its first step, so newer progress is not replaced by older.
- A reload or adoption removes the old page load's saved writes only when
  this page saved its copy; each closed tab is adopted once per page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): copy-bound portal versions, stale 401s, unreadable browser maps, early edits on recovered writes

- A served copy carries the version it came from (a symbol property that
  spreads keep and JSON drops), so a mutation of a portal document names
  the version it was built on, not the latest read.
- A 401 for a token replaced since (a new sign-in) neither clears the new
  token nor reports unauthorized, in the API client, the HttpClient
  interceptor and PwaService's proxy calls.
- Moving browser data counts a browser user-data map that cannot be read
  as a failure, so the move stays on offer.
- An edit made before the first read on top of this tab's recovered
  unsent write is applied to that write, not to the server's value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): favorite toggles as deltas, early edits as steps on recovered writes, release kept journal copies

- PlaylistEffects.updateFavorites$ applies the toggled channel through
  transformPlaylistFavorites, on the favorites read at write time, instead
  of writing the reducer's whole array: a conflict retry keeps another
  device's favorites (#1919).
- Edits made before the first read on top of this tab's recovered unsent
  write are appended to it as their own steps, so a removal of an item the
  recovered write adds survives a conflict replay.
- Journal copies of earlier page loads kept because this page could not
  save its own are removed once it can.
- adopt/extend move into server-user-data-adopt.ts over a small queue API
  (unsent, isQueued, put); the queue exposes its tag map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): refuse user state for deleted playlists

PUT /v1/user-state/:namespace/:playlistId answers 404 playlist_missing
when the server has no such playlist, so an edit queued on another device
(including its conflict replay with If-None-Match: *) cannot recreate the
state of a playlist deleted meanwhile. The client drops such an edit and
its cache entry instead of retrying; page-hide writes report it as gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): keep duplicated tabs off live journals, drain writes around remove-all, normalize legacy Stalker rows on the move

- A page marks its unconfirmed-writes record closed on pagehide (not into
  the back-forward cache); a new page takes over the previous page load's
  saved writes only when that record is closed, stale or missing, so a
  duplicated tab, which copies sessionStorage, leaves the running tab its
  writes.
- PlaylistsService.removeAll lets queued playlist writes land first and
  makes writes issued meanwhile wait until it finished; the server store
  refuses a document without an id.
- Moving browser data sets the explicit Stalker portal-mode flag on rows
  from before it (withExplicitStalkerPortalFlag, now shared with
  PlaylistsService).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): take over a reload's writes once the previous page load stops looking live

When the close flag could not be saved (storage full), a reload took the
previous page load for a running duplicated tab and left its saved writes
unsent. The page now remembers that page load and its 30 s touch takes it
over as soon as its record is closed, stale or gone, so the session's
retry sends the writes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): adopt a late previous page load instead of restoring it

When touch() finds that the previous page load no longer looks live, this
page may already have read and queued newer edits. Its saved writes and
early edits are now adopted like a closed tab's (their steps ahead of this
page's newer ones, queued at once) instead of restored as if this page
had just started, which replaced newer progress with older and parked
early edits that were never sent after the first read. Cache forgetting
moves into UserDataCache.forget().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): adopted writes without a known version conflict instead of overwriting

A page that never read the server has no version tags, so an adopted
write went out unconditionally and replaced newer server data with its
cached value. Adopted edits now use the never-matching version when this
page knows none (and for a queued write without a tag), so the server
answers 412 and the edits are replayed on its value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): a re-read sent before a playlist delete skips that playlist

ServerUserDataStorage records each successful delete; a re-read sent
before it drops the deleted playlist's entries and tags from the server's
answer (withoutDeleted), so a late answer cannot write the deleted
favorites and positions back into the cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): keep a playlist id like __proto__ when filtering a read

withoutDeleted() builds its maps without a prototype, so a surviving
playlist whose id is __proto__ stays a key instead of changing the
object's prototype and losing its favorites and tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): reorders on the stored favorites, URL-insensitive remembered server, prototype-free id maps

- PlaylistEffects.setFavorites$ applies the drag's order through
  transformPlaylistFavorites (reorderFavorites): favorites added on another
  device stay, ones removed there stay out.
- The remembered server matches the backend URL by origin and path
  without trailing slashes, so /api and /api/ start offline in server
  mode alike.
- Maps keyed by playlist id are built without a prototype (idMap), so an
  id such as __proto__ is an ordinary key in the cache, conflict replays,
  re-reads and tags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): one registry key per page load, server token only in server mode

- The unconfirmed-writes registry stores each page load's record under
  its own key (iptvnator-server:unconfirmed:<instance>:<page>), listed
  through a new keys() on the user-data storage adapter, so tabs never
  overwrite each other's records. Saved writes without a fresh record,
  also when the record was lost, are adopted as a closed tab's (#1920).
- PwaService sends the stored server token on its raw fetch calls only
  while server mode runs, and a backend that answers without server
  storage makes the browser forget the token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): forget the token for another server, listen before sign-in, adopt only stale records

- A server that answers as another backend URL or instance id than the
  remembered one makes the browser forget the stored token, so server A's
  token never reaches server B.
- The session installs its page-hide and heartbeat listeners before it
  returns for sign-in, so a page holding another page load's writes still
  marks itself closed on unload.
- Closed tabs are found by stale records only again: saved writes without
  a record may belong to a running tab whose record storage refused, and
  are left to that tab's own reload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): no token for an unverified instance, sign-in remembers its server

- ServerStorageStatusService.accessToken() returns the stored token only
  while server mode runs on an instance this start verified. The API
  client, the HttpClient interceptor and PwaService's raw fetch calls use
  it, so after an offline start from a remembered instance no request
  (including the /instance reachability check) carries the token to
  whatever answers at that address, and a 401 then does not clear it.
- Sign-in remembers the server along with the token; when storage refuses
  either, it reports SIGN_IN_STORAGE instead of reloading into a start
  that would drop the token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): check a remembered instance before sign-in sends the token

A page started from memory (startedOffline) withholds the token until the
instance is verified, so sign-in on that page sent no token and the
server's 401 cleared the right one. Sign-in now first calls
ServerStorageSession.ensureVerified(): it reads /instance without a
token, marks the status verified when the instance id matches, and
reloads the page when another server answers at the address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): read migration maps by own playlist ids only

The browser's legacy maps and the server's user-state entries are read
into null-prototype maps, so a playlist id such as `toString` is not
taken for an entry and a missing server value no longer resolves to an
inherited function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): bind the token to its instance; adopted edits yield to newer changes

The access token is stored with the instance id it was given for, and a
page reads it only for its own instance: a tab still open on the old
backend after BACKEND_URL changed no longer sends the new server's
token there. The API client hands every 401 to reportAccessDenied(),
which now also sends a page without a token for its instance to sign-in,
while one that holds its token back until the instance is checked keeps
it.

A closed tab's adopted edits carry `yields`: replayed, they change only
what is still as that tab saw it, so they never replace progress this
tab or another device saved since. The flag survives the journal.

The sync spec's fake server and memory storage move to a test-helpers
file to keep the spec under its line limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): per-instance cache, idle tabs adopt closed ones, backups wait for the first read

The user-data cache is keyed by server instance
(`iptvnator-server:cache:<instanceId>:<namespace>`): a tab still open on
the old backend after BACKEND_URL changed no longer writes over the new
server's cache, which the new tab's next edit would have sent there.
claimCache also removes the old owner's keys and the unscoped keys of
earlier builds.

touch(), which runs every 30 s, adopts closed tabs' saved writes once
this tab has read: a tab with nothing to sync never re-reads, so they
waited until a tab switch or reload.

UserDataStorage gains an optional ready(); the server storage resolves
it after its first read and rejects while that fails. A PWA backup
export awaits it, so a fresh browser whose /user-state read is slow or
failed cannot export empty favorites, history and positions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): token only to backend URLs in raw fetches; Stalker series keys in the move

PwaService joins paths to the backend URL without its trailing slashes,
so BACKEND_URL=/ no longer yields a protocol-relative //… URL that names
another host, and it attaches the server token only when the request URL
resolves to the backend (isBackendUrl), like the interceptor.

The move's item key adds the Stalker series flag to id and category: a
provider category can hold a movie and a series of one id, and the
series favorite was dropped as a duplicate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): Xtream reads and backups wait for the first read; keepalive deadline

Startup waits for the first user-data read only a few seconds. ready()
now waits for a first read in flight without starting one (an offline
page is not held up) and tells whether one succeeded:

- PwaXtreamDataSource awaits it before reading favorites, recent items
  and playback positions, so a new device whose /user-state read is slow
  does not show empty lists or start playback without its resume point.
- A PWA backup export and restore fail while no read succeeded; the
  restore stays parked, since its replacement lists would otherwise
  merge into the server's (an empty favorites list changed nothing).

A page-hide keepalive write gets the request timeout: a server that
takes it and never answers no longer holds every later write, re-read
and delete behind it until reload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): a failed sign-in keeps another tab's token; moved history newest first

A sign-in that fails clears the token only while it is still the one
this attempt stored: another tab that signed in meanwhile keeps its
valid token.

The move orders the combined Xtream recent items by viewedAt, newest
first, as the app keeps them: browser views appended after the server's
history were cut by the next view once the server held 50.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): recheck the instance after offline, back-forward cache pages, Stalker server rows

Going offline now marks the instance unchecked: the token and writes
wait until /instance answers with this page's instance (another one
reloads the page). A replaced volume at the same address no longer gets
the old token and queued user-state writes on reconnect. The session
checks as soon as it goes offline, so a blip costs no retry interval.

A page restored from the back-forward cache whose record another tab
adopted meanwhile (its timers were frozen, so the record went stale)
reloads instead of sending those writes again after newer edits.

The Stalker playlist metadata migration also runs on server rows, once
per server: a desktop database copied to the server can predate it, and
its Stalker rows lacked isFullStalkerPortal.

The take-over and change-diff loops move out of the user-data facade,
and the adoption specs get their own file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): adopted edits keep yielding through conflicts; newest view per item; same-token sign-ins

A conflict replay collapsed every step into one edit, so a closed tab's
adopted step lost `yields` after the first conflict and a second one
replayed its old position over a newer one. Its steps now stay as they
are, and only this page's own collapse into one edit on top of them.

The move combines recently viewed items one per item, keeping the newest
view and its artwork, then newest first; before, the server's older copy
of an item won over a later browser view.

A failed sign-in clears the stored token only on a 401: a network
failure says nothing about the token, and another tab may have stored
the same one meanwhile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): suspended tabs give up adopted writes; history stays newest first; favorite checks wait

A background tab the browser suspended past the record lifetime can have
its writes adopted elsewhere, like a back-forward cache page: shown
again, it now checks resumed() and reloads instead of sending them.

Conflict replays appended a newly viewed item at the end of the history,
where the next 50-item cut dropped it; a replayed recently viewed list is
now kept newest first.

isFavorite() waits for the first user-data read like the list reads, so
a detail page opened during a slow first read does not toggle an
existing favorite the wrong way. A parked backup restore replayed when
the playlist opens waits too, and stays parked while no read succeeded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): offline recheck checks only the instance; sign-out always navigates

The recheck on going offline ran a full refresh: when /instance answered
but /user-state kept failing, each failed read set the page offline
again and started the next one at once, bypassing the retry interval.
It now checks the instance only; reads wait for the retry.

Sign-out navigates to sign-in itself, also when the page is signed out
already: a guard (unsaved Settings) may have cancelled the redirect the
first time, and the state change that drives it does not repeat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): a superseded page sends and saves nothing; re-reads keep other tabs' newer saves

A suspended tab's retry timer could run before it was shown again: touch()
recreated the record another tab had removed when it adopted the page's
writes, so the page sent them again over newer edits. The facade now
treats a page whose record is gone as superseded: the queue sends nothing
(maySend hook), its journal and record are not written again, and the
retry timer reloads it like a page shown again.

A re-read answered after another tab saved an entry no longer writes the
older answer over the shared cache: entries changed while the read
travelled are kept, with an unknown tag, so the next write here
conflicts and replays instead of removing what the other tab saved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(web-e2e): never reuse another backend as the storage E2E backend

server-storage.e2e.ts deletes every playlist before each test. Reusing a
backend already listening on STORAGE_BACKEND_PORT outside CI could have
wiped a real library; an occupied port now fails the run instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): an outage on the sign-in page also unverifies the instance

reportOffline() left `verified` set while the page was unauthorized, so
a sign-in retried after an outage skipped the instance check and could
send the old instance's token to a replacement database. Verification
is now cleared on any outage; only the state change stays guarded, so
the workspace remains locked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): interceptor and raw fetches report backend outages too

Only the storage API client reported an unreachable backend as offline.
When the first sign of an outage was an HttpClient call through the
interceptor (provider targets, playlist parsing) or one of PwaService's
raw fetches (Stalker, connectivity reset), the instance stayed verified,
and the next request after a replacement instance came up at the same
address would carry the old token.

Both now report no answer (status 0, a failed fetch) and a gateway's
502/503/504 as offline, so the session checks /instance first. The
backend's own JSON 502 for a failed provider is an answer and changes
nothing; neither does a fetch this page aborted on its own timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): the immediate offline check runs once per retry interval

A response whose headers arrived but whose body was cut off reports
success and then offline, which started the next immediate instance
check at once: repeated cut-off answers kept requests going and the
connection indicator flickering. The check on going offline now runs at
most once per retry interval; later failures wait for the timed retry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): a saved write without a readable tag counts as saved; unverified online pages keep retrying

A write whose headers said saved but whose body (with the new tag) was
cut off was retired without confirmation: a newer queued edit kept its
steps and old tag, and a later conflict replayed the saved step over a
newer value from another device. It now counts as saved on an unknown
version: the landed steps drop out and the next write conflicts, so
only edits made after it replay. Writes that were unconditional stay so.
The keepalive path does the same.

A late answer to a request sent before an outage can set an unverified
page online; with nothing to sync the retry then never ran and the token
stayed withheld. The retry now also runs for online pages that are not
verified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(web): keep server storage off the startup bundle; form-field defaults on sign-in

PwaService imported isBackendUrl from the @iptvnator/services/server-storage
barrel, which pulled the whole storage subsystem (sync, migration, API
client) into main, though server mode loads it by dynamic import. The
helper now lives with the token helpers in @iptvnator/services
(re-exported from the interceptor): renderer.initialBytes 1,392,902 ->
1,357,795 bytes locally.

The remaining 11,988 bytes over #1931's baseline are what must run
before the first route (instance probe, connection status, token bound to
its instance, storage backend and write serialization); the baseline is
raised with a note, under the PR's perf-baseline-increase label.

The sign-in component provides the form-field defaults per component, as
master's check (#1928) now requires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): conflict-safe Stalker metadata migration on the server; newest document views in the move

The Stalker metadata migration wrote server rows back through the
unconditional bulk upsert, from the list read: a device that edited a
Stalker playlist in between (or a second browser migrating the same
copied database) lost its change. Server rows are now read again one by
one and written as conditional mutations on that version; a conflict
leaves the flag unset, so the next start retries.

Moving a document's history kept the server's copy of an item both held,
even when the browser had viewed it later, and skipped the write when the
length did not change. The combined history is now newest first by
added_at, one entry per item, and any content change is written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): M3U favorites by URL and history channel ids in the move; restores wait for the server

The move's M3U remap swapped references for server channel ids, but the
app looks favorites up by stream URL and history entries carry the URL
as their id: legacy favorites became invisible and history duplicated.
Legacy id favorites now become their channel's URL on both sides, and
history keeps its URL id with the server's channel id as channel_id.

A restore in server mode reported success once its lists were queued:
a list the server refused (413) left the old data while the parked
restore was consumed. Both restore paths now wait for that playlist's
writes (UserDataStorage.flushed, a few flush rounds for conflict
replays) and stay parked when the server did not take them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): a closed page record stays closed when a late write rewrites it

On pagehide the page flushes with keepalive and then marks its record
closed. When writes were held (offline), the keepalive write fails at
once and its requeue saved the journal again a microtask later, which
rewrote the record without `closed`: the reload took the previous page
load for live and left its favorites and progress unsent until the
record aged out (ten minutes). markUnconfirmed now keeps an existing
record's closed flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): the move uploads playlists create-only and merges into a copy moved first

Two tabs or devices moving browser copies with the same playlist id both
planned an upload; the browser documents carry no served version, so
both PUTs were unconditional and the later one overwrote the first copy
(and any change made on it since), with both moves reporting success.

The backend's playlist PUT now honours `If-None-Match: *` (412 when the
playlist exists; checked and written in one synchronous handler). The
move uploads with it through ServerPlaylistDocumentStore
.dbCreateAppPlaylist, and an upload that finds its id taken merges its
favorites and history into that copy instead, counted as merged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): Delete All waits for repairs holding a reservation; backups read the server again

Delete All raised its write barrier before it held the exclusive
authority reset. A Stalker repair already holding its row reservation
then waited on that barrier for its conditional write, while the reset
waited for the reservation: neither finished, and later playlist
operations stalled behind them. The barrier now goes up inside the
reset, once repairs in flight have finished; writes queued so far land
first and new ones wait for the delete, as before.

ready() said yes after any earlier read, so an export could miss what
another device added since, and restoring an empty list did nothing
when the cache was already empty. Backup export, restore and the
restore replay now ask for a fresh read (ready(true)) and fail, the
restore staying parked, when it does not succeed.

The facade's adoption loop moves to the adopt module to keep the
facade under its line limit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(services): Delete All drains queued writes before its reset; reserved writes skip its barrier

The previous commit raised Delete All's barrier inside the exclusive
authority reset and drained queued writes there, but a queued deletion
or import takes the shared side of that lock: the reset waited for it
while it waited for the reset.

Delete All drains the writes queued before it outside the lock again,
then takes the reset, with its barrier up from the start. The deadlock
with a Stalker repair (or Edit) that holds a row reservation and writes
under it is broken on the other side: such a write does not wait for
the barrier (holdsPlaylistAuthority, counted per reservation in this
context), and the reset waits for the reservation as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): a re-read keeps writes another tab had unconfirmed when it went out

A re-read checked other tabs' unconfirmed entries only after its answer
arrived. A tab whose write was unconfirmed when the read went out (its
edit already in the snapshot) and confirmed before the old answer came
back matched neither check, so the stale answer replaced its saved value
in the shared cache; that tab's next edit, holding the newer tag, then
wrote the stale list and deleted the item on the server. The entries
unconfirmed when the read is sent are kept too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(services): writes wait for Delete All before they queue; restores replace only settled lists

Delete All, third variant: a write that arrived after Delete All waited
for it inside its playlist's queue. A Stalker repair that reserved that
playlist meanwhile skipped the barrier, but its write still queued
behind that waiting one, while the reset waited for the repair's
reservation. While a Delete All runs, writes now wait for it before they
join their queue, so no queue holds work blocked on the reset; a write
under a reservation held here joins at once.

A restore replaced lists while this page's own write for the playlist
was still unsent (refused, or lost): that write's edits joined the
replacement and a conflict replay kept items the backup did not have.
Both restore paths now require the playlist's writes to be on the
server first (flushed), and otherwise stay parked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): a reload finds page loads a reload before it kept; restore master's tick baselines

When storage refused a reload's copy of the last page load's saved
writes, the old copy stayed but only this page's memory named it: the
tab's pointer already named the new page, so a second reload before any
copy was saved recovered from the empty new page, and without a
registry record no other tab could find the old one. The pointer now
also lists the page loads a page keeps (keepPages), and a reload takes
them over, oldest first, before its last page load. The pointer helpers
move to server-page-pointer.ts.

Both merges of master resolved journey-baselines.json by taking this
branch's whole file, which dropped #1929's cdTicksToFirstCard,
cdTicksIdle30s and cdTicksToFirstPage. The file is master's again with
only this branch's initialBytes and domMutationsToFirstCard raises.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): a duplicate leaves a running tab's kept page loads to it

A duplicated tab copies the tab's session storage, so after a reload
that kept older page loads its pointer lists them too, and the duplicate
took them over at once, although the tab still running owns those edits
and may have newer ones queued; its replay could then write older watch
progress over newer. When the previous page load still runs, the kept
ones now wait with it and `touch` adopts them, oldest first, once it no
longer looks live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): a restore merged by a conflict stays parked; first-read tests wait for the read

A restore whose write conflicted with an edit another device made during
it had the conflict replay merge that edit in, and flushed() still said
the lists were taken, so the restore was consumed with lists that were
not the backup's. Both restore paths now compare the stored lists with
the ones they wrote once the server took them, and a difference keeps
the restore parked to run again on a fresh read.

The two first-read tests released the mocked read after a fixed number
of microtask turns, which could come before the read started; they now
wait until it has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): page-hide keepalive writes report an outage too

sendKeepalive() turned a failed fetch, a timeout or a gateway's
502/503/504 into a failed outcome without telling the status, so the
instance stayed verified: when the hidden tab came back, its refresh
skipped the /instance check and resent the queued writes, which could
reach a replacement database behind the same address. Like requests, a
keepalive write now reports these as offline, so the instance is
checked before anything goes out again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): a keepalive answer cut off after its headers counts as offline

A page-hide write whose headers said saved but whose body broke off was
returned as saved with the read error dropped, so the instance stayed
verified. Like readJson() for requests, it now reports offline (the
outcome stays saved on an unknown version), and the instance is checked
before the next token or write goes out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): kept page loads replay oldest first; restore checks compare normalized reads

A page load and an older one it kept could both hold steps for one
entry. Adopting them, each adopted page went ahead of what was queued,
so adopting the oldest first put the newer page's steps before the
older's; restoring them after a reload collapsed the newer page's steps
into one, so its removal of an item the older page had added was lost
on a conflict replay. Adoption now goes newest first, and a reload
appends a later page's steps (and early edits) after an older copy's.

restoreUserData() compared the lists it wrote with reads that drop
duplicate favorites, so a saved restore listing one twice never
finished, also in browser-only mode. It now compares reads right after
writing with reads after the save.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): flushes wait for a page-hide batch sent at once; keepalive 401s; parked restores move

A flush scheduled in the same turn as a page-hide flush was chained on
the queue before the keepalive batch, which went out at once beside it:
a later edit (removing a favorite the keepalive batch added) could land
first and be undone when the keepalive write arrived. A scheduled flush
now waits for a page-hide batch sent at once before it takes entries.

A keepalive write refused with 401 returned failed without telling the
status; like requests it now reports the token it sent as refused.

The move copies a parked Xtream restore (a browser backup import that
could not finish) to the server playlist id a playlist merged into, so
it is applied and exported there; one parked there already wins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server-storage): a slow server does not reload a page started from memory forever

The start probe gives /v1/instance 2.5 s. A remembered server slower
than that started the page from memory; the recovery check (with the
request timeout) then got an answer and reloaded, and the reloaded page
timed out again: an endless reload loop for a server answering in 2.5
to 20 s. The reload now leaves a one-time mark in sessionStorage, and
the next probe waits as long as requests do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:57:15 +02:00

56 KiB
Raw Blame History

Workspace Shell

This document records the current workspace-first shell contract. It is the stable replacement for the older UI refactor summary.

Related:

Summary

  • /workspace is the primary app surface.
  • WorkspaceShellComponent owns the persistent frame: rail, header, optional context panel, content outlet, and external playback footer.
  • Descendant workspace pages inherit layout = 'workspace' from the /workspace root route.
  • Provider route trees now bootstrap through route-scoped session providers instead of nested provider shell components.

Core implementation:

  1. apps/web/src/app/app.routes.ts
  2. libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.ts
  3. libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html
  4. libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.ts
  5. libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-route-state.service.ts
  6. libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search.service.ts
  7. libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-search-sync.service.ts
  8. libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-header.service.ts
  9. libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-command-palette.service.ts
  10. libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.ts
  11. libs/portal/shared/util/src/lib/navigation/portal-route.utils.ts
  12. libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts
  13. libs/portal/shared/ui/src/lib/navigation/portal-rail-links.component.ts

Route Contract

Current workspace routes:

  1. / -> /workspace
  2. /workspace -> functional redirect workspaceEntryRedirect (WorkspaceStartupPreferencesService.resolveInitialWorkspacePath(); /workspace/dashboard by default, /workspace/sources when the dashboard is disabled, or the last restorable route under StartupBehavior.RestoreLastView — dashboard itself is guarded by dashboardAccessGuard)
  3. /workspace/dashboard
  4. /workspace/sources
  5. /workspace/playlists/:id/:view (plus favorites and recent siblings)
  6. /workspace/global-favorites
  7. /workspace/global-recent
  8. /workspace/search
  9. /workspace/downloads
  10. /workspace/settings/:section (/workspace/settings redirects to general; the settings context panel links each section page; sections: general, playback, epg, dashboard, remote-control, tmdb, parental — see parental lock — backup, reset, about)
  11. /workspace/xtreams/:id/...
  12. /workspace/stalker/:id/...

Compatibility redirect:

  1. /settings -> /workspace/settings

Provider route integration:

  1. Every routed view renders inside the workspace shell (the only top-level paths outside /workspace are redirects), so views keep no standalone layout branches: the shell header owns search, Back and the playlist switcher.
  2. Xtream and Stalker parent routes attach route-scoped session providers that bootstrap the active playlist, sync provider section state, and clean up provider-local state when the route is destroyed.
  3. Xtream route bootstrap is DB-first for already imported Electron playlists: if the requested section has persisted categories and content, the route hydrates from SQLite even when the portal status probe reports unavailable, expired, or inactive. Fresh/no-cache Xtream routes still use the status probe to block remote imports before the loading overlay starts.
  4. Workspace routes no longer rely on nested provider shell components for hidden local chrome.

Shell Structure

The shell is intentionally split into four persistent regions:

  1. Left rail:
    1. Static workspace links for dashboard, sources, global favorites, and recently viewed. The routed global-search rail link is Electron-only because its data source is the SQLite worker bridge.
    2. Provider-aware context links derived from the active or current playlist.
    3. Settings remains a persistent footer shortcut in the rail. In server storage mode a server status shortcut sits above it: its dot shows online, offline or sign-in needed, and it opens Settings → Server.
    4. No brand mark: it only repeated the first workspace link (Dashboard, or Sources when the dashboard is off).
  2. Top header:
    1. Leading Back slot: the current page's registered Back, else browser history while an in-app previous page exists, else nothing. See Header Back.
    2. Playlist switcher.
    3. Route-aware search input and command palette trigger.
    4. Add source action.
    5. Optional playlist refresh and route-specific shortcut actions.
    6. Downloads shortcut in Electron.
  3. Main body:
    1. Optional left context panel.
    2. Main router outlet content. In server storage mode an offline banner with Retry takes the first row of .workspace-content-frame, above the scroller, so full-height pages keep their height.
  4. Optional footer:
    1. External playback session bar when a docked session is visible.

Content surface corner

main.workspace-content (the scroller) sits in .workspace-content-frame. The frame carries the surface's 16px top-left radius and its shadow and never clips; the scroller clips to a rectangle and is a stacking context; a 16px corner piece on the frame paints the notch in the body colour. The radius used to sit on the scroller, which made Blink clip every composited effect inside it (the rail chevrons' and hero controls' backdrop-filter, running transform animations, isolated groups) through a mask layer synthesized per effect: its shader path for rounded clips needs four equal radii on macOS and a translation-only transform between the clip and the effect, and the fallback mask is the size of the whole clipped area, about 24 MB of tile memory at 2x on a 16" display. A dozen of them took the dashboard past Chromium's 512 MB tile budget (tile memory limits exceeded, blank tiles while scrolling). apps/electron-backend-e2e/src/compositing.e2e.ts guards it: no drawing layer without an owner node and without a compositing reason (a synthesized mask, unlike any content layer) may span half the content area, and the backdrop-filtered controls must really be composited, so the check cannot pass on a page with nothing to clip.

Layout slides

When a route change adds or removes the context panel, the content frame moves by the panel's width (323 px on desktop), and when Back or the phone drawer toggle appears or leaves, the header controls after it move by the slot's width (44 px). Both happen in the frame that swaps the page, so they used to be one jump: a layout shift of 0.233 in the open-source journey, 0.222 of it the content frame (see performance journeys).

LayoutSlideDirective (workspace-shell/layout-slide.directive.ts) turns these jumps into a 180 ms slide, the duration of the context panel's own width transition. The new layout applies at once; each target starts translated back to the position it had and animates its transform to none (FLIP), so nothing waits for the animation and Chromium records no layout shift for a transform. The shell's .workspace-body slides .workspace-content-frame when showContextPanel changes; the header slides the drawer toggle, switcher, search and action groups when its leading navigation (Back, drawer toggle) changes.

  1. Previous positions. offsetLeft values, which ignore transforms, are recorded after every render in which the slide key or the record key changed (the header's record key covers the other inputs that move its controls: settings route, no playlists, parental lock, shortcuts) and when the host or a target resizes (window resize, a context panel folding or being dragged wider). A slide therefore starts where the target was last laid out, and a target that has no recorded position or did not move is left alone.
  2. The uncovered strip. The frame is narrower beside the panel than without it, so the side it moves away from would show the body colour during the slide. While a slide runs the target carries layout-slide-active and the uncovered widths as --layout-slide-start and --layout-slide-end; the frame's stylesheet extends the content surface over that strip with an ::after below the scroller.
  3. Back fades in over the same 180 ms, so the switcher still sliding away from its slot does not draw over the arrow.
  4. Reduced motion. Under prefers-reduced-motion there is no animation: the jump (and its layout shift) remains.
  5. Compositing. While it runs, each slid target is its own composited layer (ActiveTransformAnimation): the frame at its width plus the covered strip (1220×664 CSS pixels at 1280×720), the header controls a few small ones. No synthesized clip mask appears, because no ancestor of the frame clips with a radius (content surface corner), and the layers go away when the slide ends: perf:compositing reads the same layer and mask counts on every route as before the slides (#1895).
  6. Not covered. A slide does not change what the route renders. Going back from a portal to the dashboard used to lay the new dashboard out below the old page before the old page left, a shift a slide cannot hide. That was the route swap, fixed at its cause (see Route swaps).

WorkspaceShellComponent binds only to WorkspaceShellFacade. The facade is kept as a thin template-facing API and delegates ownership to component-scoped services:

  1. WorkspaceShellRouteStateService owns current route parsing, rail links, context-panel state, dashboard startup preference, and playlist source signals.
  2. WorkspaceShellSearchService owns the route-aware header search capability and public search actions.
  3. WorkspaceShellSearchSyncService owns the search query signals, debounced application, provider-store synchronization, and query-param sync.
  4. WorkspaceShellHeaderService owns playlist title/subtitle, account/info actions, and refresh action state. It has no clear action: clearing recently viewed or favorites belongs to the collection page.
  5. WorkspaceShellCommandPaletteService owns command-palette dialog lifecycle and recent-command recording.
  6. WorkspaceShellXtreamImportService owns Xtream import/refresh overlay state and labels. Remote import phases and refresh preparation of the shown playlist (of any playlist on the dashboard) show the overlay at once. A session that has only read the saved catalog (loading-cached, isImporting still false) shows it only after XTREAM_LOCAL_LOAD_OVERLAY_DELAY_MS (300 ms), so a warm open goes from the skeleton to the page without flashing the overlay, while a slow or stuck cache read still blocks and stays cancellable.

When adding shell behavior, prefer placing it in the service that owns the nearest existing state. Keep WorkspaceShellFacade as a stable re-export layer for the template unless the template contract itself intentionally changes.

Route swaps

The router's activation runs in one task, outside change detection: deactivation destroys the old routed view, then activation creates the new one and inserts its host element into main.workspace-content at once. WorkspaceShellRouteStateService.currentUrl is set on NavigationEnd, which the router emits synchronously after activation, so the shell's state does not lag the outlet. The shell re-renders (the context panel leaves, the content frame slides) in the next change detection. With event coalescing that runs in the next requestAnimationFrame, before that frame paints.

The app does not provide provideAnimations(). Nothing uses @angular/animations triggers (Material animates with CSS), and that package's renderer queues every DOM removal until the end of the next change detection, while insertions apply at once. Leaving a portal section for the dashboard therefore inserted the dashboard below the still-present portal page, and the old page left only in the coalesced tick. In between, Chromium laid out the document with both pages, the dashboard at y=720 in a 720 px window, and scored the swap as one layout shift: 0.337 at 1280 px, 0.394 at 900 px, 0.723 at 420 px (measured with a layout-shift observer in the electron-performance build). With the default DOM renderer the old page is removed in the same microtask, before the new one is inserted, and the swap records no shift. app.config.spec.ts keeps the animations renderer out, and workspace-route-swap.e2e.ts asserts that no mutation checkpoint holds both pages and that neither the dashboard nor the content pane shifts.

Destroy hooks therefore run with the view's DOM already detached; the UI guidelines ("No animations module") say how hooks record geometry and focus instead.

Do not add provideAnimations() or provideAnimationsAsync() back; animate with CSS. A leave animation (animate.leave included) delays its element's removal in the same way, so a routed page must not carry one.

Header Back

The header's leading slot is the workspace's one page-level Back. Pages do not render an arrow of their own: they register a WorkspaceBackTarget (@iptvnator/portal/shared/util) with WorkspaceBackNavigationService (@iptvnator/portal/shared/data-access), normally through registerWorkspaceBack(), which registers for the calling component's lifetime while its optional available predicate holds. The newest registration wins, and each release removes only its own target. The button (data-test-id="workspace-header-back") sits beside the macOS traffic lights, never scrolls, and has Electron no-drag hit testing. A target supplies its label (else the translated "Back"), whether Escape on the page runs it, and run().

Page Registered by Back runs ≤640 px
Portal, collection, offline and recording details PortalDetailShellComponent while backAvailable() the host's backClicked replaces the drawer toggle
Xtream and Stalker Discover and actor pages DiscoverViewComponent, ActorViewComponent the route's history Back; parent: the catalog section Discover lists (vod for movies, series for TV), the portal's default section for actor (no drawer)
In-portal search, Xtream and Stalker SearchLayoutComponent while backAvailable() and no inline detail replaces the results history Back; parent: the portal's default section (no drawer)
Settings WorkspaceSettingsContextPanelComponent, which exists exactly while the settings route shows history Back; parent: the first workspace view (WorkspaceStartupPreferencesService.resolveDashboardPath(): the dashboard, or sources when it is hidden) beside the drawer toggle

Detail-page semantics (Escape, browse and watch) are in Portal Detail Navigation.

History fallback. Without a registration, the header shows Back while the previous history entry is an in-app one, and runs Location.back(). The service reads that from the Navigation API: the previous entry must be same-document (NavigationHistoryEntry.sameDocument), so the router pushed it after this document loaded. Entries from before a reload or from another page of the origin never count, and the fallback can neither leave nor reload the app. currententrychange keeps it current through pushes, replacements, traversals and guard-cancelled Back navigations that the router rewrites. Without the Navigation API (older Safari and Firefox, jsdom) there is no fallback; registered pages are unaffected. The fallback reads "Back" and advertises no Escape, because no page handles one for it. Pages that set backAvailable=false, such as M3U details, therefore show it too when they were reached by navigation.

Parent fallback. "Parent" in the table above: a registered page whose Back is history Back calls WorkspaceBackNavigationService.back(resolveParent) instead of Location.back(). It runs Location.back() while the previous entry is an in-app one, by the same Navigation API test as the history fallback. Without the API (older Safari and Firefox) the router's history depth decides (trackRouterHistoryDepth): the document's first navigation is depth 0, a push adds one, a replacement keeps it and a traversal restores the depth recorded for its entry. The lazy workspace shell creates the service after the first navigation began, so the tracker adopts the router's current or last navigation: a first one is depth 0, a later one leaves the depth unknown. A traversal to an entry from before a reload leaves the depth unknown and keeps Location.back(), which then has a previous entry. Otherwise the page opened the session (a deep link, a reload or a restored view), where Location.back() does nothing in Electron and leaves the app in a browser. The service then navigates to the page's parent with replaceUrl, so history Back cannot return to the page just left; with nothing in-app before it, the parent shows no history fallback. The resolver returns a URL or router commands, may be asynchronous, and returns null when the page knows no parent, which keeps Location.back(). Portal pages build their parent with workspacePortalCommands() (@iptvnator/portal/shared/util) from the route's :id; without a section, the portal route's redirectTo picks the default section within the same navigation, so the replacement still applies. Detail pages keep their own return logic (backClicked).

When there is nowhere to go, the slot is empty rather than a disabled arrow. Sessions often start on a page that never navigates (an M3U playlist or live TV), where a disabled arrow would stay for the whole session. When Back first appears or leaves, which happens only at the start of the history and together with a route change, the switcher and search slide by the slot's width instead of jumping (see Layout slides). There is no Forward button: Stalker inline details are store state, not history entries, so Forward would skip them.

Phone width. phoneDrawerToggle sets how Back shares the leading slot with the context drawer toggle. replace (the default) takes the toggle's slot: a detail page's drawer belongs to the list that Back returns to. beside keeps both: the settings drawer holds the page's own sections. yield hides Back while the toggle shows: the history fallback must not cost a category list its only way into the drawer, and two navigation icons do not fit beside the switcher. System and browser Back still work there.

Left in place. These controls stay inside their surface on purpose:

  1. Downloads offline and recording detail error states keep their labelled "Back to Downloads" button beside Retry or Remove. It is the error state's recovery action, not page chrome; the header shows the same Back.
  2. Back controls internal to a surface, which leave a panel rather than the page: the Embedded MPV dock panel and the alternative-sources panel inside the VOD "…" menu.
  3. The M3U player sidebar's Home button, which renders only outside the workspace shell.

Context Panel Rules

The shell decides which secondary panel to show from the current route:

  1. /workspace/sources
    1. WorkspaceSourcesFiltersPanelComponent
  2. Xtream category sections (live, vod, series)
    1. WorkspaceContextPanelComponent
  3. Stalker category sections (itv, radio, vod, series)
    1. WorkspaceContextPanelComponent
  4. /workspace/settings/:section
    1. WorkspaceSettingsContextPanelComponent
  5. Downloads sections
    1. WorkspaceCollectionContextPanelComponent

The context panel is part of the shell contract. New workspace-level routes should explicitly decide whether they need one rather than adding local sidebars inside feature pages.

Xtream and Stalker category panels preserve provider/server category order by default. The panel header exposes a sort menu next to category search with Server sorting, A-Z, and Z-A; when alphabetical sorting is active, synthetic "all categories" entries stay pinned before sorted provider categories. Search and sort render disabled while the categories load (beside the disabled Manage categories button), so the header row has its final width under the skeleton instead of growing by two buttons when the list arrives; they are hidden only once a load ended with no categories.

On live sections the category panel can be folded away independently of the channels list; the folded panel is reachable as a popover from the channels header through the LIVE_CATEGORIES_POPOVER token the shell provides. The three nested levels, their affordances and persistence are specified in iptvnator-ui-guidelines.md ("Collapsible Live Sidebar").

A category click in a LIVE section (Xtream live, Stalker itv and radio) changes only the selected category: the live layouts gate their player on the store's selected item, so the handler must not clear it — the channel the user is watching keeps playing while the sidebar re-filters (Xtream: #936; Stalker: onStalkerCategoryClicked returns before clearSelectedItem()). VOD and series category clicks do drop the open detail (setSelectedItem(null) / clearSelectedItem()) because they navigate to a list route.

The channel header offers Show playing channel when browsing excludes the active channel. It returns to that category and focuses the row without restarting playback; remote commands retain captured playback order. See the queue and reveal contract.

Search And Navigation Rules

Search is shell-owned and route-aware:

  1. On settings routes, searches the settings themselves (see Settings search).
  2. Enabled on sources routes.
  3. Enabled for /workspace/search, which is the Electron-only routed global-search view. Ctrl/Cmd+F in Electron opens this route and focuses/selects the header search input instead of opening a fullscreen dialog.
  4. Enabled for supported Xtream and Stalker content/search views.
  5. Placeholder text and search handling vary by provider and section.
  6. Input changes are debounced before route/store updates are applied.
  7. Global search uses the header input as its primary input and writes the search phrase to the q query parameter, so history/back-forward behavior matches the rest of the workspace.
  8. The URL is authoritative for the search box only when it carries search intent. WorkspaceShellSearchSyncService re-reads q on every NavigationEnd, but an app-initiated navigation that stays on the same page and carries the term already applied is always ignored — whether or not a debounce is still pending. Otherwise a page writing an unrelated query param (a downloads filter chip, a refresh bump) or the router echoing back our own trimmed q would reset the box to the applied term, eating everything typed since: the whole word while the first keystroke is still debouncing, or a just-typed trailing space once the debounce has fired ("Bein " would snap to "Bein" and typing on would yield "BeinSports"). Applied terms are always stored trimmed (applySearchQuery and setSearchState both trim), so the echoed q compares directly; the box keeps exactly what the user typed. Pages are free to write their own query params while the user types; they must not assume the shell will re-apply the search afterwards.
  9. Browser history overrides that guard. The exemption is keyed on Navigation.trigger === 'imperative', so back/forward always re-applies what the history entry carries, even mid-typing.
  10. Applying a term explicitly supersedes a queued one. applySearchQuery() cancels any pending debounce, so the Enter key committing a trimmed term cannot be overwritten a moment later by the untrimmed keystroke still waiting behind it.

Rail navigation is also shell-owned:

  1. Workspace-global entries are static.
  2. Provider entries come from buildPortalRailLinks(...).
  3. On dashboard, sources, settings, global search, global favorites, and global recent, the shell falls back to the currently selected playlist so provider navigation remains available even outside a provider route.

Command palette behavior is shell-owned but view-extensible:

  1. The shell resolves commands into groups in fixed order: current view, this playlist, global, then settings. The settings group appears only for a non-empty query and holds at most six settings matches (see Settings search).
  2. Shell-owned commands are derived from route context and current playlist state; empty groups are omitted instead of rendering disabled placeholders.
  3. Workspace features contribute current-view commands through WorkspaceViewCommandService.
  4. Header shortcut actions can opt into palette exposure by attaching palette metadata through WorkspaceHeaderContextService.
  5. Filtering matches command labels, descriptions, and keywords, and keyboard selection always lands on the first enabled command.
  6. A "Recently used" section is rendered above the standard groups when the query is empty and at least one stored id resolves to a visible+enabled command; ids are persisted via RecentCommandsService (capped at 5, stored at STORE_KEY.RecentCommands). Storage is not pruned by route visibility — a navigation command like Open sources is invisible while the user is on /workspace/sources but the id stays in storage so it reappears in the recent section after navigating away.
  7. Six "Switch player to X" commands are registered globally by WorkspacePlayerCommandsContributor (VideoJS, HTML5, ArtPlayer, Embedded MPV, MPV, VLC). Each command carries a requires flag gating its visibility: the MPV/VLC ("managed-external") entries are visible only when RuntimeCapabilitiesService.supportsManagedExternalPlayers is true, and the Embedded MPV ("embedded-mpv") entry is visible only after the command palette lazily preloads an async window.electron.getEmbeddedMpvSupport() check and it resolves to supported (mirroring the Settings dropdown gate). Do not run this Embedded MPV support check from workspace shell bootstrap: supported desktop builds may load the native addon while resolving capabilities. The entry matching the current SettingsStore.player() value is disabled. The new player setting applies to the next playback session; an existing stream is not re-mounted.

Settings rows are searchable from the header search on /workspace/settings and from the command palette. Both use the same index and ranking.

  1. The index is SETTINGS_SEARCH_ENTRIES in libs/workspace/shell/util/src/lib/settings-search/, published through the @iptvnator/workspace/shell/util/settings-search sub-entrypoint. Eager code imports the main shell util barrel, so the index stays out of it and ships only in lazy chunks (the initial-bytes ratchet enforces this).
  2. Each entry names its section, title and description translation keys, untranslated synonyms (keywords), runtime requires, and an optional fallbackId. Section definitions (SETTINGS_SECTION_DEFINITIONS) are the single source for the settings navigation too.
  3. Every titled .setting-item in the section templates carries data-setting-id. settings-search-registry.spec.ts fails when a row, id, title key or description key drifts from the index, so a new settings row must be added to the index in the same change.
  4. SettingsSearchService.search() matches the translated title and description of the current language plus the keywords; every query token must match (AND), and a label prefix outranks a word start, which outranks an inner match. Rows whose requires the runtime lacks are never returned. Embedded MPV rows depend on a lazy support probe (ensureEmbeddedMpvSupportLoaded()), run when the settings page or the command palette opens, never from shell bootstrap; frame copy also needs frameCopyAvailable, matching the settings page gate.
  5. Settings routes use local-filter search mode, so the term lives in q. While q is set, the settings page shows ranked results in place of the section page and the settings context panel shows per-section match counts, muting sections without matches. The search box is shown on settings even when no playlist exists.
  6. Choosing a result, pressing Enter in the header search (best match), or picking a settings command in the palette calls reveal(): it navigates to the section page without q (which clears the box) and the page scrolls to, focuses, and briefly highlights the row once the form is hydrated. A row hidden by the current form state falls back to its fallbackId, the control that makes it appear. A reveal must win over the typed term: WorkspaceShellSearchSyncService drops a keystroke still waiting for its debounce through onReveal(), and Enter does not apply the term first, because either q sync navigation would supersede the reveal navigation. Keyboard users keep a :focus-visible ring on the row after the highlight fades.
  7. Ctrl/Cmd+F on settings focuses the header search instead of opening global search.

Keyboard shortcut help is shell-owned:

  1. WorkspaceKeyboardShortcutsService is provided by WorkspaceShellComponent. It owns the workspace-scoped document:keydown listener for ? / Shift+/.
  2. The listener ignores events from inputs, textareas, selects, and content-editable elements via isTypingInInput(...).
  3. libs/portal/shared/util/src/lib/keyboard-shortcut-definitions.ts is the metadata registry for shortcuts shown in the help dialog and documented in README. keyboard-shortcuts.ts owns the display transformation and help trigger detection. Shortcuts that only work through the Electron bridge, such as embedded MPV controls, must set electronOnly: true so the PWA dialog does not advertise unavailable commands.
  4. New custom shortcuts should be added to that registry when the handler is added. Do not include native browser/editor behavior such as Tab or platform text editing shortcuts.

Window Chrome And Custom Title Bar

The Electron window hides the native title bar on all desktop platforms (titleBarStyle: 'hidden' in apps/electron-backend/src/app/app.ts):

  1. macOS keeps the native traffic lights (titleBarOverlay: true, trafficLightPosition from MACOS_TRAFFIC_LIGHTS_POSITION in @iptvnator/shared/interfaces); the renderer draws no window buttons. The lights sit in the header band (--workspace-header-band, 56 px) over the rail and the header's leading padding. The macOS rail (.app-rail.is-macos) starts its first link below the band: level with the content area and the dashboard hero, with its hover surface clear of the lights. The header's content starts 84 window pixels from the window's left edge (60 px rail plus 24 px padding). macOS 26 ends the lights at 76 (earlier releases at 68), which is where Back's left edge sits at 100 % because of its 8 px pull-in.

    App zoom (see "Zoom level") scales CSS pixels but not the lights. On macOS, TrafficLightsClearanceDirective on .workspace-shell reads the page zoom factor (outerWidth / innerWidth, refreshed on resize) and publishes the clearance in CSS pixels as --traffic-lights-clear-x (84 window pixels) and --traffic-lights-clear-y (48: the lights' bottom plus a gap). Zoomed out, the band grows to the vertical clearance, so the lights never overlap the content area. The header's leading padding grows to the horizontal clearance, less the rail column (--workspace-header-lights-inset). At 100 % both match the default layout. Off macOS nothing is published and the defaults apply. The phone layout, which puts the rail in a row above the header, ignores the inset. window-controls.e2e.ts ("macOS traffic lights") checks the rail alignment, the first header control (the switcher on the first page, which has no history fallback yet, then a detail page's Back) and the content top at default and minimum zoom.

  2. Windows and Linux use renderer-drawn window controls (app-window-controls, libs/ui/components/src/lib/window-controls/). frame is intentionally left untouched so native resize borders and window snapping keep working.

The controls are mounted once in app-root (not inside the workspace header) as a position: fixed top-right overlay so they stay clickable above full-window content such as Material dialog backdrops — the same behavior as the macOS traffic lights. Because CDK overlays render as popovers in the browser top layer (above any z-index), the component host is itself a popover="manual" element: it enters the top layer on init and re-enters it (hide + show) whenever another popover opens, so the controls always paint last. The z-index: 10000 remains only as a fallback when the popover API is unavailable. They render only when RuntimeCapabilitiesService.usesCustomWindowControls is true (Windows/Linux Electron with the window-control bridge methods available); the PWA and macOS never mount them.

IPC contract (constants in libs/shared/interfaces/src/lib/ipc-commands.ts, handlers in apps/electron-backend/src/app/events/window.events.ts):

  1. WINDOW:MINIMIZE, WINDOW:TOGGLE_MAXIMIZE, WINDOW:CLOSE, WINDOW:GET_STATE are ipcMain.handle channels resolved from the sender WebContents. Close goes through win.close() so the existing window-bounds persistence in app.ts still runs.

  2. WINDOW:STATE_CHANGED is pushed main → renderer on maximize/unmaximize and on the fullscreen events — enter/leave-full-screen plus the enter/leave-html-full-screen variants emitted for HTML-element fullscreen (video player fullscreen) — so the maximize/restore glyph stays correct for externally triggered changes (double-click on a drag region, OS snap, F11). The controls hide themselves while the window is fullscreen.

    Window state is never re-read at event time. attachWindowStateEvents seeds { isMaximized, isFullScreen } once at window creation and each event patches only the flag it names; every push carries a copy of that tracked state. On Windows both getters can still report the pre-transition value while the matching event fires — isFullScreen() stays true during an HTML fullscreen exit, and isMaximized() reads false while the window is fullscreen. Because the renderer replaces both flags on every push and no later event corrects a stale one, polling left the controls hidden forever after leaving fullscreen and stuck the maximize/restore glyph on the wrong icon. Regression coverage: app-window-state.spec.ts and window-controls.e2e.ts.

    The pushed isFullScreen is the OR of two flags tracked apart: native (OS-level, fed by enter/leave-full-screen) and HTML-element (fed by the *-html-* pair). Electron remembers when the window was already natively fullscreen before the player entered HTML fullscreen and then leaves ONLY the HTML state on exit — no leave-full-screen fires and the window stays fullscreen — so a single flag cleared by leave-html-full-screen would un-hide the controls over a window that is still fullscreen. A fullscreen launch (below) or F11 followed by the player's F → Esc makes that path routine on Windows/Linux.

  3. WINDOW:TOGGLE_FULLSCREEN toggles OS-level fullscreen (setFullScreen) and, like the maximize toggle, reports the requested state and leaves the WINDOW:STATE_CHANGED push authoritative. Because the transition is asynchronous — isFullScreen() reports the old value until it lands, and on Windows even while the matching event fires — a toggle must never be decided against the getter. Every native fullscreen request goes through the tracker in apps/electron-backend/src/app/services/native-fullscreen-transitions.ts (the F11 toggle AND the startup fallback below). Like the state pushes above it keeps its own per-window fullscreen state, seeded from the getter once at window creation (trackNativeFullScreen in initMainWindow, while no transition can be in flight) and fed afterwards only by the enter/leave-full-screen events, which also cover transitions the app did not request. A toggle is decided against the pending target while a transition is in flight, else against that tracked state: two quick presses are an enter-then-exit, not two enters, and F11 during the startup animation leaves fullscreen instead of asking for it again. The tracker observes and never issues a request on its own. The pending record is the LATEST requested target: an event landing on it clears it, an event landing on the other state (an earlier request of a burst landed first; ours is still queued) leaves it in place so the next press still follows the user's latest intent, and a record older than FULLSCREEN_TRANSITION_TIMEOUT_MS (2 s) is ignored. Events cannot say which request they belong to, so any automatic "repeat the target" on a mismatch is indistinguishable from reversing the user's own green-button/Ctrl+Cmd+F action and is deliberately not done: should a platform ever drop a queued request (Electron queues them on macOS and applies them synchronously elsewhere), the record expires, the event-fed state takes over and the next press corrects the window. The renderer binds it to F11 in WorkspaceKeyboardShortcutsService (deliberately not gated by isTypingInInput — it must work from any focus, because it is the only exit from a fullscreen launch on Windows/Linux, where the title bar is hidden and the controls hide themselves) and skips the key while document.fullscreenElement is set, since the player's own F / Esc own HTML fullscreen and F11 must not yank OS fullscreen out from under it. Without a bridge (PWA) F11 is left to the browser.

Startup window mode (Settings.startupWindowMode, issue #1455):

  1. normal (default) / maximized / fullscreen, chosen in Settings → General ("Window on startup"). Electron only — the select renders only when RuntimeCapabilitiesService.supportsStartupWindowMode sees both updateSettings and toggleFullScreenWindow on the bridge, so the mode is never offered without its F11 exit.
  2. Settings live in the renderer's IndexedDB, which the main process cannot read at window creation, so the SETTINGS_UPDATE handler mirrors the value into electron-conf (STARTUP_WINDOW_MODE, the same pattern as the frame-copy flag) and initMainWindow reads it synchronously. A change therefore applies on the next launch. Both sides normalize through normalizeStartupWindowMode, so junk never reaches the config file or the window options.
  3. fullscreen is the BrowserWindow constructor option: on Windows/Linux the window is created hidden and enters fullscreen before its first paint. macOS ignores the option while the window is hidden (an NSWindow only toggles fullscreen once it is on screen), so the first show repeats the request with setFullScreen(true) right after show() wherever isFullScreen() is still false — never unconditionally, or the platforms that honoured the option would animate a second toggle. The saved bounds stay spread into the options — they are the normal bounds the window returns to, and the close handler keeps persisting getNormalBounds(). maximized calls maximize() right before the first show(), never earlier: maximize() on a hidden window shows it, and a blank window would flash. That first show happens at ready-to-show or the main frame's did-finish-load, whichever comes first (services/main-window-first-show.ts): on Linux a hidden window whose startup scripts ran before its first frame gets the next one about a second later, so ready-to-show alone left the window off screen and the splash's animation frame waiting. At did-finish-load the inline splash is parsed, and the window's backgroundColor is the splash colour (MAIN_WINDOW_BACKGROUND_COLOR, keep it in sync with #initial-splash in apps/web/src/index.html), so showing before the first paint does not flash.
  4. iptvnator --fullscreen (read via app.commandLine.hasSwitch, so it can sit anywhere in argv; the playlist-path extractor already skips every --prefixed argument) forces fullscreen for that launch only and is never persisted. Resolution lives in apps/electron-backend/src/app/services/startup-window-mode.ts. The switch is consumed by the first window (launchFullscreenSwitchConsumed in App): on macOS the process outlives its last window and the Dock re-creates it through the same initMainWindow, which must then follow the stored setting only. A second-instance launch carrying the switch is ignored — the window already exists.
  5. Deliberately not offered: kiosk mode (removes the exit path) and "remember last state" (bounds persistence stores normal bounds only; an explicit choice is clearer). Regression coverage: app.spec.ts ("startup window mode"), settings.events.spec.ts, window.events.spec.ts, and the startup-window-mode cases in settings.e2e.ts.

Zoom level (Cmd/Ctrl and +/−/0, issue #1109):

  1. The packaged renderer runs under file:// with path routing. Chromium keys per-host zoom by the FULL URL when a URL has no host, so every pushState to another section owns a separate zoom entry: after a route change webContents.getZoomLevel() already reports that entry (usually 0) and the next visual-properties sync — a window resize, a display change — snaps the renderer back to it. Chromium persists those per-URL entries in Preferences on its own, which is why the level used to "appear briefly" on index.html at startup and then reset. Dev mode (http://localhost:4200) is per-host and never shows this, so only a packaged or ELECTRON_IS_DEV=0 run can verify zoom behaviour.
  2. Restore therefore happens in the preload, not the main process: applyPersistedZoomLevel (api/preload-zoom-level.ts) asks for the stored level over the synchronous WINDOW:GET_ZOOM_LEVEL IPC at preload start and applies it with webFrame.setZoomLevel, which installs a TEMPORARY, frame-bound zoom level. It survives in-page navigation and resizes, the zoom shortcuts (point 4) step it through the same call, and getZoomLevel() reports it regardless of the route. webContents.setZoomLevel from the main process would write the per-URL entry and re-create the bug. When nothing is stored the preload re-applies the current level for the same reason: entering temporary mode makes the first zoom shortcut URL-independent too. A failed request is swallowed and only costs this load its restore. The apply is deferred to DOMContentLoaded — never at preload start and never from a setTimeout: on Linux and Windows a webFrame.setZoomLevel that early leaves the hidden window without a first frame, ready-to-show never fires, show() never runs, and the renderer gets no animation frames (the splash main.ts removes in a requestAnimationFrame stays). macOS is unaffected and CDP-driven tests force frames, so only the packaged Linux/Windows E2E asserting the splash is gone caught it (legacy-playlist-migration.e2e.ts, defer-epg). After the parser finishes the call is harmless and still lands before the first Angular paint. The preload then sends WINDOW:ZOOM_LEVEL_APPLIED.
  3. Chromium never persists temporary zoom, so services/window-zoom-level.ts owns the electron-conf key ZOOM_LEVEL: the applied acknowledgement (not the request — between the two the sender's getZoomLevel() is still the per-URL default) marks the sender as owning the level, persistZoomLevel writes it back from the window close and app before-quit handlers (the bounds-only saves of before, folded into persistWindowState), and attachZoomLevelPersistence also writes it on every main-frame cross-document did-start-navigation — a reload drops the temporary level, and by did-finish-load the new document's preload has already read whatever was stored. That navigation also releases ownership until the next preload answers, so a close mid-reload cannot save the per-URL default over the user's level.
  4. The shortcuts are a renderer key binding, not a native menu: the Windows/Linux window calls setMenu(null), so no accelerator could reach it there. WorkspaceKeyboardShortcutsService (libs/workspace/shell) listens on the document like it does for F11 and resolves the chord with resolveZoomShortcutAction (libs/portal/shared/util): Cmd on macOS, Ctrl elsewhere, never Alt; +/= (so Ctrl+= and Ctrl+Shift+= both zoom in), -/_, 0, and the numpad +/-/0 (by code, since a NumLock-off 0 reports Insert). Keys are matched by event.key, so non-US layouts zoom with their own +/- keys. Like F11 it is not gated by the typing-target check — browsers zoom from any focus — and a key another handler already preventDefaulted is left alone. The binding calls the synchronous, preload-local window.electron.adjustZoomLevel (adjustFrameZoomLevel in api/preload-zoom-level.ts), which steps the frame's temporary level through the same webFrame.setZoomLevel as the restore and returns the level applied — never a main-process webContents.setZoomLevel, which would re-create the per-URL bug. The step and limits live in libs/shared/interfaces/src/lib/zoom-level.util.ts (stepZoomLevel): 0.5 per press, Electron's own zoomIn/zoomOut role step (≈10 %), clamped to levels −4…6 (≈48 %…299 %, inside Chromium's 25–500 %), off-grid levels snapping to the next grid point in the pressed direction; Ctrl/Cmd+0 returns to level 0. A stored level already outside the limits (the macOS menu roles never clamped) is never moved against the request: a press further out leaves it, a press back in lands on the limit. Persistence needs nothing extra: the main process reads the live level back (point 3). On macOS the default application menu still carries the zoomIn/zoomOut/resetZoom roles, but Chromium hands a key equivalent to the web contents first and Electron performs the menu equivalent only in WebContents::PlatformHandleKeyboardEvent (shell/browser/api/electron_api_web_contents_mac.mm, Electron 43.3.0), the unhandled-keyboard-event hook — a preventDefaulted keydown never gets there, so the binding keeps one press at one step. CDP-dispatched keys (the E2E) never reach the menu at all. Without a bridge (PWA) the browser keeps its own zoom, and the help dialog lists the chords as Electron-only. Regression coverage: window-zoom-level.e2e.ts presses the real shortcuts (in, out, numpad, reset) and measures the rendered factor (content width ÷ window.innerWidth) across a section change, a resize, a reload and a restart; key resolution and the bridge step are unit-tested in keyboard-shortcuts.spec.ts, workspace-keyboard-shortcuts.service.spec.ts and preload-zoom-level.spec.ts.

Reloading the renderer on an in-app route:

  1. The packaged renderer is dist/apps/web/index.html over file:// and Angular routes by path (no hash strategy), so once the user is on a section the document URL is file:///…/web/workspace/sources — a path with no file behind it. A reload of that URL fails with ERR_FILE_NOT_FOUND (-6) or is cancelled outright, depending on who starts it. Two user-reachable triggers: the macOS default application menu (nothing calls Menu.setApplicationMenu, so View › Reload / Force Reload are live; Windows/Linux drop the menu bar via setMenu(null)), and the settings unsaved-changes guard, which calls window.location.reload() after the user confirms a reload intent on /workspace/settings/<section>. Dev mode (http://localhost:4200) never shows either — the dev server serves the index for every path.
  2. Both legs live in services/renderer-reload-fallback.ts and end in the same restoreRendererRoute: load the packaged index with the routed URL's route — its path relative to the renderer root plus query and fragment (resolveRoutedRendererUrl) — in the restoreRoute query parameter.
    • A main-process reload (webContents.reload(), the menu role, DevTools) fires no will-navigate, so it cannot be redirected up front: it fails, Chromium commits chrome-error://chromewebdata/ and app-root stays empty until the app restarts. attachRendererReloadFallback recovers it after the fact from the main-frame did-fail-load with ERR_FILE_NOT_FOUND (resolveReloadedRendererRoute); other error codes, subframes and non-file: URLs are left alone. The recovery load is deferred to the error page's dom-ready and never issued from inside did-fail-load: a loadFile started while Chromium is still committing the error page yields a document that never receives animation frames — the splash stays, nothing paints, while document.visibilityState still says visible — and the same load after dom-ready paints normally (Electron emits did-fail-load before that dom-ready). A cross-document navigation starting in between withdraws the pending recovery, so a stale dom-ready can never re-load the index over a newer navigation.
    • A renderer-initiated reload (location.reload(), the settings guard) does fire will-navigate, where the routed URL is not the trusted index and handleRendererNavigation would cancel it — silently, so the confirmed reload simply never happened. The handler now recognizes a routed renderer URL and sends it straight to the index with its route, with no failed load in between; every other untrusted navigation is still blocked (external URLs still open in the browser). A failed index.html itself is never re-requested (it would loop): resolveRoutedRendererUrl rejects the index, and the recovery load carries index.html as its path, so a second failure cannot recurse.
  3. The renderer consumes the parameter before Angular bootstraps: apps/web/src/main.ts calls resolveRestoredRendererRoute (libs/shared/interfaces/src/lib/renderer-reload-route.util.ts, which also owns the parameter name) and installs the result with history.replaceState, so the router's initial navigation lands on the route the user was on. The route is resolved against document.baseURI (the packaged <base href="./">, i.e. the renderer directory — the same prefix Angular strips from location.pathname), and anything that would leave that directory (an absolute URL, another scheme, a .. escape) is dropped with only the parameter removed, so the app boots at its default route instead of following an arbitrary target.
  4. Zoom persistence is unaffected: the failed reload's did-start-navigation already saved the level and released ownership, the recovery load's did-start-navigation is then a no-op, and the new document's preload restores the level as after any other reload. The main-process close guard also treats the recovery like any full navigation (did-navigate disarms it).
  5. Regression coverage: renderer-reload.e2e.ts reloads from the main process (webContents.reload(), the menu role) on Sources and from the renderer (window.location.reload(), the settings guard) on a settings section and asserts a NEW document is rendered on the same route with the parameter gone (renderer-reload.support.ts marks the old document, since the URL alone is identical before and after); window-zoom-level.e2e.ts reloads the same way. Unit coverage: renderer-reload-fallback.spec.ts, renderer-reload-route.util.spec.ts, app.spec.ts ("renderer reload recovery").

Layout integration:

  1. document.body gets a frameless-platform class (set in AppComponent, same mechanism as dark-theme) — body-level so rules also reach cdk-overlay content rendered outside app-root.
  2. apps/web/src/styles.scss reserves padding-right: 150px in the top-aligned drag region (.workspace-header) for the 3 × 46px button strip.
  3. Button colors follow the theme via CSS variables (--app-on-surface, --app-hover-overlay); the close button uses the Windows-style red hover (#e81123). No theme IPC is involved.

Window decorations on Linux (shadows, corners):

  1. Hiding the title bar removes the window manager's decorations, so the shadow/rounded corners must come from client-side decorations (CSD). Electron 43 enables rounded corners by default when the Linux desktop environment supports CSD. GTK drop shadows and extended resize boundaries remain environment-dependent; Electron selects native Wayland automatically on Wayland sessions.
  2. Linux environments without CSD support can still show a square, undecorated window. Windows 11 keeps its DWM rounded corners and shadow because the standard frame is retained.

Toolchain notes for the Electron 43 baseline:

  1. better-sqlite3 remains pinned exactly so native dependency updates happen deliberately with database-worker, packaging, and Electron E2E validation. Version 13 uses Node-API and ships its supported platform binaries in the package. It must not be listed in pnpm's onlyBuiltDependencies: forcing an implicit node-gyp rebuild bypasses those binaries and makes installation depend on the host compiler toolchain. The old node-abi override belonged to v12's removed prebuild-install path and is no longer required.
  2. Local development supports Node 22.22.3–22.x or 24.15.0–24.x, declared in engines as ^22.22.3 || ^24.15.0. Use .nvmrc (currently 22.23.2) for development and CI. Angular 22 sets this supported LTS range and requires TypeScript >=6.0 <6.1. electron-builder 26.15.7 also pulls @electron/rebuild 4, which requires Node 22.12 or newer, and the root postinstall runs install-app-deps on every pnpm install. The 26.15.7 minimum also carries the v26 backport that fully extracts the Snap template's .tar.7z payload; 26.15.0–26.15.6 can produce a Snap that is missing desktop-init.sh.
  3. Dependabot keeps Electron, native database, packaging, EPG parser, and version-locked Shaka/mpegts updates out of the shared npm minor/patch group. Those dependencies require standalone PRs so their dedicated package, worker, playback, and diagnostic-contract validation cannot be hidden by an unrelated grouped update. Electron majors are ignored: Electron 44 ships no linux-armv7l binary, and the release still packages armv7l AppImage, DEB and Snap builds. Remove the ignore together with those targets when 32-bit ARM support is dropped.
  4. Electron 42 and newer download their development binary on the first Electron command instead of during package postinstall, so electron must not remain in pnpm's onlyBuiltDependencies allowlist. The first local pnpm run serve:backend can include a one-time download; use pnpm exec electron --version to prewarm it before an offline run.

Known caveats:

  1. DIY buttons cannot show the Windows 11 Snap Layouts flyout (only native caption buttons or the Window Controls Overlay get that).
  2. Double-click-to-maximize on drag regions is handled natively by Electron/Chromium; on Linux the exact behavior depends on the window manager.

Maintenance Guidance

Use this document as the source of truth when changing workspace shell behavior.

  1. New top-level user destinations should default to child routes under /workspace.
  2. Shared provider navigation logic belongs in portal-shared util/UI libraries, not duplicated inside the shell.
  3. If a provider route changes how playlist/session bootstrap works, update the route-session provider and shell-facing route contract together.
  4. When adding a non-native keyboard shortcut, update the shared shortcuts registry, the help dialog tests, README, and the closest behavior test.
  5. Historical migration notes, cleanup lists, and one-off refactor steps should stay out of this file; track them in issues or PR notes instead.