Files
iptvnator/docs/architecture/iptvnator-ui-guidelines.md
T
4grayandClaude Fable 5.1 3b2af73baa style(ui): ADR and one global kit layer for menus, select panels and destructive buttons (#1936)
* docs(ui): record the Angular Material role decision in the UI guidelines

Angular Material stays the behaviour and accessibility layer; the look comes
from the design kit through mat.*-overrides() on --app-* tokens in the shared
partials, kit-only components build on CDK, and plain actions may become
native buttons. Records the measured Material and CDK share of the initial
bytes, why a replacement would not pay, and the v22 behaviour changes to
keep covered in E2E.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* style(ui): one global kit layer for menus, select panels and destructive buttons

- `<mat-menu class="app-menu">` gets the kit panel from one rule in
  styles.scss; the two lazy components drop their `::ng-deep` copies.
- Select option panels follow the same panel: bordered, 10px radius, 6px
  padding, 36px rows at 13px, the chosen row on the list-selection tokens.
- `.app-destructive-button` is the kit's red tint on the `--app-danger-*`
  tokens (tinted fill, red text, no ripple, 40% opacity when disabled)
  instead of a solid error fill, for filled, text, outlined and icon
  buttons.
- The backdrop blur is scoped to the dark backdrop the CDK gives modal
  overlays; menus, selects and popovers open over a transparent backdrop
  that no longer matches the rule.
- `mat.elevation-classes()` is dropped: nothing uses `.mat-elevation-z*`,
  and its 25 rules cost 4.4 KB of the initial stylesheet. The stack now
  measures 1,595,843 initial bytes (was 1,597,764).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(e2e): wait for focus to enter an opened menu before navigating it

Material focuses a menu's first item after the next render, which the
zoneless scheduler runs on a later task than the keydown that opened it.
The panel is visible before that, so End sent as soon as it appeared still
reached the trigger: on the Linux runner the Sources row-menu delete test
then found "Remove playlist" inactive on every retry. `waitForMenuFocus`
polls for the active element inside the open menu; the Sources keyboard
and switcher keyboard tests use it before their first navigation key.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 19:53:22 +02:00

1565 lines
86 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
## Angular Material's Role
Decision of 2026-10-12. The repository has no ADR folder; decisions live in
the subsystem's document.
**Decision.**
- Angular Material stays as the behaviour and accessibility layer: dialog,
menu, select (inside forms only), form field and input, snack-bar, tooltip,
slide-toggle, checkbox, progress and icon.
- The look comes from the IPTVnator design kit, applied through
`mat.*-overrides()` on the `--app-*` tokens in one place: the shared
partials under `libs/ui/styles/` (the control mixins live in
`_app-controls.scss`), not per-component `::ng-deep`.
- A kit component that Material lacks is built on CDK primitives (overlay,
a11y key managers), as the playlist switcher popover is.
- A plain action button may become a native `<button>` with the kit mixins.
- No wholesale replacement of Material. Re-evaluate Angular's headless
`@angular/aria` (developer preview as of v21; not installed) for menu,
listbox and combobox once it is stable.
**Why** (production build, `renderer.initialBytes` 1,597,763 bytes):
- Material's JavaScript is about 203 KB (12.7% of the initial bytes) and the
CDK's about 126 KB (7.9%). Any replacement keeps the CDK, and the CDK is
where the behaviour lives.
- In the global CSS the `--mat-*` tokens are 96 KB raw but about 11 KB
gzipped, and Material's own rules are 9 KB. 23.6 KB of the tokens belong to
17 components the app never imports, because `mat.all-component-themes`
emits every component.
- The startup cost came from a few eager imports (the form-field defaults
token in `app.config.ts`, the startup, update and EPG-progress components,
the confirm-dialog service), not from Material itself. Those are gone (see
Material off the startup path below): `renderer.initialBytes` went from
1,597,763 to 1,345,807 with Material still in place.
- Rewriting dialog focus trapping, menu keyboard handling, select listboxes
and snack-bar live regions would cost months and accessibility risk for no
visible gain.
**Consequences.**
- Material upgrades can change behaviour. Seen in v22: the menu panel has no
max-height; a submenu is offset by the parent's first direct
`mat-menu-item`, so a trigger that follows `role="group"` rows is wrapped
in `<div role="none">`; overlay panes are `position: static` inside the
bounding box.
- Keep E2E coverage on menus and dialogs.
## 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,
destructive menu items and `.app-destructive-button`;
`--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](performance-journeys.md#rendererinitialbytes))
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`; `fab` is listed only
while a runtime template renders `mat-fab` or `mat-mini-fab`, since the
button import carries the directives either way and the fab tokens alone
are about 6 KB).
- **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 class="app-menu">` gets the kit panel from one global rule in
`styles.scss` (`menu-panel` in `libs/ui/styles/_app-controls.scss`): a
bordered 10px panel with 6px padding, 36px rows at 13px with 16px icons, the
checked row on the list-selection tokens, a destructive row marked
`app-menu-item--danger` after a divider, and a shortcut hint in a trailing
`kbd.app-menu-item__kbd`. The panel renders in the overlay container, outside
every component view, so a component never copies the mixin into its own
stylesheet.
A `mat-menu` that picks one value, such as a sort order or a rating
threshold, uses `appMenuItemRadio` and `appMenuItemRadioCheck` from
`@iptvnator/ui/components`:
```html
<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](./workspace-shell.md#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](performance-journeys.md#j3-playback-start-playback-to-the-first-frame)).
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`, as the unsaved-changes dialog's Discard does. It is the
kit's red tint on the `--app-danger-*` tokens per theme (tinted fill and
red text for filled and outlined buttons, red text for text and icon
buttons), never a solid error fill; hover changes the fill only, and a
disabled button is the same button at 40% opacity.
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 danger tint 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.
A `mat-select` belongs inside a form. Its option panel is styled once in
`m3-theme.scss` to match the kit menu (a bordered 10px panel, 36px rows at
13px, the chosen row on the list-selection tokens). A picker in a toolbar is
not a select: it is a secondary button with a chevron that opens an
`app-menu`.
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](workspace-shell.md#content-surface-corner))
## 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.