# 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 ``, `