* fix(ui): make the workspace usable on phone-sized screens
The shell was half-adapted below 640px: the rail flipped to a horizontal
bar but the link lists inside it kept stacking downwards, so the navigation
was drawn outside the bar and over the header (#1100).
Three resizable rails — the shell context panel, the live-layout channel
sidebar and the M3U channel drawer — kept their persisted desktop width,
which left the content around 50px on a 375px screen. They now span the
full width and stack above the content. The inline width written by
ResizableDirective is why these rules need `!important`.
Found while walking the rest of the UI at 375px and 768px:
- The detail hero kept poster and details side by side, squeezing the
action row below its own labels until "Play" was clipped to its icon.
- The settings section list did not scroll and painted over the footer,
which also affected short desktop windows.
- Hiding the M3U channel list on a phone was one-way: the restore handle
was hidden and only Cmd/Ctrl+B could bring it back.
- The live header drew the channel count and the paginator on top of each
other up to tablet width, because the paginator does not shrink and the
meta collapsed to zero width and overflowed its box.
- The search scope checkbox was pushed off the right edge.
Live TV states a floor for the player instead of a ceiling for the lists,
so the video keeps a usable share of the screen under the categories panel
and the channel list.
Closes #1100
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): address review — keep the palette reachable and the video visible
Two findings from the Codex review on #1326.
Hiding the command-palette trigger on phones removed the only pointer-driven
way to open it: the rail renders route links plus Settings and emits nothing,
so `commandPaletteRequested` had exactly one source. The button stays and its
keyboard-shortcut label is swapped for an icon instead. Doing that exposed a
latent flex trap in the same row — an <input> keeps an intrinsic min-width
from its `size`, and `min-width: auto` honours it, so the field refused to
shrink and pushed the trigger out onto the buttons beside it.
The M3U drawer released the shared player floor, which on a short landscape
phone (600-640px wide) left the content container at half the shell body.
The inline guide inside it is `flex: 0 0 <basis>` and took its full 180px out
of a container that no longer had it, so the video could reach zero height.
The floor is restored and now yields on short viewports, the video states its
own minimum, and the guide is what gives way.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): let the channel list keep its height on a landscape phone
Follow-up to the review: the player floor added in the previous commit was
measured against the viewport, not against what the shell had left. On a
640x360 landscape phone the stacked categories panel already takes 30vh, so
claiming another 50vh here drove the channel sidebar to zero height while it
was still marked expanded — no way to pick another channel — and pushed the
layout past the viewport.
The floor now applies only where the screen can afford it (`min-height:
600px`), the sidebar states a floor of its own so it cannot be squeezed out,
and the collapsed rule clears that floor so hiding the list still works.
Below that height the two panes simply share what is left.
Portrait is unchanged: categories 244px, channel list 220px, player 240px on
a 375x812 screen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): address review — settings nav on landscape, poster dead space
Two more findings from the Codex review.
The stacked settings context panel capped itself at 30vh, which on a 360px
screen is 108px — less than the panel's own title and footer, so the seven
section rows collapsed to nothing behind an overlapping footer. On short
screens the caption gives way (the rail already labels the page), the footer
sheds its tall-screen padding, and the settings variant gets a slightly
larger cap: unlike the live routes there is no player below competing for
height, only a scrollable form.
The poster kept a 330px minimum from the skeleton fallback at the bottom of
the file — sized for the 220px desktop poster — while the stacked phone hero
renders it 140px wide with a ~210px aspect-ratio height. Every loaded detail
page carried ~120px of empty space between the poster and the title. The
override sits after that rule because it wins on source order, not
specificity.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): let the playlist switcher yield to the search field on narrow phones
Codex review of d6133da9: on a 320px header a route that contributes its
shortcut button left the search field less than its own chrome needs (~74px
of icon, palette trigger, gaps and padding), so the field's contents spilled
onto the buttons beside it.
The switcher is the one header region whose content can ellipsize, so it is
what shrinks — down to an 88px floor — while the field states its chrome as
a minimum. The field's basis moves from auto to zero so the input's intrinsic
size stops counting as content: with basis auto the field claimed its
intrinsic width even when room was ample and squeezed the switcher to ~115px
on a 375px screen that could fit all 140.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(ui): fit the switcher's own chrome inside its phone floor
Follow-up to the Codex note that the trigger's fixed chrome (type icon,
refresh, chevron, gaps, padding) exceeds the 88px floor the shell now allows
the switcher to shrink to. The flagged scenario itself cannot occur — the
Multi-EPG shortcut needs Electron bridge methods the PWA lacks, and Electron
enforces a 900px minimum window width so it never sees the phone breakpoint —
but the floor should hold on its own terms rather than by accident of which
buttons happen to render. Dropping the decorative type icon on phones brings
the fixed chrome under the floor, and the name gets the space instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
18 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:
apps/web/src/nav-list.scss - Theme tokens:
apps/web/src/m3-theme.scss - Settings surfaces:
apps/web/src/app/settings/settings.component.scss - Detail view shell styles:
libs/ui/styles/_detail-view.scss
Shared Tokens
These tokens are the base for interactive emphasis:
--app-selection-color--app-selection-on-color--app-selection-surface--app-selection-surface-strong--app-selection-border--app-selection-glow
Use the app's own surface tokens for neutral surfaces (defined for both themes
in apps/web/src/m3-theme.scss):
--app-shell-bg/--app-rail-bg/--app-header-bg/--app-content-bg--app-widget-bg/--app-widget-header-bg— panels and popovers--app-card-hover-bg— raised or hovered rows--app-widget-border/--app-rail-border— hairlines--app-on-surface— primary text--app-eyebrow-color— secondary/muted text
Angular Material mixins and Material-component overrides may use the tokens
owned by that component. Outside a Material-owned component, prefer the
app-owned tokens above. A --mat-sys-* reference is acceptable there only
after the built light and dark theme contexts both prove that it is emitted,
and it must still have a real app-token or literal fallback, for example:
var(--mat-sys-surface-container, var(--app-widget-bg)).
Several existing app surfaces still reference Material system tokens without that proof or use hard-coded layout/selection colors. Treat those references as migration debt, not patterns to copy.
Do not hardcode unrelated accent colors for selected state when these tokens already exist.
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 the detail-view Sass mixin (@mixin base)
from libs/ui/styles/_detail-view.scss. Feature-local styles/detail-view.scss
files should only @use that module and @include detail-view.base(...) with
small typography overrides when a provider needs them.
Do not copy the full detail-view stylesheet into feature libraries. Add shared layout changes to the mixin, and keep provider-specific differences explicit in the wrapper file that includes it.
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 does not set this centrally yet. Until
that debt is fixed, consumers in drag regions must cover the handle themselves
and must not assume it already opts out.
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
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.
Collapsible Live Sidebar
- M3U, Xtream, and Stalker live layouts share a single sidebar collapse toggle that hides the channels rail to give the player and EPG full width.
- 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. - In Xtream and Stalker live TV, the same toggle also collapses the workspace
shell context sidebar (the "Live Categories" rail rendered by
WorkspaceShellContextSidebarComponent), matching M3U's "everything quiets" behaviour. The shell categories rail only collapses when the active section islive(Xtream) oritv/radio(Stalker); movies, series, favorites, and recent routes leave it untouched. - Collapsed state is owned by
LiveLayoutSidebarStateService(providedIn: 'root') in@iptvnator/portal/shared/util. Every surface that participates injects the service and readsisCollapsed; any toggle callsservice.toggle(). Persistence delegates to the existinglive-sidebar-statehelpers, so the localStorage key stays unchanged and missing/invalid values restore to expanded. - A
mat-icon-buttonwithchevron_leftlives in the sidebar header and toggles state. While collapsed, a floatingchevron_rightmini-fab appears at the left edge of.content-containerto restore the rail (and the categories rail, in Xtream/Stalker live). - Keyboard shortcut:
Cmd/Ctrl+B. The handler ignores events that originate inside<input>,<textarea>,<select>, or content-editable elements via the sharedisTypingInInputhelper. - 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. - 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.
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
Progress Bars
Channel preview progress and EPG current-program progress should stay visually aligned.
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.
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.
Settings Surfaces
Settings use the same system but are flatter than content-heavy views.
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
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 or stacks
- 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. - Side rails stack above the content instead of beside it: the shell context panel, 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. - The
mat-paginatorpage-size select, which is the widest part of the control and the least useful one on a phone. The range and arrows stay. - 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.
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
- copying the shared EPG's hard-coded dark/blue fallbacks into new surfaces
- 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
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.