mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
* 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>
1565 lines
86 KiB
Markdown
1565 lines
86 KiB
Markdown
# 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.
|