# 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-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) 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. The app theme is the `.dark-theme` class that `SettingsService` puts on ``; 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 ``, 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. ## Single-Choice Menus And Status Chips A `mat-menu` that picks one value, such as a sort order or a rating threshold, uses `appMenuItemRadio` and `appMenuItemRadioCheck` from `@iptvnator/ui/components`: ```html ``` - 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 `` 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. 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 `` 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 ``, `