* fix(details): continue the hero artwork under the first section The vertical scrim stopped at the hero's bottom edge, so a bright backdrop ended in a visible band above the Episodes heading. The hero now grows by a 140px tail that the shell pulls the sections up over, and the scrim resolves to the exact page surface behind the heading. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(details): one section header and a 56px rhythm below the hero Episodes, Cast & crew and Similar now share app-detail-section-header: an 18px/600 title, a muted tabular counter and lead/end slots. The Episodes counter adds the watched count ("8 episodes · 3 watched"), and sections sit 56px apart, with 56px after the last one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(details): flat episode rows with a reserved action slot Each episode is now one app-episode-item, a list row or a grid card: - a number column, a 168px thumbnail with a watched check, a progress bar only for started episodes and a play overlay - title with "46 min · 12 Jan" (time left once started) and a plot clamped to two lines at 74ch - mark watched, download and a "…" menu in a 112px slot that is always reserved and shows on hover or keyboard focus, so nothing shifts - the whole item is one stretched button: Tab focuses it, Enter plays - the playing or last unfinished episode is highlighted The "…" menu holds Episode details and, for a started episode, Play from beginning, which Xtream and Stalker now start at 0. TMDB's episode runtime fills the meta line when the provider sends none. The list is the default view for anyone who has not chosen one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(details): segmented list/grid switch and a 30px season pill The view toggle becomes a 2px-padded segmented track with 30×26 segments and a neutral checked segment; the season pills and the season dropdown share the section header's 30px pill. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(details): no season synopsis that repeats the series description Providers often send the series plot, or its first sentences, as every season's description, so the same text showed twice a few hundred pixels apart. The season strip now drops a synopsis that equals the hero's description, or is cut short from it, and clamps its own to two lines at 72ch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(details): draw the episode thumbnail hairline as a border An inset shadow over the artwork is invisible to the surface-contrast checks and to forced-colors mode; a 1px border is what both read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(e2e): follow the list-first, flat episode items The list is now the default view, rows and cards share .episode-item classes, and grid titles no longer carry the "N." prefix. The surface checks measure the thumbnail hairline instead of a row border, assert that hovering a row does not move its title, and capture list, grid and hover screenshots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(ui): episode list, section rhythm and hero tail contracts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(dashboard): continue the hero artwork under the first rail The dashboard hero's fade ended at its edge, so the artwork stopped just above the Continue Watching heading. Like the details hero, it now grows by a 160px tail that the rails are pulled up over: the art fades behind the first rail's heading and reaches the page colour before its cards, so the rail's scroll-edge fades never show as a box. The narrow layout, whose slide carries its own scrim block, keeps no tail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changes): note the smoother hero fade Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(details): season chips up to four, a counts menu from five, no season art The season picker drops every poster: chips for up to four seasons, a menu button from five whose rows read the season and "N episodes · M watched". The season synopsis loses its cover, sits on the number column 24px above the list and renders nothing without a plot of its own. The player's episode panel shares the picker and loses its dropdown thumbnails too; its season strip stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(details): bare rows for seasons without metadata, skeletons while it loads A season whose final episode data has no plot and no usable still (the series poster or season cover repeated as a still counts as none) renders 44px rows: number, title, meta and an inline check or progress bar, the same action slot, no grid toggle. While the provider list or a TMDB lookup that could still fill a bare-looking season is outstanding, the episodes are skeleton rows at the full row's exact geometry instead of a spinner; a season the provider already describes renders at once. Xtream tracks the show match and each season's enrichment in the store; Stalker tracks its show match in the selection state and settled season fetches in its TMDB service, and now reports a regular series' season request as loading instead of an empty series. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(details): loading skeletons laid out like the loaded page The hero skeleton targeted ngx-skeleton-loader's old `.loader` class, so its blocks kept the library's light default fill and margins; `display: block` stacked the chips and buttons into columns; and the details column sat at the top while the loaded one is bottom-aligned, so the title dropped ~150px on load. It is now plain shimmer blocks at the loaded hero's geometry with the stage height kept while loading. The Xtream series page shows an episodes section skeleton under it, and the Stalker series hero holds the Play button's place while seasons load. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(e2e): the modeled Stalker series renders bare rows Its episodes repeat the series poster and carry no plots, so the shared series surface check now expects 44px bare rows without thumbnails or a grid toggle in both themes. The thumbnail hairline and grid checks stay in the Xtream suite, whose episodes have stills. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * style(details): format the season picker stylesheet Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(details): episode skeletons never outlive their metadata The loading state added for TMDB-enriched episode rows could stick or flicker: - an empty season is never enriched, so its host never settled it and the season showed six skeleton rows forever instead of its empty state; - Xtream re-marked an enriched season pending on every selection write, turning rendered rows back into skeletons (and dropping row focus) for each cache read; - a superseded lookup of a reopened series, or of a Stalker item without an id, could settle a newer lookup's pending state or never clear it; - TMDB requests have no timeout, so a blocked TMDB host held provider episodes back for minutes. Metadata now only holds back a season that has episodes, the wait is capped at 4s per season, only the latest lookup of a key (or selection) settles it, a settled Xtream season stays settled within a visit and a new visit starts its seasons afresh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(details): described seasons render at once; room for titles on phones Metadata lookups held every season as skeletons until TMDB answered, even one the provider had already described with plots and stills. The state now waits for metadata only when the current data would render bare; described rows show at once and the metadata lands in place. At a pane width of 480px or less the list row kept the 112px thumbnail and the action slot beside the text, leaving a 320px phone about 18px for the title. The thumbnail shrinks to 96px there and the actions take their own row under the text. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(details): keep phone-width grid cards stacked The phone-width grid-area placements applied to grid cards too, which declare no template areas, so a card's artwork and text overlapped in a narrow pane. The placements are scoped to list rows; the surface check now also asserts a card's text stays under its artwork at 360px. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(details): a still that only one episode carries is not a repeat The distinct-stills check counted unique image URLs and treated a single one as the series poster repeated, so a season where one episode has a genuine still and the rest have none lost that still and went bare, with its grid toggle. One image on one episode is now a still; only one image on several episodes counts as a repeat. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(details): phone-width skeleton rows match the rows they precede At a pane width of 480px or less the finished list row uses a 96px thumbnail and an action row under the text, but its skeleton kept the 112px single-row layout, so pictures and text moved when loading ended. The skeleton row now carries an empty 34px action slot and mirrors the phone layout. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(details): no air date from a placeholder or an impossible day The Date constructor turned a provider's "0000-00-00" into a day in 1899 and rolled "2025-02-31" into March, so the episode meta line showed dates nobody sent. An ISO day is now validated part by part and an invalid one leaves the date out. The season menu's container colour goes through mat.menu-overrides(), as the UI guidelines require. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * style(details): spinner colours through mat.progress-spinner-overrides() The episode item set the spinner's indicator colour as raw --mat-* declarations; the UI guidelines want Material tokens set through the component's overrides mixin, which rejects unknown names at build time. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(details): unknown season counts, resume position, hero-skeleton spec A lazy Stalker VOD season the portal has not answered yet showed "0 episodes" in the season menu: the picker now takes the per-season load states and shows no count for such a season. A started episode whose duration nobody knows (no provider or TMDB runtime, none saved with the position) lost its saved position from the meta line; it now reads "Resume at 12:34". The downloads offline-detail spec queried the ngx-skeleton-loader the hero skeleton no longer uses; it queries the hero-skeleton test id. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(playback): a reused external player starts each load at its own offset MPV and VLC are launched with a global --start / --start-time for a resumed title, and a reused process applies that to every later load: "Play from beginning", the next episode and a later resume all began at the first launch's offset. The seek sent right after loadfile does not help — mpv rejects it before the file is loaded, and the command sender never reads the reply (verified against a real mpv over IPC). Every reuse load now carries a per-file start (0 unless an offset is requested): mpv's loadfile options and VLC's :start-time input option. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(details): loading flags follow the detail container, menus know unloaded seasons The fullscreen episode picker reports its season through the same onSeasonSelected as the detail container, so the Stalker view's episode-list and metadata loading flags followed whichever season was picked last: choosing another lazy season in fullscreen turned the detail page's loaded season into skeletons until the portal answered. Both flags now follow the detail container's own selection. The fullscreen panel also shares the season picker but never forwarded its per-season load states, so a lazy season read "0 episodes" in its menu; the states are forwarded now. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(details): Xtream metadata loading follows the detail container too Like the Stalker view, the Xtream seasons service keyed its metadata loading flag off the season selected last, which the fullscreen episode picker also sets: picking another season there could hide the detail page's settled bare rows behind skeletons for up to four seconds. The flag now reads the detail container's own selection; the picker's choice still gets enriched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
62 KiB
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
-
Prefer shared components over duplicated markup. The canonical channel row is
app-channel-list-item. -
Drive emphasis through selection state, not through constant decoration. Neutral rows should stay quiet. Only active or current items should pick up strong color.
-
Use the same selection language everywhere. Selected nav items, channels, and current EPG cards should feel like the same system.
-
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.
-
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--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.
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(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). 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 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:
52pxmin 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
68pxheight that matches the virtual-scroll stride. EPG-disabled, compact rows use a matching fixed52pxrow and virtual-scroll size. - At
310pxand below, hide the end time while keeping the start time and progress bar. - At
270pxand below, hide the decorative logo while retaining program context and actions, and tighten horizontal padding to preserve the remaining content. - At
220pxand 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 at220px. isRadioalone must not change row height inside a fixed-size mixed virtual list; the consumer'sshowEpgstate 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 (
typelive/itv/radioor thelogovariant — logos are too often missing to identify a channel), search results and "recently added" rails (they answer by name; hosts pass[allowPostersOnly]="false"toapp-content-card;app-grid-listandapp-unified-grid-tabdrop the wall themselves while theirsearchTerminput 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-overlayinside the poster wrapper: bottom gradient scrim, two clamped lines, 150 ms ease-out opacity, shown on:hoverand:focus-visibleof the card, none underprefers-reduced-motion. It isaria-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 swappingsrcin place. - Layout hints. The grid's
contain-intrinsic-sizedrops 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-visiblering (card-focus-ringmixin inlibs/ui/styles/_content-grid.scss). Onapp-content-cardthat surface is the inner.content-card__activationelement, and the Remove control (labelled byremoveTooltip) is a SIBLING positioned over the poster corner — an interactive control nested inside arole="button"is an invalid accessibility structure. Its ring is drawn on the OUTER.content-cardvia:has(> .content-card__activation:focus-visible), because the card'soverflow: hiddenwould clip an outline on the inner surface on every edge. Posteraltis 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-timelineas 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 (
TimelineWindowControllerinepg-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). 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.tswalks 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):expanded— categories rail + channels rail + player.categories-hidden— channels rail + player. The shell's categories rail (WorkspaceShellContextSidebarComponent, live sections only: Xtreamlive, Stalkeritv/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.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 (
/livewith no selected category) follows the same paginatedAll Itemsshell as VOD and Series: a widget header with the total channel count, page-size controls, and page navigation above the sharedapp-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_leftin the categories rail header (WorkspaceContextPanelComponent,presentation="sidebar", live sections only) → level 2 (hideCategories('portal')). - A
chevron_rightat 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'sLiveCategoriesPopover.showCategoriesPanel(): it setsshowCategories('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") opensLIVE_CATEGORIES_POPOVERanchored below itself. The token lives in@iptvnator/portal/shared/util; the workspace shell provides it (WorkspaceLiveCategoriesPopoverService, CDK overlay hostingWorkspaceLiveCategoriesPopoverComponent, which stamps the context panel withpresentation="popover") and the live layouts reach it through theirLivePanelsController(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-categoriesid, 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 throughPortalCategorySortStateService, so a sort picked in the popover survives into the restored rail. Backdrop, Escape, the footer, any category selection (categorySelectedoutput), any routerNavigationStartand any live-panel level change (Cmd/Ctrl+Breaches the layout through the dialog) close it; focus returns to the trigger. The popover host is arole="dialog"witharia-modaland aCdkTrapFocushost directive that captures focus on open, matching the trigger'saria-haspopup="dialog". - The
chevron_leftin the channels header → level 3 (collapse('portal')). - While collapsed, a floating
chevron_rightmini-fab at the left edge of.content-container, the workspace header toggle andCmd/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 sharedisTypingInInputhelper. "Show playing channel" (XtreamLiveChannelNavigationService,StalkerLiveNavigation) usesexpand('portal')for the same reason: revealing the row must not unfold a deliberately hidden categories rail.
- A
- Collapsed state is owned by
LiveLayoutSidebarStateService(providedIn: 'root') in@iptvnator/portal/shared/utiland kept per surface (LiveSidebarSurface):m3u(the M3U player),portal(Xtream and Stalker live layouts plus the shell categories rail) andcollection(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 onareCategoriesHiddenFor(surface), the channels rail onisCollapsedFor(surface); actions aretoggle,collapse,expand,hideCategories,showCategoriesandsetState, all per surface. Nothing reads localStorage directly. Persistence lives underlive-sidebar-state:<surface>and every level is restored as stored; the leveltoggle()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 keylive-sidebar-stateis forgotten on service construction and never read — a storedcollapsedthere 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-buttonwithchevron_lefthides it; while collapsed a floatingchevron_rightmini-fab sits at the left edge of.content-container. Because both of those live in the thing they hide, the workspace header additionally rendersview_sidebar(headerSidebarToggle,WorkspaceShellHeaderService) on every route that renders its own rail — M3Uall/groups, Xtreamlive, Stalkeritv/radio(resolveRouteLiveSidebarSurface). It stays in place in both states, usesaria-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 genericapp-portal-empty-stategrew optionalhint,actionLabel,actionIconinputs and anactionoutput 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(isContextPanelInerton the shell rail,isSidebarCollapsedon the Xtream/Stalker channels rail) to leave the Tab order and the accessibility tree; the shell rail skipsinertwhile 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 viafocusIfFocusLost()(@iptvnator/portal/shared/util): the layouts'LivePanelsControllerinstallshandoffFocusOnLiveSidebarChange()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), thetabindex="-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 theappResizabledirective withwidth: 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: 0instead ofwidth: 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 ofCmd/Ctrl+B. The phone context drawer ignores the folded state entirely (the user explicitly opened it).
EPG Card
- Radius:
11px(.epg-timeline__blockinlibs/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 inapps/web/src/m3-theme.scssas that theme's--app-selection-color, and declared again inside.dark-themebecause 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:hostwith theprogress-tokenmixin oflibs/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.
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.
The movie and series detail hero has an immediate skeleton too, built from
the shared shimmer (libs/ui/components/src/lib/styles/_skeleton-shimmer.scss)
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-colorfill) 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.6with a 160 ms transition (0msunderprefers-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. Keepdata-setting-idon every row: settings search and the command palette reveal rows by it, and the registry spec checks the index against the templates. - Controls:
.settings-selectis a 220px outlined select without a floating label (the row title names it; passaria-labelledby); boolean settings aremat-slide-toggleswitches; segmented choices use.theme-switcheras a pill;.settings-chipmarks rows that apply after a restart or are experimental;.settings-callout(--info,--warn) sits under the row it explains inside.setting-item__suband only while that choice is made. - Destructive actions keep the confirm dialog from the Dialogs section; the
row itself uses
.app-destructive-buttonon 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'saria-labelledbyat it. A custom-styled title keeps the directive and overrides Material's headline padding and 40px::beforestrut, 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.tsinweb-e2emeasures 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 Electronepg-timeline-interaction.e2e.tsmeasures 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.EpgProgrammeDialogServiceopens 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
warnbutton colors for M2 themes, so thecolorinput is a no-op here. A button that removes or discards user data uses the global.app-destructive-buttonclass fromm3-theme.scss(error/on-error tokens per theme, for filled, text, outlined and icon buttons), as the unsaved-changes dialog's Discard does. Confirmations go throughDialogService.openConfirmDialogwith a translated verb as the requiredconfirmLabel("Remove playlist", "Clear") andtone: 'destructive'when the action loses data; the dismiss defaults to "Cancel". Never confirm with "Yes"/"No". When the verb itself is "Cancel …", passcancelLabel"Close" so the two buttons do not read alike.theme-tokens.e2e.tschecks the label and the error fill 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.tsinportal/shared/ui), from the collection page's button and from the command palette alike; the shell header does not repeat it.recent.e2e.tschecks the single control and the single confirmation on M3U, Xtream and Stalker recently viewed pages.
Forms
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-buttonsuffix withPasswordVisibilityToggleDirective(@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 througharia-pressed, as an ARIA toggle button does. - URLs. A URL field has a neutral
mat-hintwhere the format is not obvious, and its ownmat-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.tsinweb-e2ecovers the shared labels, the toggle and the URL errors.
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.
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: columnstacks its links out of the bar and over the header. The bar scrolls sideways once a portal contributes its sections, and the settings link isposition: stickyso 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 callclose()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 isposition: fixedon the sidebar host, which also removes it from the shell grid, so the phoneworkspace-bodystays single-pane. The drawer is modal for keyboard and screen-reader users:CdkTrapFocuscaptures and contains Tab focus while open, the shell marks the rail, header, content, and playback footerinert(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 andfocus()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 — checkdefaultPrevented, 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 aninertancestor, sinceinertdoes not silence document-level listeners. Any NEW document-level key listener on routed content must apply the sameclosest('[inert]')guard. The service is root-provided from@iptvnator/workspace/shell/utilso 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 withEmbeddedMpvOverlayVisibilityService.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
⌘Kbadge, 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 thefontshorthand, 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 runspnpm 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; setfont-weight: 500there. - Import whole
@fontsource/<family>/<weight>.cssfiles. The single-script files such ascyrillic-600.csshave nounicode-range, so a Cyrillic-only face wins the weight match for Latin text inRoboto, …stacks and sends it to the next family. <html lang>follows the UI language (syncDocumentLanguage()mapsbytobeandzhtwtozh-TW), sohyphens,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:
- Check whether
app-channel-list-itemcan be reused. - Check whether
app-epg-timelinealready provides the correct structure. - Check whether
nav-list.scssalready solves the list-selection problem. - 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. - 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. - Extend tokens first, duplicate styles last.
Implementation Workflow
When updating IPTVnator UI:
- Inspect the current shared component first.
- Reuse the shared structure where possible.
- Keep selection, progress, and spacing in sync across Xtream, Stalker, and shared portal views.
- Verify in both light and dark themes.
- Run the focused component/unit target and the closest Playwright workflow.
- 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-radiuswithoverflow) when it holds composited effects such asbackdrop-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)
Definition Of Done For UI Changes
A visual change is not done until:
- Shared component reuse was considered first.
- Light theme and dark theme both look intentional.
- Selection and progress states match existing IPTVnator patterns.
- Scroll behavior is correct.
- The result was checked in the running app for layout-sensitive work.
Network source indicators (Electron)
The switcher and source rows use SourceHealthIndicatorComponent: green means
available, orange expired, red disabled/access refused, and neutral means
checking or unverified. Accessible tooltip text distinguishes refusal from a
confirmed disabled account and includes the check time. M3U indicators describe
the source URL only. Check again is a separate action, not a playlist refresh.
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.