Files
iptvnator/docs/architecture/iptvnator-ui-guidelines.md
T
4grayandClaude Opus 5.5 90a31d9be9 feat(sources): redesign the Sources page and the playlist switcher (#1932)
* feat(sources): add the shared source row and credential-free formatting

One app-source-row (regular for the Sources page, compact for the
playlist switcher) with an app-source-tile, built on pure helpers:

- resolvePlaylistSourceKind: one provider rule for icons, filters and
  counts (the list filter and the panel counts used to disagree).
- sourceDisplayName / sourceDisplayUrl: a name or URL never shows a
  password, token or MAC. URL imports that stored
  "get.php?username=…&password=…" as the title now show the host.
- stripUrlCredentials (shared/logging): drops what the log redactor
  would mask, plus short user/pass keys.
- formatSourceDate / sourceMetaParts / sourceStatusTag: one meta line
  order and Intl dates and durations in the UI language.
- SourceStatusService + SourceHealthHistoryService: one status per
  source (desktop health check, browser Xtream portal status) and the
  last conclusive result kept across restarts, so "not responding for
  3 days" survives a relaunch. No URL or credential is stored.

Also fixes playlistNameHasCredentials answering differently on repeated
calls (global regex lastIndex), and adds the UI-kit button, menu and
segmented-control mixins (libs/ui/styles/_app-controls.scss); menus opt
in with class="app-menu", styled by the components that use them.

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

* feat(sources): rebuild the Sources page on the shared source row

- Side list (UI-kit side list): title with the playlist count and an Add
  button, a Type group, and on desktop a Status group (Any / Responding /
  Not responding). The two filters combine and persist; each group counts
  within the other's choice. The chosen row uses one selected-row token
  (soft tint, outlined in light theme).
- Page: a 28px title with the visible count instead of the bordered
  header bar; Clean up and the sort menu are secondary buttons. Clean up
  shows how many sources do not respond. Sort adds Last updated, Most
  channels and (desktop) Not responding first, and keeps Name Z-A and
  Custom order.
- List: app-source-row with hover actions (Refresh, Edit, ⋯); a failing
  row keeps Retry in view. "No matches" offers Reset filters. Dragging in
  custom order no longer reuses positions of rows a filter hides.
- Delete with Undo: no confirmation; the row leaves at once and the delete
  runs when the toast closes (its close button commits at once). Deleting
  the active source switches to the next row. A large Xtream delete shows
  its row as "Removing…" with progress and Cancel, as before.
- The list's refresh flows moved into SourceListRefreshService; E2E and
  performance drivers follow the new markup and delete flow.

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

* feat(switcher): restyle the toolbar trigger and open it with Cmd/Ctrl+P

- The trigger is a 40px secondary button: the active source's tile with
  its status dot, its name and "type · channels" (or the source count),
  and a chevron that turns while open. The name comes from
  sourceDisplayName, so a URL-named playlist no longer shows its URL or
  credentials in the toolbar.
- Cmd+P / Ctrl+P opens the switcher wherever it is shown (Cmd+K stays
  the command palette) and is listed in the keyboard shortcuts dialog.
- The trigger's inline refresh button is gone; refresh stays in the
  playlist's ⋯ menu and the command palette.

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

* feat(switcher): rebuild the playlist switcher as an overlay popover

The switcher's mat-menu becomes a CDK connected overlay
(app-playlist-switcher-panel), 440px wide, with a dimmed, blurred
backdrop; only its list scrolls and row menus open above it.

- Header with the source count, Add playlist and All sources.
- Search by name or host, autofocused; the type filter is one segmented
  control (All / M3U / Xtream / Stalker with counts) instead of three
  checkboxes, remembered between openings (the old checkbox setting is
  migrated). The search is no longer kept between openings.
- "Recent" (the three most recently opened, kept across restarts) above
  "All" in the Sources page order, as compact source rows: the current
  one tinted with a check. Each row's ⋯ menu holds Refresh, Playlist
  info, Account info, Check again and Remove (with Undo); the footer's
  info, account and add actions moved there and to the header.
- Keyboard: a listbox driven by aria-activedescendant from the search
  field. Arrow keys move and wrap, Enter opens, Shift+F10 opens the
  row's menu, Escape closes and returns focus to the trigger. Hints in
  the footer.
- Deleting from a row menu moves focus to the toast's Undo, since the
  row (and its focus) left the page.

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

* refactor(sources): remove the old source row, filter state and dead styles

- Delete app-playlist-item and SourceHealthIndicatorComponent, replaced by
  app-source-row and its tile dot.
- Delete the ngrx type-filter slice (FilterActions, selectedFilters,
  selectActiveTypeFilters); the Sources filters live in
  SourcesViewStateService.
- Drop the empty state's unused "no results" variant and its styles.
- The switcher and Sources list components are no longer in the max-lines
  baseline.
- Docs: UI guidelines (source row, Sources page and switcher, status dot,
  selected-row tokens, UI-kit mixins), the source-health contract and the
  zoneless inventory. Release notes for the redesign and the credentials fix.

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

* fix(catalog): keep the refine menu whole in short windows

The refine menu held 12 rows at 48px plus two section titles (657px).
Angular Material 22 sets no max-height on a menu panel, so below ~800px
the overlay was pushed up over its own Refine button, and at 600px the
last thresholds were clipped behind an invisible scrollbar.

The minimum-rating thresholds now open in a submenu from one row that
names the active threshold. The menu is about 390px and opens below its
button at 600px. The submenu trigger sits in a role="none" wrapper:
Material offsets a submenu by the first direct mat-menu-item's
offsetTop, and with the sort rows inside a role="group" that item was
the trigger itself, which opened the submenu 335px off screen.

The Electron E2E opens both menus by keyboard in a 600px-tall window
and checks that each lies whole on screen without scrolling.

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

* style(workspace): drop the side panel's divider line

The context panel beside Sources, categories, Settings and collections
drew a 1px outline-variant border against the content surface, whose
background, shadow and rounded top-left corner already mark the edge;
the line clashed with that corner. The border stays 1px but transparent,
so the panel width, the persisted sidebar widths and the resize handle's
position do not move. The handle keeps its hover and drag highlight.

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

* fix(switcher): keep the search field and type filter whole with many sources

The switcher popover is a height-capped flex column. With a long list
(hundreds of sources) the browser shrank every part in proportion to its
size, not only the list: the type filter, which clips its overflow,
collapsed to 2px and showed no labels, and the search field dropped from
36px to its 21px text line. The header, search, type filter, empty state
and footer are now flex: none, so only the list shrinks and scrolls.

The search field now matches the header and side-panel search fields:
the shared --app-search-* tokens, a 10px radius, an 18px icon, the same
text size and focus halo.

The Electron E2E opens the switcher in a 320px-tall window and checks the
search height, the type toggles and the scrolling list; it fails without
the fix (search 21px).

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

* fix(sources): checks behind filters, keyboard Undo in the switcher, check causes

Local review round 1 (Codex + Greptile) on the redesign:

- A saved Responding / Not responding filter hid every source without a
  result, and the hidden rows were what started the checks, so the list
  stayed empty. keepSourcesChecked (portal shared data-access) now keeps
  every library source checked while the Sources page lives, on desktop
  where a check is one shared job that rows join.
- Removing from the switcher left its focus trap open around the search,
  outside of which the Undo toast sits: Tab could not reach Undo. The
  popover now closes first; Undo takes the focus and returns it to the
  trigger (SourceRemovalOptions.returnFocusTo). Every switcher action
  closes through one path, so a closing row menu no longer pulls the focus
  back into the popover.
- A running desktop Xtream delete brought its source back into the
  switcher as an ordinary option that could be opened or removed again; the
  switcher now leaves it out until the delete ends (the Sources page shows
  its progress and Cancel).
- Undo re-activated the removed source over a source the user had chosen
  since, when nothing had replaced it (it was the only row a filter
  showed). Undo now restores only while no other source is active.
- The switcher highlight stays on its option, or in range, when the list
  changes under the same search, so Enter still opens something.
- The status dot named every failure "Could not connect". SourceStatus
  now carries the check's cause (access denied, account disabled, timeout…,
  kept with the health history and validated on read), and M3U dots again
  say the check covers the playlist URL, not its channels.

Tests: new specs for keepSourcesChecked, the saved-filter check, the Undo
guard and focus return, the switcher close/highlight/deleting cases and
the dot's cause; the filter and Undo specs fail without their fixes. The
switcher keyboard E2E now deletes and undoes from the keyboard.

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

* fix(sources): own Undo timer, per-connection health history, race-free perf waits

Local review round 2 (Codex + Greptile):

- The delete committed whenever the shared snack bar was dismissed, so
  another message (a refresh finishing elsewhere) cut the four-second Undo
  window short and deleted the source early. The window is now the
  service's own timer; only the toast's close button (commitNow) or a
  second delete commits sooner.
- The health history was keyed by playlist ID only, so an edited address
  or account inherited the old connection's result, failing run included.
  Entries now keep a 32-bit FNV-1a fingerprint of the source health key
  (no URL or credential); a result for another connection starts over and
  a stale entry no longer shows.
- The M3U refresh-cancellation probe still queried the removed row's
  .cancel-btn, .busy-state__message and .action-spinner, so its cancel
  never fired; it now uses the source row's selectors (smoke run passes,
  cancelEffect=true).
- The Xtream delete benchmark waited to catch the "Removing…" row coming
  back before it left, which a fast delete could finish before the wait
  began. The driver now arms a delete-playlist operation window before the
  commit click and ends on its completed event and the absent row.

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

* fix(sources): sort by the shown name; start the benchmark's Undo window in its trigger

Local review round 3 (Greptile; Codex was clean):

- Name order compared the stored title, while the rows show a URL-named
  or credential-bearing source by its host: a source stored as
  get.php?username=… sorted by its hidden account details. sortPlaylists
  takes the list's name function, and Sources and the switcher pass
  sourceSortName, the name they display.
- The Xtream delete benchmark clicked Remove while preparing, which starts
  the four-second Undo window; capture setup before the trigger could
  outlast it, so the delete committed on its own before the measurement
  and the close button was gone. Preparation now stops at the open menu,
  and the trigger clicks Remove and then the toast's close button, with
  the trigger time taken right before the commit click as before.

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

* fix(sources): never hand over to a leaving source; browser checks per connection

Local review round 4 (Codex + Greptile):

- Removing the active source handed over to its neighbour even when that
  neighbour's own delete was still running ("Removing…"), activating a
  source about to disappear. replacementAfterRemoval (playlist shared
  util) picks the nearest row that is not leaving, on the Sources page and
  in the switcher; SourceRemovalService also refuses a leaving replacement.
- In the browser build a "Check again" for an account the user then
  edited could finish last and overwrite the new account's status under
  the same playlist ID. Portal statuses are now keyed by playlist and
  connection fingerprint, so the old check lands under the old key.
- The switcher scrolled the highlighted option into view through a
  selector built from the playlist ID, which a restored ID with "." or
  ":" broke (or made throw). It now finds the row by its exact DOM ID.

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

* fix(sources): keep ⌘P out of open dialogs; redact refresh errors

Local review round 5 (Greptile; Codex was clean):

- Ctrl/Cmd+P opened the playlist switcher over an open dialog (an
  editor, a confirmation), taking the focus out of it and handing it back
  to the toolbar behind the dialog on close. The shortcut now leaves the
  key alone while MatDialog has open dialogs.
- SourceListRefreshService logged a failed refresh's raw error, which can
  carry the playlist URL and its credentials. It now logs through the
  redacting portal logger (createLogger).

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

* fix(sources): show a new search's first match; keep inconclusive check causes

Local review round 6 (Codex; Greptile was clean at 5/5):

- Typing a search or picking a type in the switcher highlighted the first
  match but kept the list scrolled where the previous result left it, so
  the highlighted row could sit above the view. The list returns to the
  top whenever the search or type changes.
- A source with no kept history whose check was inconclusive (connection
  checks paused by the connectivity guard, a cancelled check) lost the
  live cause and time, and its dot said only "Not verified". The status
  now keeps them; a running check still reads as checking.

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

* fix(switcher): fall back to All when the remembered type has no sources left

Local review round 7 (Greptile 5/5 with one P2; Codex was clean): when the
last source of the remembered switcher type was removed while two other
types remained, the list kept filtering by the missing type. Its segment
was gone, no segment was selected, and the list said "Nothing found". The
list now filters by All until a source of that type returns; the remembered
choice is kept.

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

* fix(sources): clean credentials nested in kept URL parts

Codex bot (PR review, P1): stripUrlCredentials copied every non-sensitive
parameter value unchanged, so a URL nested in one (a proxy's
?url=http%3A%2F%2Fjoe%3Ahunter2%40origin.test%2F…) kept its userinfo and
password and reached the Sources row through sourceDisplayUrl.

Kept query and fragment values and path segments are now cleaned
recursively, up to four levels: a nested URL loses its credentials the
same way, a nested parameter list loses its sensitive keys, and a part
that still names a secret (joe:hunter2@host, token=…) is dropped. Deeper
nesting is dropped rather than shown.

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

* fix(sources): keep fragment and nested URL secrets out of source names

Greptile bot (PR review, P1 security): an unnamed import of
https://host.test/list.m3u#api_key=secret123 stored list.m3u#api_key=…
as its title. isUrlDerivedName only knew names ending in a query, and
sourceSecrets only read the top-level query, so the row and the toolbar
showed the secret while the displayed URL hid it.

A name that ends the URL with a query or a fragment is now URL-derived,
and shows the host. The secrets a typed name is checked against now
include sensitive fragment parameters and, as stripUrlCredentials cleans
them, the credentials of URLs and parameter lists nested in parameters
(nested account names included).

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

* fix(sources): keep deletes busy until settled and on screen; refresh E2E via the switcher

PR #1932 round 1 (CI + local Codex/Greptile on the bot fixes):

- CI: category-management.e2e refreshed the active playlist through the
  old switcher trigger's inline refresh button, which the redesign
  replaced with the switcher row menu (⋯ → Refresh playlist). The helper
  now goes through that menu; it failed on all three OS shards.
- A desktop Xtream delete's completed event cleared its progress while
  PlaylistsService still cleaned up (parental lock, stored state), so the
  already-deleted source was actionable again on Sources and back in the
  switcher until the store dropped it. A terminal event now only ends
  Cancel; the row stays busy until the delete settles and the removal
  service clears it.
- A running delete (from the switcher, say) disappeared from Sources when
  the page's saved filters excluded it, hiding its only progress and
  Cancel. Running deletes stay listed whatever the filters.

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

* fix(sources): keep counting a failure's duration while the list stays open

Local Greptile (5/5, one P2) on the PR round: the row's status tag
formatted "Not responding · 3 days" against the time the row mounted (or
the language last changed), so a new failure never gained its duration
and an existing one froze while Sources stayed open. A shared root
SourceClock now ticks once a minute and only the status tag reads it, so
dates and the meta line do not recompute every minute.

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

* fix(sources): keep the page header in view; mark the active source's row

Local Greptile (two P2s) on the PR round, both regressions against master:

- The redesigned Sources page scrolled as one, so the title, Clean up and
  Sort left the view in a long library (master kept a sticky header, and
  the UI guidelines say headers stay and lists scroll). The header now
  stays and only the list below it scrolls, which also keeps a row reached
  by keyboard from scrolling under the header.
- Master marked the active playlist on the Sources page; the new row only
  tinted the current row in the compact (switcher) size. The active
  source's row is now tinted with the same --app-list-selected-* token on
  the page too; the switcher keeps its check.

The design handoff called the header non-sticky and the current tint
switcher-only; both followed master and the guidelines instead.

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

* fix(sources): name the Sources sort group; refit the switcher on resize

Local review on the PR round:

- Greptile: the Sources sort menu's radio group had no accessible name,
  unlike every other menu group (UI guidelines). It is named by its
  section, "Sort by".
- Codex: the switcher popover capped its height once, when it opened, so
  shrinking the window while it was open left the footer and the lower
  rows out of reach. A window resize refits the open popover.

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

* wip: linear URL-cleaning regexes

* fix(sources): linear URL-cleaning patterns; keep the stripper off the startup path

PR #1932 CI:

- CodeQL (polynomial regular expression on uncontrolled data): the
  embedded-URL and keyed-pair patterns in stripUrlCredentials could retry
  from every position of a long run of letters. Matches now start only
  where a name can start (lookbehinds) and every name part is bounded, as
  is the bare-userinfo pattern; a spec times 50,000-character runs. The
  preceding "wip: linear URL-cleaning regexes" commit holds that change.
- Initial bytes ratchet (+135 B over after master's #1928 lowered the
  baseline): stripUrlCredentials sat behind the shared logging barrel,
  which startup code imports, so esbuild placed it in an initial chunk
  although only the lazy Sources page and switcher use it. It now lives
  beside its only consumer in playlist/shared/util source-display, and the
  logging barrel exports the two redaction helpers it needs. Measured
  locally on the merge with master: 1,537,497 bytes, within 1,538,700.

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

* fix(sources): case-insensitive Xtream path markers; no removal during a refresh

- Strip and redact the account segments after `/LIVE/`, `/Movie/` or an
  encoded marker such as `/%6Cive/`, as after `/live/`. The display-name
  check also treats an Xtream path password as a URL secret.
- A source cannot be removed while a switcher refresh clears and refills
  it: the removal service ignores the request, the switcher disables
  Remove, and the Sources page shows the refresh as busy.

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

* fix(sources): search URLs nested in path segments for name secrets

A proxy URL can carry a whole source URL, or a token pair, in one path
segment. The display stripper already cleans such segments; the name
check now collects their secrets too, so a name holding them collapses
to the host.

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

* fix(sources): decode nested URLs before classifying; narrow Xtream paths

- A URL nested in a parameter or path segment is percent-decoded one
  round at a time until it reads as a URL or parameter list, so one
  encoded twice is cleaned too; a value still encoded after the last
  round is dropped. The name check unwraps nested values the same way.
- The two segments after `/live/` and its siblings count as an account
  only when a stream follows them, so a playlist path such as
  `/live/news/list.m3u` keeps its file name in the URL and the title.
- Adding a playlist from the phone drawer closes the drawer first, so
  two focus-trapped modals never stack.

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

* fix(sources): clean URL-shaped fragments; one registry for busy sources

- A fragment is cleaned whatever its shape: `#https://joe:pw@…` loses its
  userinfo, an unclassifiable secret is dropped, and the name check reads
  a URL in the fragment as well as a parameter list.
- Every refresh holds its source in the root SourceActivityService, which
  deletes and the startup auto-refresh already use: Sources-row refreshes,
  the switcher's M3U refresh and the shared Xtream flow. The switcher no
  longer offers Refresh or Remove for a source the page is refreshing, the
  removal service ignores a held source, a second refresh from either
  entry point is skipped, and the page disables Remove while it is held.
- The long-input timing spec allows 3 s: the inputs take about 0.25 s,
  a backtracking pattern takes minutes, and a loaded runner took 1.6 s.

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

* fix(sources): keep a browser refresh busy until the backend answers

The browser build's PLAYLIST_UPDATE returned at once while its request
ran on, so the row stayed refreshable and removable and the shared
registry saw the source as idle. The event now settles when the refresh
does (the PWA service resolves after its success or error report; the
Electron fallback awaits its handler), and both the Sources row and the
switcher keep the source busy and held until then.

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

* fix(sources): name check hides values under short account keys

The name check used the log redactor's key list, which leaves out the
short keys `pass`, `pas`, `user` and `usr` that the URL stripper drops.
Both now share `isDisplaySensitiveKey`, so a name holding `?pass=…`
collapses to the host; `user`/`usr` are account names like `username`
and stay shown at the top level, as by design.

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

* fix(sources): read keys, markers and secrets through every encoding layer

Each check decoded its input once, so one more encoding layer slipped a
secret past it: `?%2570assword=…` kept its value, and `/%256Cive/…`
kept its account segments, both on screen and in logs.

`decodeFully` (shared logging) undoes up to four percent-encoding layers
and is now the one form every name check reads: the redactor's key
normaliser and Xtream marker check, the display key predicate and path
check, and the name check's secret values. Logs and the Sources lists
gain the fix together.

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

* fix(sources): read Xtream paths decoded; keep short decoded forms out

- An Xtream account could hide behind encoded separators
  (`live%2Fjoe%2Fhunter2%2F1.ts` is one raw segment). `findXtreamAccount`
  reads the path as decoded parts, maps them back to raw segments, and is
  the one scanner for the log redactor, the on-screen URL and the name
  check. Account segments go whole, joined ones included.
- The name check required a minimum length only of the raw secret, so a
  decoded form as short as one letter (`%2542` is `B`) hid ordinary
  names. Each form must now be long enough on its own.
- The decoding helpers move to `url-credential-parts.ts`, which keeps the
  redactor under the line limit.

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

* fix(sources): read an Xtream path both whole-segment and split

Reading the path only as decoded parts misplaced the account when the
user name itself held an encoded slash (`/live/joe%2Fx/hunter2/1.ts`):
the password segment stayed on screen and in logs. `findXtreamAccount`
now scans both readings, each raw segment decoded whole and the decoded
path split on every separator, and drops what either finds.

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

* fix(sources): drop JSON configs from shown URLs; read their secrets

A nested value such as `?config={"password":"…"}` was neither a URL nor
a parameter list, and the quoted key slipped past the free-text check.

- A nested value that parses as a JSON object or array is dropped from
  the shown URL (JSON can spell a key so no pattern reads it, such as
  `w`); brackets that are not JSON, like `[1080p]`, stay.
- The free-text check accepts a quoted key (`"token": …`).
- The name check parses JSON values and collects what sits under
  sensitive keys, and follows URLs in its other strings.

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

* fix(sources): hold Xtream sources through re-import; match secrets in any case

- An Xtream refresh released its hold when navigation ended, while the
  re-import ran on from the route session, so Remove came back during
  the import's writes. The route session now takes the hold synchronously
  on NavigationEnd and keeps it until its sync, and the import it awaits,
  settle; the store also holds the source for every content
  initialization, retries included.
- Secret matching was case-sensitive against encodeURIComponent's
  upper-case escapes, so `%3a` or a lower-case MAC slipped through. Text
  and secrets are now compared lower-cased, the text also fully decoded.
- The Xtream `· username` label is left out when it is, or holds, the
  password.

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

* fix(sources): read a bracketed key as parameters unless it is JSON

The name check returned early for any value starting with `{` or `[`,
so `[password]=…` (not JSON) skipped the parameter check and its
password stayed in the name. It now returns early only when the value
parses as a JSON object or array.

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

* fix(sources): no opening a source from the switcher mid-refresh

While an Xtream refresh was still deleting the catalog, ⌘P could reopen
the switcher and selecting that source navigated at once, starting its
import before the user data to restore was parked. The refresh flow now
exposes `isRunning` (its set of runs is a signal), and the switcher marks
a refreshing source aria-disabled and ignores its selection by pointer
and Enter alike. Imports alone do not block opening a source.

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

* fix(sources): hand over only to an idle source; hide names that are a short secret

- Removing the active source could hand over to a neighbour a refresh
  was still clearing, opening it and starting its import early.
  `SourceRemovalService.canTakeOver` (not leaving, not deleting, not held
  in SourceActivityService) now picks the replacement in both lists and
  gates it in the service.
- Secrets under three characters were dropped from the check entirely,
  so a name that was just a two-letter password showed it. Short secrets
  now count as the whole text (trimmed, any case); longer ones still
  match inside a text.

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

* fix(sources): offer Refresh only when it can run; drag handle never opens

- PlaylistRefreshActionService runs one refresh at a time, but
  canRefresh checked only the source, so the switcher, the command
  palette and the dashboard offered a Refresh that refresh() then
  ignored. canRefresh now applies the same global guard.
- In Custom order a click on a row's drag handle bubbled to the row and
  opened the source; the handle now stops it.

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

* fix(sources): no idle Refresh on held rows; Recent counts the filtered list

- The Sources page offered Refresh on a source the startup auto-refresh,
  an import or a delete held, and refresh() then refused it. The row now
  offers Refresh only while it can run, keeping it (with its spinner)
  while that source refreshes.
- The switcher showed Recent whenever the library had six sources, so a
  type filter down to one or two listed them twice and the keyboard
  visited each twice. Recent now depends on the filtered count.

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

* fix(sources): compare passwords in their own case; only a MAC caselessly

Matching every secret lower-cased hid any name holding the password word
in another case: the Electron E2E source "Stress Xtream Portal" (password
`stress`) showed its host, and the deletion test could not find its row.
A password is case-sensitive, so it is compared as written; the text is
still read fully decoded, so `%3a` escapes count as `:`. A MAC is the
same address in either letter case and is compared lower-cased.

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

* fix(sources): match a MAC from the URL in any letter case

Only the source's own `macAddress` was compared caselessly, so an M3U
URL carrying `?mac=…` (nested ones too) left the MAC visible in a name
written in another case. Any MAC-shaped secret, and every form of the
source's own MAC, is now compared lower-cased; passwords keep their case.

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

* perf(sources): keep source-health helpers off the startup path

Master's #1931 lowered the initial-bytes baseline, and this branch then
sat 535 bytes over its slack. Most of its growth was runtime helpers in
`@iptvnator/shared/interfaces` (reachability, health history, reason and
fingerprint helpers) that only the lazily loaded source services call:
the bundler put them in a chunk the app preloads. They now live in
`@iptvnator/portal/shared/data-access` beside those services (types stay
in interfaces); initialBytes 1,350,438 → 1,349,048, 855 under the limit.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:06:23 +02:00

82 KiB
Raw Blame History

IPTVnator UI Guidelines

This document captures the current UI language used across IPTVnator, with emphasis on channel lists, EPG views, settings surfaces, and shared selection patterns.

Use it when changing existing views or introducing new list-based UI in the workspace, Xtream, or Stalker flows.

Core Principles

  1. Prefer shared components over duplicated markup. The canonical channel row is app-channel-list-item.

  2. Drive emphasis through selection state, not through constant decoration. Neutral rows should stay quiet. Only active or current items should pick up strong color.

  3. Use the same selection language everywhere. Selected nav items, channels, and current EPG cards should feel like the same system.

  4. Keep dark and light themes intentionally different. Dark theme can carry more density and tinted surfaces. Light theme should be flatter and cleaner, with white or near-white cards.

  5. Scroll ownership must be explicit. Headers stay visible. Lists scroll. Do not let nested panes compete for scroll.

Canonical References

  • Channel row: libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html
  • Channel row styles: libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss
  • Shared EPG timeline: libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html
  • Shared EPG timeline styles: libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.scss
  • Shared EPG list: libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.ts
  • Shared EPG list styles: libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.scss
  • Shared list selection style: libs/ui/styles/_nav-list.scss
  • Source row (Sources page and playlist switcher): libs/playlist/shared/ui/src/lib/source-row/source-row.component.html
  • UI-kit buttons, menus and segmented control: libs/ui/styles/_app-controls.scss
  • Side lists (title, group labels, single-choice rows): libs/ui/styles/_side-list.scss
  • Theme tokens: apps/web/src/m3-theme.scss
  • Settings surfaces: apps/web/src/app/settings/settings.component.scss
  • Detail view shell styles: libs/ui/styles/_detail-view.scss

Shared Tokens

These tokens are the base for interactive emphasis:

  • --app-selection-color
  • --app-selection-on-color
  • --app-selection-surface
  • --app-selection-surface-strong
  • --app-selection-border
  • --app-selection-glow

Use the app's own surface tokens for neutral surfaces (defined for both themes in apps/web/src/m3-theme.scss):

  • --app-shell-bg / --app-rail-bg / --app-header-bg / --app-content-bg
  • --app-widget-bg / --app-widget-header-bg — panels and popovers
  • --app-card-hover-bg — raised or hovered rows
  • --app-focus-ring — the keyboard focus ring (see Keyboard Focus)
  • --app-widget-border / --app-rail-border — hairlines
  • --app-on-surface — primary text
  • --app-eyebrow-color — secondary/muted text
  • --app-success-color — done/watched status (episode watched toggle)
  • --app-list-selected-bg / --app-list-selected-fg / --app-list-selected-outline — the chosen row of a side list and the active source's row on Sources and in the switcher (see Selection Pattern)
  • --app-line-strong — popover and menu borders, a step above --app-widget-header-border
  • --app-danger-text / --app-danger-surface — a failing source's tag and destructive menu items; --app-neutral-tag-surface for a neutral tag
  • --app-font-mono — URLs and keyboard hints (JetBrains Mono, 400/500)
  • --app-popover-shadow / --app-popover-backdrop — the switcher popover

Angular Material mixins and Material-component overrides may use the tokens owned by that component. Outside a Material-owned component, prefer the app-owned tokens above.

Both themes are built with the legacy mat.define-theme config, whose component mixins never declare the --mat-sys-* system variables. The theme therefore adds the mat.system-level-* mixins for the light (html) and dark (.dark-theme) contexts, and apps/electron-backend-e2e/src/theme-tokens.e2e.ts asserts they resolve in both. Use a --mat-sys-* token for Material-derived roles that have no app token (error, outline, surface containers); keep app chrome on --app-*.

Set Material component tokens through the component's mat.*-overrides() mixin: it rejects unknown names at build time, where a hand-written --mat-* declaration with a typo fails silently. Material 22 reads only --mat-* tokens, so the retired --mdc-* names compile but do nothing; pnpm run styles:material-tokens:validate (CI) rejects them. A stylesheet that a spec loads as raw CSS cannot use Sass modules; it declares the --mat-* token directly and says why.

Material off the startup path

renderer.initialBytes (performance journeys) counts every module an eager file imports, and the bundler keeps a module in one chunk, so one import of one export carries the whole module. Material therefore reaches the initial bundle only through the snack-bar, which root services inject (and with it button, the icon button and the CDK overlay). Everything else is lazy by these rules:

  • Form-field defaults. A component that renders <mat-form-field> adds providers: [provideFormFieldDefaults()] (@iptvnator/ui/components/form-field-defaults); the token is never provided at the root, where its import alone put the 54 KB form-field chunk on the initial path. pnpm run ui:form-field-defaults:validate (CI) requires the provider on every such component and allows the token nowhere else; see the Dialogs and forms notes below.
  • Dialogs from services created with the app. A root service that opens a dialog resolves MatDialog through LazyMatDialog (@iptvnator/ui/components/lazy-dialog) and imports the dialog component on demand, as DialogService, the player service, the parental lock prompt and the workspace shell actions do; openConfirmDialog returns a promise that settles once the dialog is open. A component in a lazy chunk injects MatDialog directly.
  • Panels the shell mounts on every launch. The EPG progress panel and the update notification are an eager host with @defer (when visible) around the view that imports Material; the startup status draws its spinner as static markup copied from mat-spinner. A new always-mounted component follows the same split.
  • Theme scope. m3-theme.scss themes only the components in its $material-components list, through meta.apply for both the light theme and the dark colours. Add an entry with a new @angular/material/<entry> import and remove it with the last one; pnpm run styles:material-theme-scope:validate (CI) checks both directions (core always stays, button brings icon-button and fab).
  • No animations module. Nothing declares an Angular animation trigger and Material 22 animates with CSS, deciding from prefers-reduced-motion itself, so app.config.ts does not call provideAnimations(); the Electron material-motion.e2e.ts checks that dialogs, selects, menus, snack-bars and tooltips still animate in and out and stop under reduced motion. One side effect of the module is gone with it: its renderer deferred every DOM removal to the end of the change-detection tick, so a destroy hook still saw its view attached. Without it Angular detaches the view first, where scrollTop reads 0 and Chromium has already fired a focusout with no related target for the focused control. A destroy hook therefore never reads geometry or focus from its own view: the catalog list tracks the grid offset from scroll events, and the player settings panel decides "focus was inside" in a microtask after such a focusout, which the close destroys the panel before. Both have specs that detach the view first.

The app theme is the .dark-theme class that SettingsService puts on <body>; a "System" choice resolves the OS preference there and nowhere else. Component styles never use a prefers-color-scheme query, which follows the OS: with the app set to dark on a light OS it paints light colours into the dark theme. Read --app-* tokens, which switch with the class, or use :host-context(.dark-theme) for a rule that only one theme needs. The class is on <body>, not the root, so :root:not(.dark-theme) always matches. On a surface, a hairline or thin fill written as white rgba() vanishes on light backgrounds: mix --app-on-surface at the same alpha, and use --app-widget-bg for a card. Every var(--name) read needs a declaration somewhere, or it silently falls back; pnpm run styles:theme-references:validate (CI) rejects both undeclared reads and colour-scheme queries.

Existing hard-coded layout and selection colors are migration debt, not patterns to copy.

Do not hardcode unrelated accent colors for selected state when these tokens already exist.

Player And EPG Theme Boundaries

The native-view Embedded MPV dock is app chrome: its solid widget background, text, separators, sliders and interaction states resolve app tokens together. Material icon buttons override their component tokens, including disabled icons. The dock must never pair a dark fallback surface with inherited light app text. Loader/stall and transient feedback overlays own a light foreground and dark scrim because they cover video. Video viewports remain black in both themes and fullscreen; frame-copy and built-in shared controls keep their light-on-dark overlay palette — the fixed --pc-* token set of the shared dock (accent blue, cyan, violet, the --pc-live / --pc-danger reds and a light text ramp), never the app theme. The overlay styles in player-controls/ never read a --mat-sys-* token, and their keyboard focus is a 2px --pc-text outline rather than Material's theme-coloured focus layer (player-controls-keyboard.e2e.ts checks it in both themes).

EPG timeline, list, empty states and programme details use the library-local libs/ui/epg/src/lib/_epg-theme.scss palette, based on app surfaces, separators, selection and live accents. Text pairs with the actual surface in both themes; current/playing titles must not force white onto a light selection tint. Past programme text remains readable without reducing opacity on the whole card. List loading shimmer uses translucent primary text stops so placeholders remain visible on either theme’s content surface. Theme changes resolve through CSS on the mounted components immediately.

Electron E2E measures app-panel foreground/background contrast (including translucency, ancestor opacity and the timeline’s sibling progress fill), surface brightness and control geometry. Shared overlay icons are separately rasterized over a white test frame to include gradient scrims and Material hover/focus layers in their contrast check. Synthetic media is used for visual artifacts. Native-view video is composited outside Chromium screenshots, so playback is also verified from session position; a black screenshot viewport alone is not proof of failed decoding.

Selection Pattern

Apply the same visual recipe to selected list items, active channels, and current EPG items:

  • Background: linear-gradient(135deg, var(--app-selection-surface-strong), var(--app-selection-surface))
  • Border: var(--app-selection-border)
  • Glow: outer shadow using var(--app-selection-glow)
  • Lift: transform: translateY(-1px) for selected list items only
  • Text: selected text should inherit var(--app-selection-color)

Use var(--app-selection-on-color) when text or an icon sits directly on a solid var(--app-selection-color) fill.

Use this pattern for:

  • .nav-item.selected / .nav-item.active
  • .channel-list-item.active
  • .epg-item.current-program

Do not add extra badges, left rails, or second selection systems unless there is a strong reason.

Side lists styled with _side-list.scss (the Sources filters) and the active source's row (Sources page and playlist switcher) use the Settings navigation's variant instead, through --app-list-selected-*: a soft tint with no lift or glow, outlined in light theme. Its text is the heading colour in light theme (the selection blue on its own tint is 3.3:1) and the selection colour in dark.

Single-Choice Menus And Status Chips

A mat-menu that picks one value, such as a sort order or a rating threshold, uses appMenuItemRadio and appMenuItemRadioCheck from @iptvnator/ui/components:

<button mat-menu-item [appMenuItemRadio]="mode() === option.mode">
    <mat-icon appMenuItemRadioCheck>check</mat-icon>
    <mat-icon>{{ option.icon }}</mat-icon>
    <span>{{ option.labelKey | translate }}</span>
</button>
  • The row becomes a menuitemradio whose aria-checked follows the bound state, so E2E tests select it with getByRole('menuitemradio').
  • Every row renders the check slot as its first icon. Only the checked row shows it. Material projects every <mat-icon> of a menu item, and of a mat-button, ahead of the label. A check rendered only on the chosen row therefore shifted that row's label. In a button, put a trailing icon after the label with iconPositionEnd.
  • A semantic icon, if any, comes after the check slot.
  • A menu with several groups, such as the catalog refine menu, wraps each group in role="group". The group is named by its section title, and role="separator" sits between groups.
  • Keep a menu short enough to open whole below its trigger in a 600px-tall window. Material 22 sets no max-height on a menu panel: a taller menu is pushed up over its own trigger, then clipped. Move a secondary group into a submenu whose row shows the active value, as the refine menu does with its rating thresholds.
  • Material offsets a submenu by the offsetTop of the parent menu's first direct mat-menu-item. When the earlier rows sit inside a role="group", that first direct item is the submenu trigger itself, and the submenu opens off screen. Wrap such a trigger in <div role="none">.

A chip that shows the active state next to its menu trigger, such as the catalog sort chip, shows its icon and a short value at every width, for example "Newest" or "9.0+". Do not swap between a full and a compact label with container queries: long translations still cut off the full label.

Screen readers must still get the full text, such as "Sort: Date Added (Latest First)":

  • A chip that is a button carries it in aria-label.
  • A plain div chip cannot be named, and screen readers may ignore its aria-label. Put the full text in a .visually-hidden span and mark the short label aria-hidden="true".

Detail Views

VOD and series detail screens share app-portal-detail-shell and app-content-hero (libs/ui/components). The hero orders its column as kind label ("Movie · playlist") → title → chips → description (three lines, "More") → resume bar → action row → credits, with the poster bottom-aligned on the left and the backdrop filling the hero behind a two-layer scrim built from --app-content-bg. The hero keeps min(520px, 60vh) of stage for a 16:9 backdrop. The shell gives it a 140px --hero-tail and pulls the first section up over it, so the artwork continues under that section's heading and the vertical scrim ends on the exact page surface there, never as a band at the hero's edge. Without one, or when the provider sends the poster as the backdrop, the hero is compact (hero--compact, sized by its content) over the blurred poster. The layout is decided once per title, so a backdrop that TMDB enrichment adds a moment later fills the compact hero instead of growing it. The pane is a size container (detail); the poster hides below 760px of pane width.

The pieces are shared and provider-neutral (libs/ui/components/src/lib/detail-ui/): app-meta-chip (pill; rating and status variants; facets as projected .meta-chip__facet buttons), app-detail-action-button (the light primary with a two-line label, or the ghost secondary text button), app-detail-icon-button (44px ghost with tooltip and aria-label), app-vod-more-menu (the "…" dropdown: right-aligned, flips upward, arrow keys, Escape, hosts the alternative-sources panel), app-detail-credits ("Starring" + three names + "and more", "Director"), app-cast-crew-row, app-detail-rail/app-similar-rail (hidden scrollbar, prev/next arrows, title + year) and TrailerDialogService. The dashboard hero reuses the same light primary (light-primary-button in libs/ui/styles/_detail-view-actions.scss) and chip, and the same kind of tail: its artwork runs 160px under the first rail, whose heading keeps a trace of it, and reaches the page colour before that rail's cards (their scroll-edge fades are drawn in the page colour), with no tail in the narrow layout. Series titles drop their season marker (splitSeasonSuffix) into a "Season N" chip. Rows a provider cannot serve are left out of the menu, never disabled. The page-level Sass mixin (libs/ui/styles/_detail-view.scss) only carries the page shell, meta items and the episodes section.

With detailTrailerBackdrop on (Settings → Playback → "Play trailers in details background", default off) the hosts hand the trailer embed URL to the shell and app-hero-trailer-backdrop plays it muted and looping under the scrim after three idle seconds, with a 32px mute toggle in the corner. It never starts under prefers-reduced-motion or with saveData, and stops while the hero is off screen or the window is unfocused.

Back Navigation

Page-level Back lives only in the workspace header's leading slot (see Header Back). A routed page, or the shell it renders in, registers it with registerWorkspaceBack() instead of drawing an arrow, so Back keeps one position and one look on every page and never floats over a scroll owner. A page whose Back is history Back calls WorkspaceBackNavigationService.back() with its parent route rather than Location.back(), so Back still leads somewhere when the page opened the session. Without a registration the header falls back to browser history while an in-app previous page exists, and shows nothing otherwise. An arrow that returns within a menu, dialog or player panel is not page navigation and stays in that surface; an error state may repeat the header's Back as a labelled recovery button beside its other actions.

Electron Drag Regions

Every interactive descendant of a drag region—including buttons, links, inputs, overlays, and resize handles—requires app-region: no-drag. The shared directive-generated .resize-handle sets this centrally in resizable.scss. The shared live-layout sidebar reserves 8 px at its right edge so the inward half of the 12 px resize handle cannot cover the channel scrollbar.

Resizable Sidebar Widths

Workspace context panels and Favorites share one sidebar-width key (its legacy aliases map onto it) but have different limits. ResizableDirective renders the stored width clamped to the host's own limits and never writes that clamped value back: only a drag that changes the width persists, so a narrow sidebar such as Settings cannot shrink the width a wider one saved.

A drag starts from the host's computed CSS width, the box that style.width sizes, not from offsetWidth. Hosts may be content-box (the workspace context panels and the Favorites sidebar) or border-box (live-layout and groups rails); starting from the border box widened a padded content-box host by its padding and border on the first move.

Keyboard Focus

  • Always a ring for the keyboard, none for the pointer. apps/web/src/styles.scss removes the outline only under :focus:not(:focus-visible) and draws a fallback ring on every other :focus-visible element: 2px of --app-focus-ring at a 2px offset, from focus-ring-declarations in libs/ui/styles/_focus-ring.scss. Both rules sit in :where(), so any component rule that styles focus wins. Never bring back a global outline: none: pnpm run styles:focus-visible:validate (CI) rejects one on an unscoped selector, and a missing fallback.
  • Token. --app-focus-ring is declared per theme in m3-theme.scss, deeper than the selection blue in light, so it keeps 3:1 on every app surface and selection tint; apps/web/src/m3-theme.spec.ts measures it.
  • A ring of your own. A component that needs the ring elsewhere or at another offset includes the mixin ($offset); a stylesheet a spec loads as raw CSS uses var(--app-focus-ring). The colour is always the token, for every focus indicator: tools/nx/check-focus-ring-colour.mjs, part of the same validate script, reads a focus rule's outline, its unblurred ring or line shadows and its border colours, and rejects any colour other than the token (also inside color-mix()), the player's --pc-* palette or a neutral boundary, except the few exceptions it lists with their reasons. Draw it on the element that owns overflow: hidden (the cards' card-focus-ring), or inside the element (a negative offset) when a clipping parent would cut it. When an ancestor or a box shadow draws the ring, set outline: none on the focused element so it never shows two. Never use outline as a decorative border on something focusable: any outline outranks the fallback, so the element loses its ring (use border or a spread box-shadow, as the selected season tab does).
  • Removing the outline. A component rule that sets outline: none also outranks the fallback, so it must show focus another way: a search field's wrapper takes a token border on :focus-within, optionally with a soft token halo (the command palette underlines its row instead), a ring drawn on another element, or a highlight on an item inside an arrow-key composite.
  • Material. Buttons, switches, button toggles and checkboxes take the ring over their 12% focus state layer (global rules in styles.scss). Menu items and select options keep Material's highlight, since arrow keys, not Tab, move within them.
  • Over video the ring is the player's text colour, not the theme's: the player controls, vendor chrome, the fullscreen channel and episode panels and the Up Next rail include focus-ring-token from _player-palette.scss.
  • Check. keyboard-focus-ring.e2e.ts (Electron) tabs through the detail actions and season tabs, a catalog grid with its refinement chips, the Sources list, the live EPG list, Settings and the command palette in both themes. Each stop must draw exactly one ring in the token's colour, uncut by an ancestor's overflow: hidden, at 3:1 where it sits on a flat colour, and a click must draw none.

Keyboard Scrolling and Channel Focus

ChannelScrollFocusDirective belongs on the actual channel scroll owner, including virtual viewports and nonvirtual Favorites/Recent/Stalker lists. Pointer selection focuses that owner without moving its scroll position. ArrowUp/Down, PageUp/Down, Home/End and Space retain native scrolling there; scroll keys do not bubble into document-level player shortcuts. A row's main button remains separate from favorite/info actions, supports native Enter and Space activation, and retains keyboard focus on activation. Tab/Shift+Tab use the normal DOM order; Safari's default keyboard preference skips buttons on plain Tab, so there the row button is reached with Option+Tab (WebKit E2E runs press it through pressTab in apps/web-e2e/src/e2e-helpers.ts). Scrolling from a virtual row moves focus to its viewport before CDK can recycle the row; asynchronous data updates never move focus. Xtream aligns a newly selected channel only when it is outside the viewport; updates to the same selected ID never re-align it. A smooth scroll to an already visible row would otherwise cancel an immediate keyboard scroll.

In portal Live TV, ArrowRight on the selected category enters the visible live-channels region; ArrowLeft from that region or a channel's main button returns to the selected category in portal-categories. These IDs identify the single mounted main pane, not fullscreen or overlay lists. Navigation does not select a channel or start playback. Modified shortcuts, input fields, menus, dialogs, player controls and hidden/inert panes keep their own behavior.

Channel List Item

The shared row should be reused instead of rebuilding channel markup per view.

Current Reference Values

  • Current minimum height: 68px
  • Horizontal gap: 12px
  • Padding: 8px 10px 8px 12px
  • Radius: 12px
  • Current logo shell: 44x44, rounded, subtle inset treatment
  • Compact variant: 52px min height with slightly tighter padding

These values describe the current shared row, not a fixed-width contract. Keep the row responsive: the text column uses min-width: 0 and ellipsis, while logos, drag affordances, and trailing actions use flex-shrink: 0. Prefer minimum dimensions and flexible columns over fixed row widths.

Content Layout

  • Title is one line, medium-bold, slightly condensed
  • Program title is a secondary line with lower emphasis
  • Timeline uses three columns: start time, progress bar, end time
  • Action buttons sit on the trailing edge and inherit row color

Responsive Information Priority

  • EPG-enabled, noncompact rows keep a fixed 68px height that matches the virtual-scroll stride. EPG-disabled, compact rows use a matching fixed 52px row and virtual-scroll size.
  • At 310px and below, hide the end time while keeping the start time and progress bar.
  • At 270px and below, hide the decorative logo while retaining program context and actions, and tighten horizontal padding to preserve the remaining content.
  • At 220px and below, hide the start time while keeping the progress bar.
  • In EPG-preview rows, narrow width alone must not remove the channel name, program title or no-program placeholder, progress bar, drag affordance when applicable, or enabled actions.
  • Radio consumers without EPG render the row as compact instead of showing a false no-program placeholder. Compact rows keep the logo at 270px, then hide the logo and actions at 220px.
  • isRadio alone must not change row height inside a fixed-size mixed virtual list; the consumer's showEpg state and virtual-scroll item size own density.
  • Loading skeletons mirror the same responsive hierarchy and row geometry.

Logo Rules

  • Show fallback icon only when no image is available or image loading fails
  • Do not render placeholder and real logo at the same time
  • Keep logos contained with object-fit: contain

Cover Grids

Movie and series covers render in three surfaces: the catalog grid (app-grid-list, libs/portal/shared/ui/.../grid-list/), the favorites / recent card (app-content-card, same lib) and the dashboard rails. All of them size from the --cover-grid-min-width / --cover-rail-width / --cover-gap tokens that Settings.coverSize writes onto <html> as data-cover-size (apps/web/src/_cover-size.scss).

Posters-only wall

Settings.showCoverTitles (Settings > General, default on, only an explicit false opts out — coerced like webPlayerSharedControls) removes the title row under VOD and series covers so the grid shows more rows per screen.

  • Resolution. CoverTitlesService.postersOnly (libs/portal/shared/ui) is the single source: the opt-out AND a hover-capable pointer ((any-hover: hover) media query, tracked live). On touch-only devices the preference is ignored and titles stay under the covers, because a tap already opens the item and there is no gesture left to peek at a hidden name.
  • Scope. Catalog grids (Xtream/Stalker VOD and series), unified favorites/recent grids and the portal favorites tab. Exempt, regardless of the setting: live channel grids (type live/itv/radio or the logo variant — logos are too often missing to identify a channel), search results and "recently added" rails (they answer by name; hosts pass [allowPostersOnly]="false" to app-content-card; app-grid-list and app-unified-grid-tab drop the wall themselves while their searchTerm input is non-blank, i.e. an in-section search is filtering the list), and the dashboard rails (their meta rows do not fit an overlay).
  • Reveal. The title is a .cover-title-overlay inside the poster wrapper: bottom gradient scrim, two clamped lines, 150 ms ease-out opacity, shown on :hover and :focus-visible of the card, none under prefers-reduced-motion. It is aria-hidden; the card itself carries the accessible name.
  • Pinned caption. When the item has no cover to identify it — no poster URL, or the image failed and the default poster / placeholder is showing — the overlay is pinned open (--pinned). Both components track failed URLs so the fallback branch re-renders instead of swapping src in place.
  • Layout hints. The grid's contain-intrinsic-size drops from 270 px to 222 px (bare 2/3 poster) under .grid-list--posters-only, and the skeleton hides its text lines so loading matches the cards it precedes.
  • Keyboard. Both cards expose a role="button", tabindex="0" surface labelled by the title, activated by Enter and Space (Space prevents the page scroll) and carrying a :focus-visible ring (card-focus-ring mixin in libs/ui/styles/_content-grid.scss). On app-content-card that surface is the inner .content-card__activation element, and the Remove control (labelled by removeTooltip) is a SIBLING positioned over the poster corner — an interactive control nested inside a role="button" is an invalid accessibility structure. Its ring is drawn on the OUTER .content-card via :has(> .content-card__activation:focus-visible), because the card's overflow: hidden would clip an outline on the inner surface on every edge. Poster alt is the title, not a literal.

EPG Views

The shared timeline and list still contain local dark surfaces, blue selection accents, and white foregrounds. These non-semantic hard-coded colors are migration debt. New work should use app surface/selection/text tokens and must not spread those local fallbacks. Semantic live, error, and status colors may remain local when the meaning is explicit.

Shared EPG Pane

  • Header title stays sticky
  • Program list is the only scrolling region
  • Add bottom padding so the last program is not clipped
  • Current program card uses the same selection treatment as selected channels

Collapsible Live EPG

  • Live TV layouts with an internal player render app-epg-timeline as the EPG content, including playlist-specific live pages and the global favorites/recent live tabs.
  • The timeline's own panel bar owns the current-program summary and live date navigation together; there is no separate wrapper component around it.
  • Collapsed state is shared across M3U, Xtream, and Stalker with live-epg-panel-state; missing or invalid values restore to expanded.
  • The collapsed panel is a slim current-program strip with a trailing progress line and an expand button. Date controls stay out of the collapsed strip.
  • Do not render the collapsed strip for external MPV/VLC playback; those layouts keep the full EPG-only panel.
  • Keep the EPG content mounted while collapsed so current-program state can continue updating.
  • The ribbon renders only the blocks, ticks and day dividers within half a viewport of the visible range (TimelineWindowController in epg-timeline-window.controller.ts); the track keeps the full schedule's width, so positions, the scrollbar and scroll-to-now are unchanged. A channel's full schedule is often a few hundred programmes, and rendering them all competed with the stream's first frame (J3 in performance journeys). Tests and features must not assume an off-screen programme is in the DOM: scroll the ribbon to it first. Keyboard access must keep working: focusing a block scrolls it into view, which renders its neighbours before the next Tab (epg-timeline-interaction.e2e.ts walks past the rendered range both ways).

Collapsible Live Sidebar

  • Live-TV panels fold from the outside in, in three nested levels owned by LiveSidebarState (@iptvnator/portal/shared/util):
    1. expanded — categories rail + channels rail + player.
    2. categories-hidden — channels rail + player. The shell's categories rail (WorkspaceShellContextSidebarComponent, live sections only: Xtream live, Stalker itv/radio) folds; the channels header turns its category title into a dropdown that opens the same rail as a popover, so switching categories stays one click away.
    3. collapsed — player + EPG only ("theater").
  • There is deliberately no "channels hidden, categories visible" state: a category click has to bring the channels back anyway. Surfaces without a categories rail (M3U, the unified-collection live tab) treat level 2 like level 1 — and so does the live ROOT (no selected category, Xtream /live "All Items", Stalker's all-items grid): there is no channels rail to host the way back, so the shell folds the categories rail at level 2 only while the portal store has a selected category (hasLiveCategorySelection), and the rail's hide chevron is withheld there too. Level 3 folds it regardless, since the floating restore handle lives in the content area.
  • Xtream Live TV's root view (/live with no selected category) follows the same paginated All Items shell as VOD and Series: a widget header with the total channel count, page-size controls, and page navigation above the shared app-grid-list. Use the grid list's logo-oriented live variant so channel logos stay contained in 16:9 thumbnails instead of being cropped like VOD/series posters. Selecting a channel from that root grid starts playback, selects the channel's category, highlights the active category and channel, and scrolls the category rail plus virtual channels list to the selected rows when those rails are visible.
  • Affordances, each in the panel it acts on:
    • A chevron_left in the categories rail header (WorkspaceContextPanelComponent, presentation="sidebar", live sections only) → level 2 (hideCategories('portal')).
    • A chevron_right at the start of the channels header (data-test-id="live-show-categories") and the popover footer's "Show categories panel" → level 1, through the shell's LiveCategoriesPopover.showCategoriesPanel(): it sets showCategories('portal') and, at phone widths where the rail is the off-canvas context drawer whose open state the level does not drive, also opens that drawer (WorkspaceShellContextDrawerService.open()).
    • The category dropdown (data-test-id="live-category-dropdown") opens LIVE_CATEGORIES_POPOVER anchored below itself. The token lives in @iptvnator/portal/shared/util; the workspace shell provides it (WorkspaceLiveCategoriesPopoverService, CDK overlay hosting WorkspaceLiveCategoriesPopoverComponent, which stamps the context panel with presentation="popover") and the live layouts reach it through their LivePanelsController (createLivePanelsController() in a field initializer: level flags, the popover bridge and the focus handoff in one shared object, so the layout components carry none of it; without a provider the header keeps its plain title). The stamped panel opts out of the live-TV column keyboard contract (columnHandoff=false: no #portal-categories id, no ArrowRight handoff to #live-channels), since the dialog's focus trap would bounce that handoff back inside and a second id would shadow the folded rail's; the category sort preference is shared through PortalCategorySortStateService, so a sort picked in the popover survives into the restored rail. Backdrop, Escape, the footer, any category selection (categorySelected output), any router NavigationStart and any live-panel level change (Cmd/Ctrl+B reaches the layout through the dialog) close it; focus returns to the trigger. The popover host is a role="dialog" with aria-modal and a CdkTrapFocus host directive that captures focus on open, matching the trigger's aria-haspopup="dialog".
    • The chevron_left in the channels header → level 3 (collapse('portal')).
    • While collapsed, a floating chevron_right mini-fab at the left edge of .content-container, the workspace header toggle and Cmd/Ctrl+B (toggle(surface)) return to the level the user collapsed from, not always to level 1. The shortcut handler ignores events that originate inside <input>, <textarea>, <select>, or content-editable elements via the shared isTypingInInput helper. "Show playing channel" (XtreamLiveChannelNavigationService, StalkerLiveNavigation) uses expand('portal') for the same reason: revealing the row must not unfold a deliberately hidden categories rail.
  • Collapsed state is owned by LiveLayoutSidebarStateService (providedIn: 'root') in @iptvnator/portal/shared/util and kept per surface (LiveSidebarSurface): m3u (the M3U player), portal (Xtream and Stalker live layouts plus the shell categories rail) and collection (the unified favorites/recent live tab). Every participant injects the service and reads a derived, stable per-surface signal, never the raw state: the categories rail folds on areCategoriesHiddenFor(surface), the channels rail on isCollapsedFor(surface); actions are toggle, collapse, expand, hideCategories, showCategories and setState, all per surface. Nothing reads localStorage directly. Persistence lives under live-sidebar-state:<surface> and every level is restored as stored; the level toggle() comes back to is session-only. Hiding the list is a per-context choice: it must not follow the user from a portal to an M3U playlist, nor from the desktop rail to the phone bottom drawer of another surface. The pre-split shared key live-sidebar-state is forgotten on service construction and never read — a stored collapsed there hid every channel list in the app behind a 32px chevron and survived restart, "Remove all playlists" and re-import (issue #1458).
  • The control never moves. Inside the rail a mat-icon-button with chevron_left hides it; while collapsed a floating chevron_right mini-fab sits at the left edge of .content-container. Because both of those live in the thing they hide, the workspace header additionally renders view_sidebar (headerSidebarToggle, WorkspaceShellHeaderService) on every route that renders its own rail — M3U all/groups, Xtream live, Stalker itv/radio (resolveRouteLiveSidebarSurface). It stays in place in both states, uses aria-pressed (pressed = rail visible) and tints primary only while the rail is hidden, since the hidden state is the exception that deserves the cue. Collection pages are deliberately excluded: only the page knows whether its live tab, and therefore the rail, is on screen, so its own header toggle beside the content switch stays the owner. At the phone breakpoint (≤640px) the header toggle is hidden: the rail is a bottom drawer there with its own toggle and the header has no spare width.
  • While the rail is collapsed and nothing is playing, every live host renders app-channel-list-hidden-state (@iptvnator/portal/shared/ui) instead of the "select a channel" empty state: a title that says the list is hidden, a one-line hint naming the shortcut, and a full-size "Show channels list" stroked button wired to the same toggle. The generic app-portal-empty-state grew optional hint, actionLabel, actionIcon inputs and an action output for this; the action keeps full opacity while icon and copy stay muted, because it is the way out of the state.
  • A folded rail is 0px wide but still rendered, so it also carries inert (isContextPanelInert on the shell rail, isSidebarCollapsed on the Xtream/Stalker channels rail) to leave the Tab order and the accessibility tree; the shell rail skips inert while it renders as the open phone drawer, whose stylesheet ignores the folded state — and the rail's hide chevron is withheld there for the same reason (canHideCategories). Every level change removes or inerts the very button the user activated, so focus drops to <body>; the side that gains the replacement affordance picks it up after its next render via focusIfFocusLost() (@iptvnator/portal/shared/util): the layouts' LivePanelsController installs handoffFocusOnLiveSidebarChange() over the EFFECTIVE level (on the live root the first category selection folds the rail with no state change) and focuses the floating restore handle at player-only or the show-categories button while the rail is folded, and the shell sidebar, watching the rail's ACTUAL fold state (on the live root the rail stays visible at level 2, so only player-only ↔ visible is a transition there), focuses the control the context panel names (focusTarget(): its hide chevron, or its first header action when the chevron is withheld) and, while none is rendered (categories loading, a failed load), the tabindex="-1" aside itself — only on transitions, and never when another control still owns focus.
  • The CSS class .sidebar-collapsed (channels rail) and .context-panel--collapsed (workspace shell categories rail) both override the inline width set by the appResizable directive with width: 0 !important; min-width: 0 !important. The directive's persisted width is preserved so uncollapsing restores the user's previous resized width. Both rails share the same 180 ms width transition so motion stays in lockstep. The dropdown reuses the static heading's type so folding the rail does not move the title; the caret is the only added ink (_portal-sidebar.scss).
  • At the phone breakpoint the M3U layout's bottom-drawer rule overrides the desktop collapse to height: 0 instead of width: 0. The floating restore handle stays visible there: the collapse toggle is reachable by touch, so hiding the handle left a phone with no way to bring the list back short of Cmd/Ctrl+B. The phone context drawer ignores the folded state entirely (the user explicitly opened it).

EPG Card

  • Radius: 11px (.epg-timeline__block in libs/ui/epg/src/lib/epg-timeline/epg-timeline-track.component.scss)
  • Neutral cards use low-contrast surface treatment
  • Current card uses selection surface and selection border
  • Description should clamp rather than overflow

Sticky Header

  • Keep the title readable above content
  • Use a solid or near-solid backing surface
  • Do not let it overlap or cover player controls

Workspace Xtream Sync Overlay

The import/refresh card pairs a near-opaque --app-widget-bg surface with app-owned text colors in both themes. Blur belongs to the backdrop; card text must not depend on an unprovided Material system surface token. Phase text uses the primary foreground, and explanatory/progress copy uses a readable blend of primary text and the widget surface instead of the decorative muted token. Local and remote badges, and the outlined cancel button, resolve their text, surfaces and interaction colors together. Electron provider E2E coverage holds a cache read open and checks text contrast across live theme changes.

Progress Bars

Channel preview progress and EPG current-program progress should stay visually aligned.

Watch progress colour

A title's watch progress (its resume share, progressPercent) has exactly one colour per context, and never a literal of its own:

  • App chrome — dashboard rail cards and the hero, catalog grids and season episodes (app-progress-capsule, and the episode items' own 3px bar): --app-progress-color, declared per theme in apps/web/src/m3-theme.scss as that theme's --app-selection-color, and declared again inside .dark-theme because a derived custom property resolves where it is declared. The capsule's green from 90 % marks a finished title; it is a status, not progress.
  • Over video — the dock timeline, the Up next card, the Up Next rail and the fullscreen episode panel: the player's fixed --pc-progress (accent blue #4f8eff), never an app token, because the player palette is theme-independent (see Player And EPG Theme Boundaries). An episode therefore reads the same in every player surface. The rail and the episode panel render beside the controls host, outside its --pc-* scope, so they declare the token on their own :host with the progress-token mixin of libs/ui/playback/src/lib/player-controls/_player-palette.scss.
  • ArtPlayer's legacy skin takes a colour string, not a custom property, so it gets PLAYER_PROGRESS_COLOR (player-palette.ts), which a spec pins to the Sass value.
  • Live programme progress next to a LIVE marker (the dashboard's live rail and live hero slides) keeps --app-live-color; EPG programme progress keeps the fill described below.

Specs hold the rule in each owning project: m3-theme.spec.ts (web: the token in both theme contexts), player-progress.palette.spec.ts (ui-playback), progress-capsule.component.spec.ts and season-container.progress-colour.spec.ts (components) and dashboard-progress-colour.spec.ts (workspace-dashboard-feature).

Track

  • Height: 6px
  • Shape: full pill radius
  • Neutral background: medium gray or neutral surface tint
  • Include a slight inset edge so the remaining duration is visible

Fill

  • Use --app-selection-color
  • Add a subtle sheen, not a heavy gradient
  • Add a restrained glow, not a neon effect

The progress bar should clearly communicate:

  • completed duration
  • remaining duration

Avoid making the track too faint, especially in dark theme.

Loading States

Two loading states exist, chosen by whether content is already on screen.

First load: skeleton

Nothing is rendered yet, so the skeleton replaces the whole content area and mirrors the row/card geometry it precedes (see Channel List Item and Cover Grids). The unified Favorites/Recent page gates this on isLoading, set only while its item list is empty.

A skeleton takes its boxes from the code that draws the content, not from copied values: the same Sass mixins for every box that sets a height (header padding and minimum height, row and card size, track bleed, gaps), and for text a line box of the real font size and line height with a shorter shimmer bar inside it. Copied values drift: the M3U channel list moved down by 19 px when its playlist loaded, and the dashboard rail skeletons were 2:3 posters in front of 84 px channel cards.

  • The channel list views and app-channel-list-loading-state share libs/ui/components/src/lib/styles/_channel-list-layout.scss. The loading state takes the view and hides its headers where the loaded view does (showHeader, compact lists).
  • The dashboard rails and lib-dashboard-rail-skeleton share libs/workspace/dashboard/feature/src/lib/rails/_dashboard-rail-geometry.scss. A skeleton takes the layout and aspectRatio of the rail it precedes: channel cards for live favorites, 16:9 cards for sources, posters with a title and a meta line otherwise.
  • apps/electron-backend-e2e/src/skeleton-geometry.e2e.ts holds the IPC channel that answers each loading state, compares the skeleton's header, first-row and rail boxes with the content's to 0.5 px at 1280×700, and keeps the dashboard's layout-shift score from skeleton to content at or under 0.01. A whole subtree swapped for new nodes does not count as a layout shift, so the positions are compared directly.

Pages of independently loading blocks: delayed skeletons

The dashboard renders each rail as soon as its own data arrives, and several rails resolve empty on a normal profile. A per-rail skeleton shown immediately therefore flashed for a few tens of milliseconds and collapsed, pulling every rail below it upwards (a layout shift of about 0.23 on each launch with sources). Rail skeletons are gated per rail (createRailSkeletonGates in libs/workspace/dashboard/feature/src/lib/rails/dashboard-skeleton-grace.ts):

  • a skeleton waits out a grace period (DASHBOARD_RAIL_SKELETON_GRACE_MS, 300 ms) counted from when that rail started loading, since Xtream and TMDB rails start after the local ones;
  • it never appears above a rail that already shows cards: the placeholder would push visible content down, and back up if the rail resolves empty, while the real rail inserts at most once;
  • once shown, it stays until its own rail finishes, so skeletons do not vanish in a cascade when the first real rail arrives.

The top block (the dashboard hero) keeps its immediate skeleton: it reserves the space above everything else, where a late insertion would push the whole page down. For the same reason it stays until every source that can fill it has loaded, not only the first one. Use the same rules for any page that stacks independently loading blocks.

Skeleton blocks use one shimmer, libs/ui/styles/_skeleton-shimmer.scss: a fill mixed from --app-on-surface (8%, with a 16% highlight), so it reads on both themes. The dashboard rails used to sweep between --app-widget-bg and --app-widget-header-bg, 1.04:1 apart on the light theme.

The movie and series detail hero has an immediate skeleton too, built from the shared shimmer at the loaded hero's geometry: stage height, bottom-aligned eyebrow, title, one row of chips, two description lines, the action row and two credit lines. The Xtream series page adds an episodes section skeleton under it, and the Stalker series hero holds the Play button's place (app-detail-action-skeleton) while its seasons load.

Reload with content on screen: non-destructive indicator

A reload of a list that is already rendered (the collection page's "This playlist ↔ All playlists" scope switch, a favorites reload) must never swap back to the skeleton: that unmounts a playing channel, drops focus from the toggle the user just clicked, and flashes on fast IndexedDB/SQLite answers. Instead keep everything mounted and layer feedback on top:

  • an indeterminate mat-progress-bar (2 px track, --app-selection-color fill) absolutely positioned over the header's bottom separator, so its appearance never shifts content;
  • aria-busy="true" on the content region for the whole reload;
  • the list or grid dims to opacity 0.6 with a 160 ms transition (0ms under prefers-reduced-motion: reduce). The player is never dimmed — the live tab dims only its channel rail.

Grace period. The bar and dimming render only once the reload has run for COLLECTION_RELOAD_INDICATOR_DELAY_MS (180 ms); a reload that settles sooner shows nothing. aria-busy is set immediately, since it does not paint. A superseded reload keeps the earliest deadline and never clears the indicator; only the latest request's completion does. Reference implementation: createCollectionReloadIndicator in libs/portal/shared/data-access/src/lib/collection/collection-reload-indicator.ts. Controls that triggered the reload stay enabled and reflect the requested value at once (scope.set() runs synchronously before the load starts). Because the rows on screen then belong to the PREVIOUS request, actions on them (Clear, drag reorder) must bind to the request that loaded those rows, never to the toggle's current value — "This playlist" applied to still-mounted global rows would delete other playlists' favorites or write foreign URLs into this playlist (loadedRequest on UnifiedCollectionDataService, which the collection page reads through its own mutationRequest).

Navigation Lists

Use the shared nav-list.scss treatment for sidebar and context-panel list items.

Rules

  • Keep labels one line with ellipsis
  • Keep icon area clear from the selection border and any decorative rail
  • Hover is neutral surface, not the selected color
  • Selected state uses the shared selection recipe

If the label is too long for the rail, shorten the label key instead of shrinking the component until it becomes inconsistent.

Detail Actions And Episode Surfaces

The action row never wraps on a desktop window: the primary, the Trailer button, then the 44px icon buttons and the "…" menu. Season and series actions (mark watched, download season, reset progress) live in that menu and drive the season container's presenters.

Every section below the hero (Episodes, Cast & crew, Similar) uses app-detail-section-header: an 18px/600 title, a muted 12.5px tabular counter, a lead slot (the season pills or dropdown, a 30px pill) and an end slot (the list/grid toggle, rail arrows). Sections sit 56px apart, with 16px between a header and its content and 56px after the last one. The Episodes counter reads "N episodes · M watched". The list/grid switch is a segmented track whose checked segment is a neutral raised fill with heading ink; the list is the default view.

Episodes render through app-episode-item as a flat list row or grid card, never a bordered box. A row is a number column, a 168px 16:9 thumbnail (with a light hairline border so its edge survives the light theme), the title with a "46 min · 12 Jan" meta line (time left once started, "Resume at 12:34" when no runtime is known) and the plot clamped to two lines at 74ch, and a 112px action slot. Rows are separated by a hairline that gives way around a hovered, focused or highlighted row. Watched episodes carry a check badge on the thumbnail and muted text, and never a bar; a started one gets a 3px bar and the time left. Mark watched, download and "…" (Episode details, Play from beginning) are always laid out and only fade in on :hover/:focus-within, so nothing shifts. The whole item is one stretched button: Tab focuses it and Enter plays. The playing episode, else the most recently watched unfinished one, is highlighted. Without distinct stills the thumbnails show a dimmed fallback, not a column of bright posters. The season picker never shows a season poster: chips up to four seasons, a menu button from five whose rows read "Season N" and "N episodes · M watched" (the second part left out at zero, and no count at all for a lazy Stalker VOD season the portal has not answered yet — seasonLoadStates). The selected season's synopsis sits under the header, offset 52px to the number column and 24px above the list, only when present; one that repeats the series description (or is cut short from it) is dropped, and without one no row renders.

A season whose final episode data has no plot and no usable still (a still equal to the series poster or the season cover counts as none) renders bare: 44px rows of number, title, meta and an inline check or 72px bar, the same action slot, and no grid toggle. Only some stills or plots missing keeps full rows (dimmed tile, line left out). While the provider list is outstanding, and while a TMDB lookup that could still fill a season that would otherwise render bare is outstanding, the episodes are skeleton rows at the full row's geometry (app-episode-skeleton); a season the provider already describes renders at once and the metadata lands in place. The state is decided from data (episode-meta-state.util.ts); the metadata wait is only capped (EPISODE_METADATA_WAIT_MS, 4s) because TMDB requests carry no timeout, and it never applies to a season without episodes, which no host enriches. At a pane width of 480px or less the thumbnail shrinks to 96px and the actions take their own row under the text. Keep these treatments in the shared season components so Xtream and Stalker share the same behavior.

Browser regression coverage measures the composited neutral edges and selected toggle fill, in addition to capturing light/dark grid and list screenshots. Hero action edges use matching pixels from rendered screenshots with the border visible and transparent, retaining the artwork and gradient behind the button; flat ancestor-color compositing is only appropriate outside that layered hero.

Settings Surfaces

Settings use the same system but are flatter than content-heavy views. Reference: apps/web/src/app/settings/settings.component.scss and the per-theme tokens in apps/web/src/_settings-theme.scss.

Structure

  • The sidebar is a 20px "Settings" title over four groups (App, Library, Devices, Data), with About pinned to the footer beside the installed version and an update badge. The active item is a soft selection fill with tinted text and no border, so the list never shifts. Back lives in the workspace header and Esc runs it. Section pages are defined once in SETTINGS_SECTION_DEFINITIONS (libs/workspace/shell/util).
  • Each page is a title (26px) and one-line subtitle, then sections: a 14px heading, an optional description and action buttons, and a card.
  • Rows (.setting-item) are a label column (13.5px title, description capped at 56ch) and one right-aligned control column. Put every control in that column, including segmented controls; the page is a size container and rows stack under 600px of pane width. Keep data-setting-id on every row: settings search and the command palette reveal rows by it, and the registry spec checks the index against the templates.
  • Controls: .settings-select is a 220px outlined select without a floating label (the row title names it; pass aria-labelledby); boolean settings are mat-slide-toggle switches; segmented choices use .theme-switcher as a pill; .settings-chip marks rows that apply after a restart or are experimental; .settings-callout (--info, --warn) sits under the row it explains inside .setting-item__sub and only while that choice is made.
  • Destructive actions keep the confirm dialog from the Dialogs section; the row itself uses .app-destructive-button on a stroked button.
  • The save bar floats over the content column while the form is dirty with the change count, Discard and Save (Cmd/Ctrl+S); the sidebar marks pages with staged edits. A saved change that waits for a restart shows a sticky notice at the top of the page.

Light Theme

  • Prefer white or near-white cards
  • Use app-owned neutral borders, or a proven Material token with a real fallback such as var(--mat-sys-outline-variant, var(--app-widget-border))
  • Keep active sections mostly defined by outline and subtle tint
  • Avoid dark translucent backgrounds

Dark Theme

  • Denser tinted surfaces are acceptable
  • Neutral rows can use low-opacity dark overlays
  • Keep strong blue tint reserved for active sections and selected items

Dialogs

  • Name. Every dialog has a mat-dialog-title; Material points the container's aria-labelledby at it. A custom-styled title keeps the directive and overrides Material's headline padding and 40px ::before strut, as the programme dialog does.
  • Order. Dismiss first, primary last. With end alignment the primary sits on the inline end: the right in LTR, the left in RTL.
  • One dismiss. Offer one visible dismiss: a footer "Cancel"/"Close" or a header close icon, not both. Escape and the backdrop still close.
  • One row. Action labels are short verbs ("Cancel", "Discard", "Save"), not phrases, so the row fits one line in every locale at the dialog width. Secondary actions tied to one part of the content stay with that content (the programme dialog's archive tools sit under their notice), so the footer is only the dismiss and the primary.
  • Phone. At the phone breakpoint an action row may stack: one full-width button per row, in DOM order (dismiss on top, primary at the bottom). The settings unsaved-changes dialog is the reference; settings-unsaved-dialog-layout.e2e.ts in web-e2e measures it in six locales. The programme dialog stacks its archive tools and footer the same way, letting a long label wrap inside its button; the Electron epg-timeline-interaction.e2e.ts measures it in French at 360px.
  • Width. A dialog with several openers is opened through one helper that owns its MatDialogConfig, so its width never depends on the entry point. EpgProgrammeDialogService opens the programme dialog at 540px from the timeline, list, guide and channel rows, with a panel class that scopes its surface overrides.
  • Destructive actions. Material only emits warn button colors for M2 themes, so the color input is a no-op here. A button that removes or discards user data uses the global .app-destructive-button class from m3-theme.scss (error/on-error tokens per theme, for filled, text, outlined and icon buttons), as the unsaved-changes dialog's Discard does. Confirmations go through DialogService.openConfirmDialog with a translated verb as the required confirmLabel ("Remove playlist", "Clear") and tone: 'destructive' when the action loses data; the dismiss defaults to "Cancel". Never confirm with "Yes"/"No". When the verb itself is "Cancel …", pass cancelLabel "Close" so the two buttons do not read alike. theme-tokens.e2e.ts checks the label and the error fill in both themes.
  • One control per action. A page offers one control for a destructive action. Clearing a favorites or recently viewed tab goes through createClearCollectionAction (unified-collection-clear-action.ts in portal/shared/ui), from the collection page's button and from the command palette alike; the shell header does not repeat it. recent.e2e.ts checks the single control and the single confirmation on M3U, Xtream and Stalker recently viewed pages.

Forms

Every <mat-form-field> is outlined with a dynamic subscript. A component that renders one adds providers: [provideFormFieldDefaults()] (@iptvnator/ui/components/form-field-defaults); the root injector does not provide the defaults, because the token import there would load Material's form field and @angular/forms on startup. pnpm run ui:form-field-defaults:validate (in CI) reports a missing provider.

The add-source forms (M3U URL, Xtream, Stalker) and the edit dialog share one vocabulary, so a field reads the same wherever it appears:

  • Name. The source name is labelled "Playlist title" (HOME.XTREAM_PLAYLIST.TITLE) in every add form. Every add form submits with "Add playlist" (HOME.URL_UPLOAD.ADD_PLAYLIST).
  • Passwords. A password input is masked and has a mat-icon-button suffix with PasswordVisibilityToggleDirective (@iptvnator/ui/components/password-visibility-toggle). The input binds [type]="toggle.inputType()"; the button keeps one translated label ("Show password", HOME.SHOW_PASSWORD) and exposes its state through aria-pressed, as an ARIA toggle button does.
  • URLs. A URL field has a neutral mat-hint where the format is not obvious, and its own mat-error. Never borrow another field's message.
  • Feedback. While the dialog stays open, a check or refusal is shown inline under the URL field in a role="status" paragraph. The message is translated in the template and cleared by any edit. Use a snackbar only for outcomes that close the dialog. add-source-forms.e2e.ts in web-e2e covers the shared labels, the toggle and the URL errors.

Translated Text

The UI language can change while views stay mounted: Settings → Language switches it at runtime, and on a cold start the translation file can arrive after the first render. Text on screen must follow the current language:

  • Keep translation keys, not translated strings, in stores, services and data models. When the app adds an entry to provider data, give it a labelKey that the view renders with | translate and leave its provider name empty. The Stalker every-item genre is an example.
  • Prefer | translate in the template. A computed that calls TranslateService.instant must also read injectTranslationTick() from @iptvnator/services. It ticks on the three events the translate pipe listens to: onLangChange, onDefaultLangChange and onTranslationChange. onLangChange alone is not enough. A start-up without a saved language never calls use(), so only onDefaultLangChange fires when the dictionary lands, and the computed would keep showing raw keys. Without any tick, the computed keeps the language it first ran in. State factories such as createSeriesHeroState and createVodDetailsHeroState take the tick as language.
  • instant is fine at event time, for snackbar, dialog and confirm text.

Source Type Icons

SOURCE_TYPE_ICONS in @iptvnator/shared/interfaces is the only source of provider icons: Xtream cloud, Stalker cast, the M3U family playlist_play, and per playlist link (URL), description (local file or text) and subject (pasted text in the add flow). Use getPlaylistSourceIcon() for a stored playlist. An icon never stands for two providers, and the Dashboard rail icon is never a provider icon.

Icon Names

<mat-icon> draws its text through the ligatures of the Material Icons font from material-design-icons-iconfont, imported in apps/web/src/styles.scss. The font renders a name it does not know as plain text, so pick names from its dist/fonts/MaterialIcons-Regular.json codepoints file: newer Material Symbols names such as file_off or arrow_outward are not in it. pnpm run styles:icon-ligatures:validate (CI) checks static <mat-icon> text, the string results of its bindings, icon / *Icon inputs, and TypeScript values named icon, *Icon, *_ICON, *Icons or *_ICONS. Name a member that feeds an icon that way, so the check can see its values.

Icon-Only Buttons

A <button> whose only content is a <mat-icon> needs an accessible name: the ligature text is hidden from assistive technology, and a matTooltip only adds a description that appears on hover or focus. Give it a translated aria-label, usually [attr.aria-label] bound to the same key as its tooltip, or aria-labelledby. A toggle keeps one label and reports its state with aria-pressed (the channel row's star is "Favorite", pressed or not), as the password visibility toggle does; its tooltip may still name the next action. pnpm run a11y:icon-buttons:validate (CI) checks .html and inline templates, looking through control flow, ng-container and spinners; content it cannot see into (ng-content, another component, an image) counts as named, and text under aria-hidden="true" never does. icon-button-names.e2e.ts in web-e2e runs axe's button-name rule on a channel list, the channel details dialog, Sources, the playlist info dialog and an Xtream search.

Phone Layout

640px is the phone breakpoint. Use @media (max-width: 640px) rather than inventing a nearby value: several surfaces cooperate at this width, and a component that picks 599px leaves a band where the shell has already stacked but the component has not.

Rails become rows, stacks, or drawers

  • The workspace shell rail turns into a horizontal top bar. Everything inside it has to opt into the row direction — a nested list that keeps flex-direction: column stacks its links out of the bar and over the header. The bar scrolls sideways once a portal contributes its sections, and the settings link is position: sticky so it never scrolls out of reach.
  • The shell context panel (categories, filters, settings sections) is an off-canvas drawer: hidden by default so the route content owns the full pane, opened from a toggle in the workspace header, closed by selection, backdrop tap, Escape, or any navigation. State lives in WorkspaceShellContextDrawerService (root-provided from @iptvnator/workspace/shell/util — see below for why); the panels call close() after selections that do not navigate — a NavigationEnd listener alone misses Stalker ITV/radio categories, settings sections, sources filters, and collection filters. The drawer positioning is position: fixed on the sidebar host, which also removes it from the shell grid, so the phone workspace-body stays single-pane. The drawer is modal for keyboard and screen-reader users: CdkTrapFocus captures and contains Tab focus while open, the shell marks the rail, header, content, and playback footer inert (a focus trap alone does not stop a screen reader's virtual cursor from activating obscured controls), the panel itself is the initial focus target (tabindex="-1" + cdkFocusInitial, so capture still works when a category list is loading or empty and renders no focusable rows), and the shell restores focus to the header toggle on close — deferred one tick, because the toggle is inside the inert header and focus() on a still-inert element is silently ignored. The service closes the drawer when the viewport leaves the phone breakpoint so the trap and inert state can never hold the in-flow desktop layout. While open, the shell consumes Escape (downstream consumers — the inline player's close handler, the shared controls shortcuts — check defaultPrevented, so one keypress cannot close both the drawer and the obscured player) and suppresses workspace-level shortcuts (Ctrl/Cmd+F global search, Ctrl/Cmd+K command palette, Ctrl/Cmd+R global recent, the ? shortcuts dialog — dialogs must not stack a second focus trap on the modal drawer, and navigation must not act behind it), and document-level shortcuts owned by routed content (shared controls, Embedded MPV legacy dock, radio audio player, the live layouts' Ctrl/Cmd+B sidebar toggle, the M3U player's digit-key channel switching and sidebar toggle) opt out on their own by checking for an inert ancestor, since inert does not silence document-level listeners. Any NEW document-level key listener on routed content must apply the same closest('[inert]') guard. The service is root-provided from @iptvnator/workspace/shell/util so consumers outside the shell's element injector (AppComponent's Ctrl/Cmd+R handler) can observe it without pulling the lazy shell chunk into the eager bundle. The shell also registers the open drawer with EmbeddedMpvOverlayVisibilityService.acquireExternalModalSurface(): the native-view video surface is composited outside DOM stacking and would paint straight over the drawer regardless of z-index. The drawer carries its own phone-only close button: touch screen-reader users have no hardware Escape and cannot reach the inert header toggle or the aria-hidden backdrop, so the trapped surface itself must offer dismissal even when its list is loading or empty. The toggle's label is variant-aware — categories, filters, or settings sections — because a fixed label would misdescribe two of the three.
  • Other side rails stack above the content instead of beside it: the live-layout channel sidebar and the M3U channel drawer.

Resizable rails need !important

ResizableDirective writes the persisted desktop width as an inline style, so a phone rule must be width: 100% !important to win. Hide .resize-handle in the same rule — dragging is meaningless at full width. Since there is no global border-box reset, a full-width rail with its own padding also needs box-sizing: border-box or it overflows the viewport.

State the content's floor, not the list's ceiling

On routes that stack two lists above the player (live TV shows the categories panel and the channel list), capping both lists still leaves the video a sliver. Give the player container a min-height instead and let the lists shrink into what is left.

What to drop

Prefer removing a control over shrinking everything around it:

  • Keyboard-only affordances — the ⌘K badge, the shortcuts button.
  • Counts and subtitles that a neighbouring control already states.

Never drop the only way back to a hidden surface. A collapse toggle that is reachable by touch needs its restore affordance to be reachable too. For the same reason the header's history Back yields to the context drawer toggle, and Settings keeps the toggle beside its Back; only a detail page's Back, whose list shows the toggle again, takes the toggle's slot.

Typography

The app stack is DM Sans with Roboto behind it ($app-font-stack in apps/web/src/m3-theme.scss). DM Sans covers Latin only, so Cyrillic and Greek UI text (ru, by, el) renders in Roboto. apps/web/src/styles.scss bundles both families in 400, 500, 600 and 700, and JetBrains Mono in 400 and 500 (its heavier faces would exceed the initial-bytes ratchet).

  • Use only those four weights: in font-weight, in the font shorthand, and in any custom property, Sass variable or token map that feeds one. A weight between faces snaps to a neighbour (650 renders as 700), and 600 or more with no face of at least 600 gets Chromium's synthetic bold. CI runs pnpm run styles:font-weights:validate, which rejects any other value.
  • JetBrains Mono text stays at 500 or lighter, also where it is a fallback behind ui-monospace (only macOS resolves that). The check enforces this in any rule that sets the family, directly or through a variable, or inherits it from an enclosing rule. A mixin's family and weights count where it is included, from its own stylesheet module or another one. It cannot see what a mono modifier class inherits from its base rule; set font-weight: 500 there.
  • Import whole @fontsource/<family>/<weight>.css files. The single-script files such as cyrillic-600.css have no unicode-range, so a Cyrillic-only face wins the weight match for Latin text in Roboto, … stacks and sends it to the next family.
  • <html lang> follows the UI language (syncDocumentLanguage() maps by to be and zhtw to zh-TW), so hyphens, text-transform (Turkish İ) and Han glyph fallback use the right locale.

Theme Guidance

Light Theme

  • Flat beats glossy
  • White and app-owned widget/content surface layers should separate content
  • Selection should read as a blue outline plus soft tint, not a solid slab

Dark Theme

  • Slight translucency is acceptable
  • Background layers can be deeper and more cinematic
  • Keep contrast readable without going pure white everywhere

Reuse Strategy

Before creating new markup or CSS:

  1. Check whether app-channel-list-item can be reused.
  2. Check whether app-epg-timeline already provides the correct structure.
  3. Check whether nav-list.scss already solves the list-selection problem.
  4. Inspect the public APIs of @iptvnator/ui/components, @iptvnator/ui/epg, @iptvnator/ui/playback, @iptvnator/ui/shared-portals, @iptvnator/portal/shared/ui, and @iptvnator/playlist/shared/ui.
  5. Put provider-neutral collection loading, persistence, and cross-provider orchestration in @iptvnator/portal/shared/data-access, not a UI library or the shared util library.
  6. Extend tokens first, duplicate styles last.

Implementation Workflow

When updating IPTVnator UI:

  1. Inspect the current shared component first.
  2. Reuse the shared structure where possible.
  3. Keep selection, progress, and spacing in sync across Xtream, Stalker, and shared portal views.
  4. Verify in both light and dark themes.
  5. Run the focused component/unit target and the closest Playwright workflow.
  6. Use the running Electron app through CDP only for Electron-only gaps or additional layout inspection; it does not replace available E2E coverage.

Anti-Patterns

Avoid these:

  • introducing a new selected-state color unrelated to the theme tokens
  • introducing independent dark-only EPG surface palettes
  • duplicating channel row markup in portal-specific views
  • showing placeholder logos behind real logos
  • making entire panes scroll when only the list should scroll
  • using dark translucent fills unchanged in light theme
  • solving cramped sidebars with smaller fonts instead of shorter labels
  • rounding a scroller's corners (border-radius with overflow) when it holds composited effects such as backdrop-filter: each effect gets a clip mask the size of the scroller; paint the corner instead (see the content surface corner in the workspace shell contract)

Definition Of Done For UI Changes

A visual change is not done until:

  1. Shared component reuse was considered first.
  2. Light theme and dark theme both look intentional.
  3. Selection and progress states match existing IPTVnator patterns.
  4. Scroll behavior is correct.
  5. The result was checked in the running app for layout-sensitive work.

Source row keyboard access

A Sources row (app-source-row, regular size) opens on a click anywhere on it, and it also holds a drag handle and its actions. Like app-content-card, it opens from the keyboard through a dedicated element that is a sibling of those controls: the title and meta block .source-row__body (role="button", tabindex="0", named by the source's display name, aria-current on the active source, aria-disabled while busy). Enter and Space open the source (Space prevents the page scroll), and Tab reaches each row before its actions. Never put role="button" or key handlers on the whole row: the actions would sit inside a button, and their Enter and Space would bubble into it. The row draws the ring for that element, inset ($offset: -2px), because the list's scroller would cut a ring drawn outside the full-width row. R on a focused row refreshes it, as its menu item shows. sources-keyboard.e2e.ts (Electron) tabs to a row, checks its ring, opens the source with Enter, and deletes and undoes a source keyboard-only.

Sources page and playlist switcher

One app-source-row serves both lists: size="regular" on the Sources page (56px, 36px tile, hover actions Refresh, Edit and ⋯; a failing row keeps Retry in view; touch screens always show the actions) and size="compact" in the switcher (52px, 32px tile, ⋯ only). In both the active source's row is tinted with the selected-row token; the switcher adds a check. The page header (title, Clean up, Sort) stays; only the list below it scrolls. The meta line is always type · channels · added · updated · URL, with empty parts left out; only M3U playlists store a channel count. The helpers in @iptvnator/playlist/shared/util (source-display, source-format, source-list) own that text, the filters and the counts.

  • No credentials on screen. Names come from sourceDisplayName: a URL, a URL-derived name such as get.php?username=…&password=… or any name holding the source's password, MAC or URL secrets becomes the host. URLs come from sourceDisplayUrl, built on stripUrlCredentials (beside it in source-display, out of the eager logging barrel): no userinfo, sensitive query or fragment parameters, or Xtream path credentials; Xtream shows scheme://host:port · username. Copy URL copies the full address on request.
  • Delete with Undo. A single delete has no confirmation: the row leaves every list at once (SourceRemovalService.pendingIds) and the delete runs when the four-second Undo window ends. The window is the service's own timer, not the toast's: another message replacing the toast (a refresh finishing elsewhere) does not shorten it. The toast's close button commits at once, and quitting within the four seconds keeps the source. Deleting the active source switches to the next row; Undo switches back only if no other source was chosen since. A desktop Xtream delete brings its row back as "Removing…" with progress and Cancel on the Sources page; the switcher leaves the source out until the delete ends. When the delete took the row and with it the focus, the toast's Undo takes the focus. A delete from the switcher closes the popover first, since its focus trap would keep the keyboard from Undo: Undo takes the focus and returns it to the trigger (SourceRemovalOptions.returnFocusTo). A source cannot be removed while a refresh clears and refills it: every refresh (a Sources row, the switcher, the Xtream flow, the startup auto-refresh), every Xtream import and every delete holds the source in the root SourceActivityService, which both lists, the removal service and a second refresh consult. The Xtream route session takes its hold on the refresh's NavigationEnd, so the catalog is never idle between the delete and the re-import. The Sources page shows a switcher refresh as busy. Bulk cleanup keeps its confirmation dialog.
  • Side list. Type and, on desktop, Status (Any / Responding / Not responding) combine and persist (SourcesViewStateService); each group counts within the other's choice. The page checks every source, not only the rows a filter shows (keepSourcesChecked): a saved status filter would otherwise hide the unchecked sources it is waiting for.
  • Switcher. The trigger is a 40px secondary button; ⌘P / Ctrl+P opens it (⌘K stays the command palette). The popover is a CDK connected overlay, so row menus open above it, not inside its scrolling list. Focus stays in the search field, which drives a role="listbox" through aria-activedescendant: ↑/↓ move and wrap, Enter opens, Shift+F10 opens the highlighted row's menu, Escape closes and returns focus to the trigger. The compact row's ⋯ is pointer-only (aria-hidden, not focusable), since an option may not contain a control. "Recent" lists the three most recently opened sources once there are six or more. The search field is the shared 36px search field (--app-search-*). The popover is a height-capped column in which only the list shrinks and scrolls: every other part is flex: none. Otherwise a long list (hundreds of sources) squeezes them in proportion to its size, and the type filter collapses.

Network source indicators (Electron)

The status dot on a source tile reads SourceStatusService: green answers, red does not, orange is an expired account, grey is not checked yet; local files have no dot. On desktop the live SourceHealthService result wins; the last conclusive result per source is kept across restarts (SourceHealthHistoryService, no URL or credential stored, only a 32-bit fingerprint of the connection), so a source that failed yesterday shows as failing before it is checked again, with "Not responding · 3 days". An edited address or account is a new connection: its kept result no longer counts, and its history starts over. The dot's tooltip and accessible name give the check's cause (SourceStatus.reason: access denied, account disabled, timeout…, kept with the history) and check time; M3U checks say they cover the playlist URL, not its channels. Retry and Check again rerun the check; they are not a playlist refresh. The browser build keeps the Xtream portal status only.

Inactive-source cleanup dialog

Electron Sources offers a library-wide cleanup action even when page filters hide all rows. The confirmation dialog groups confirmed inactive accounts, uncertain candidates, skipped sources and deletion outcomes. It preserves checkbox choices on recheck, labels destructive consequences, and announces progress. During deletion Escape/backdrop closing is disabled; Stop after current source finishes the current operation. Healthy sources appear only in the summary. Source titles wrap; credential-bearing URLs are not displayed.