# IPTVnator UI Guidelines
This document captures the current UI language used across IPTVnator, with emphasis on channel lists, EPG views, settings surfaces, and shared selection patterns.
Use it when changing existing views or introducing new list-based UI in the workspace, Xtream, or Stalker flows.
## Core Principles
1. Prefer shared components over duplicated markup.
The canonical channel row is `app-channel-list-item`.
2. Drive emphasis through selection state, not through constant decoration.
Neutral rows should stay quiet. Only active or current items should pick up strong color.
3. Use the same selection language everywhere.
Selected nav items, channels, and current EPG cards should feel like the same system.
4. Keep dark and light themes intentionally different.
Dark theme can carry more density and tinted surfaces.
Light theme should be flatter and cleaner, with white or near-white cards.
5. Scroll ownership must be explicit.
Headers stay visible. Lists scroll. Do not let nested panes compete for scroll.
## Canonical References
- Channel row:
`libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html`
- Channel row styles:
`libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss`
- Shared EPG timeline:
`libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html`
- Shared EPG timeline styles:
`libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.scss`
- Shared EPG list:
`libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.ts`
- Shared EPG list styles:
`libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.scss`
- Shared list selection style:
`libs/ui/styles/_nav-list.scss`
- 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-widget-border` / `--app-rail-border` — hairlines
- `--app-on-surface` — primary text
- `--app-eyebrow-color` — secondary/muted text
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.
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.
## 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(480px, 60vh)` of stage for a
16:9 backdrop. 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. 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.
## 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.
## 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 `` as
`data-cover-size` (`apps/web/src/_cover-size.scss`). The same file carries
`--season-cover-width` (96 / 120 / 144px) for the season cover beside the
season tabs on series detail pages; medium equals the About block's 120px
poster so browse and watch share one secondary-poster size.
### 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.
### 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 ``, `