Files
iptvnator/docs/architecture/iptvnator-ui-guidelines.md
T
4grayandClaude Fable 5 8442747c37 feat(xtream): replace catalog pagination with infinite scroll (1/2) (#1392)
* feat(xtream): replace catalog pagination with infinite scroll

Xtream movie/series/live catalogs now load continuously while scrolling
instead of paging. The selection store keeps a growing visibleCount render
window over the in-memory catalog (initial 50, +50 per load) plus a saved
scroll state, so opening a title and going back restores the exact spot. A
shared InfiniteScrollDirective (portal/shared/ui) fires loadMore near the
bottom (edge-triggered, mirroring search-layout) and auto-fills viewports
taller than the initial window by measuring container overflow — capped at
10 self-initiated loads per list identity, with a ResizeObserver re-check.

The shared CategoryContentViewComponent branches on the transitional
PortalCatalogFacade.supportsInfiniteScroll flag: Xtream scrolls, Stalker
keeps its server-driven paginator and ?page= round-trip untouched until its
append lands (PR 2), after which the paged facade members and the flag are
deleted. grid-list loses its dead built-in paginator and gains tail states
(append spinner, retry-on-error) plus content-visibility on cards. The
in-portal search results reuse the search layout's nearEnd hook to window
their full result set instead of rendering it unbounded.

Validation: portal-xtream-data-access (234), portal-xtream-feature (357),
portal-catalog-feature (22), portal-shared-ui (77, incl. new directive
spec), portal-stalker-* (253) unit tests green; catalog-sorting e2e 5/5
(new scroll-growth + spot-restore test against the large 200-item mock
scenario, Stalker paged spec unchanged); search e2e 16/16; lint green;
release note added and validated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(xtream): auto-fill search results and refresh the near-end latch

Review findings from #1392: the in-portal search window could stall at its
first 60-item chunk when the rendered cards did not overflow the container
— nearEnd only fired on real scroll events (Greptile P1), the search
layout's edge latch survived a result-set replacement (Codex), and the
shared directive's latch went stale after appended content moved the
bottom out of the threshold (Codex).

The search layout now drives its results container through the shared
InfiniteScrollDirective instead of a bespoke scroll handler: the measured
auto-fill reveals further chunks on tall viewports without any scroll, the
reset key (search term) and item-count changes refresh the latch, and new
nearEndHasMore/nearEndAppending inputs let consumers gate emissions.
Xtream search wires them for both modes — this also fixes the same latent
tall-viewport stall in the global search's 100-item pages — and the
Stalker search page (single capped request until PR 2) sets hasMore=false.
The directive's fill check now refreshes the latch from the measured
state, so an End-key jump straight to the new bottom is a genuine crossing
again.

New coverage: directive stale-latch regression, search-layout auto-fill +
hasMore gating, in-portal window reveal/reset in search-results. Reruns:
portal-shared-ui 80, portal-xtream-feature 357, portal-stalker-feature
green; search e2e 16/16 (fresh Playwright report verified); lint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(xtream): re-measure search auto-fill on the rendered window, not the total

Round-2 review finding on #1392 (Greptile P1 + Codex P2, same defect): the
search layout bound the constant result-set total to the infinite-scroll
directive's item count, so once the in-portal window grew 60 -> 120 no
tracked input changed, no further overflow check was scheduled, and
results beyond 120 stayed unreachable on tall viewports.

The layout now takes an explicit nearEndRenderedCount (falling back to
resultsCount for consumers that render everything they report) and feeds
THAT to the directive. Xtream search passes the windowed slice length for
in-portal mode and the loaded-set length for global mode. Regression
specs: layout re-measures when the rendered window grows while the total
stays constant; the component exposes the rendered count following the
window. portal-shared-ui 81, portal-xtream-feature 357, lint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(xtream): include filter state in the search reset identity

Round-3 Codex P2 on #1392: the near-end latch and auto-fill budget were
keyed on the search term alone, so a filter-only transition (type filters
or the hidden-categories toggle) replaced the result set without resetting
them — a jump straight back into the threshold could be swallowed. The
search layout now accepts an explicit nearEndResetKey (defaulting to the
term); Xtream search supplies term + type filters + excludeHidden.
Regression specs: layout latch resets on an identity change without a new
term; the component identity changes on filter-only and hidden-toggle
transitions. portal-shared-ui 82, portal-xtream-feature 358, lint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(xtream): refuse global-search appends while an edited query debounces

Round-4 Codex P2 on #1392: after the reset-identity change, the layout's
auto-fill can request more results inside the 300ms search debounce. The
append then ran with the freshly edited term but the old result count as
offset, interleaving a page of the new query into the old query's visible
results until the offset-zero search landed.

An append now only continues the LAST EXECUTED search: the append guard
additionally requires the effective term to equal lastGlobalSearchTerm,
so pagination stays suppressed from the first keystroke until the fresh
search replaces the result set. Regression spec covers the mid-debounce
refusal; the two existing append specs state their precondition
explicitly. portal-xtream-feature 359, lint clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(xtream): per-selection scroll snapshots and progress-based auto-fill stop

Round-5 Codex P2s on #1392:

1. The single saved-scroll slot lost the first tab's position on a
   VOD -> Series -> VOD round trip — the series view's destroy hook
   overwrote it with series coordinates. Snapshots are now kept per
   selection identity (bounded to the 8 most recent), so a detour's save
   can never destroy another list's spot. Store API is unchanged.

2. The fixed 10-load auto-fill budget could strand items on a viewport
   large enough that ten chunks still do not overflow — with no
   scrollbar, no real scroll event can ever fire. The auto-fill now
   terminates on lack of progress instead: loads continue while they
   grow scrollHeight (until genuine overflow hands off to scroll
   events) and stop after three consecutive loads without growth, which
   only a source that reports more but renders nothing can produce.

Regression specs: VOD/Series round trip keeps both snapshots; growth
keeps filling past the old cap and stops at overflow; no-growth stalls
stop at three; reset key clears the stall guard. portal-shared-ui 83,
portal-xtream-data-access 235, catalog-sorting e2e 5/5 re-run, lint
clean. CLAUDE.md wording updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 11:33:56 +02:00

493 lines
21 KiB
Markdown

# IPTVnator UI Guidelines
This document captures the current UI language used across IPTVnator, with emphasis on channel lists, EPG views, settings surfaces, and shared selection patterns.
Use it when changing existing views or introducing new list-based UI in the workspace, Xtream, or Stalker flows.
## Core Principles
1. Prefer shared components over duplicated markup.
The canonical channel row is `app-channel-list-item`.
2. Drive emphasis through selection state, not through constant decoration.
Neutral rows should stay quiet. Only active or current items should pick up strong color.
3. Use the same selection language everywhere.
Selected nav items, channels, and current EPG cards should feel like the same system.
4. Keep dark and light themes intentionally different.
Dark theme can carry more density and tinted surfaces.
Light theme should be flatter and cleaner, with white or near-white cards.
5. Scroll ownership must be explicit.
Headers stay visible. Lists scroll. Do not let nested panes compete for scroll.
## Canonical References
- Channel row:
`libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html`
- Channel row styles:
`libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.scss`
- Shared EPG timeline:
`libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.html`
- Shared EPG timeline styles:
`libs/ui/epg/src/lib/epg-timeline/epg-timeline.component.scss`
- Shared EPG list:
`libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.ts`
- Shared EPG list styles:
`libs/ui/epg/src/lib/epg-list-view/epg-list-view.component.scss`
- Shared list selection style:
`libs/ui/styles/_nav-list.scss`
- Theme tokens:
`apps/web/src/m3-theme.scss`
- Settings surfaces:
`apps/web/src/app/settings/settings.component.scss`
- Detail view shell styles:
`libs/ui/styles/_detail-view.scss`
## Shared Tokens
These tokens are the base for interactive emphasis:
- `--app-selection-color`
- `--app-selection-on-color`
- `--app-selection-surface`
- `--app-selection-surface-strong`
- `--app-selection-border`
- `--app-selection-glow`
Use the app's own surface tokens for neutral surfaces (defined for both themes
in `apps/web/src/m3-theme.scss`):
- `--app-shell-bg` / `--app-rail-bg` / `--app-header-bg` / `--app-content-bg`
- `--app-widget-bg` / `--app-widget-header-bg` — panels and popovers
- `--app-card-hover-bg` — raised or hovered rows
- `--app-widget-border` / `--app-rail-border` — hairlines
- `--app-on-surface` — primary text
- `--app-eyebrow-color` — secondary/muted text
Angular Material mixins and Material-component overrides may use the tokens
owned by that component. Outside a Material-owned component, prefer the
app-owned tokens above. 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:
`52px` min height with slightly tighter padding
These values describe the current shared row, not a fixed-width contract. Keep
the row responsive: the text column uses `min-width: 0` and ellipsis, while
logos, drag affordances, and trailing actions use `flex-shrink: 0`. Prefer
minimum dimensions and flexible columns over fixed row widths.
### Content Layout
- Title is one line, medium-bold, slightly condensed
- Program title is a secondary line with lower emphasis
- Timeline uses three columns:
start time, progress bar, end time
- Action buttons sit on the trailing edge and inherit row color
### Responsive Information Priority
- EPG-enabled, noncompact rows keep a fixed `68px` height that matches the
virtual-scroll stride. EPG-disabled, compact rows use a matching fixed `52px`
row and virtual-scroll size.
- At `310px` and below, hide the end time while keeping the start time and
progress bar.
- At `270px` and below, hide the decorative logo while retaining program
context and actions, and tighten horizontal padding to preserve the remaining
content.
- At `220px` and below, hide the start time while keeping the progress bar.
- In EPG-preview rows, narrow width alone must not remove the channel name,
program title or no-program placeholder, progress bar, drag affordance when
applicable, or enabled actions.
- Radio consumers without EPG render the row as compact instead of showing a
false no-program placeholder. Compact rows keep the logo at `270px`, then
hide the logo and actions at `220px`.
- `isRadio` alone must not change row height inside a fixed-size mixed virtual
list; the consumer's `showEpg` state and virtual-scroll item size own density.
- Loading skeletons mirror the same responsive hierarchy and row geometry.
### Logo Rules
- Show fallback icon only when no image is available or image loading fails
- Do not render placeholder and real logo at the same time
- Keep logos contained with `object-fit: contain`
## EPG Views
The shared timeline and list still contain local dark surfaces, blue selection
accents, and white foregrounds. These non-semantic hard-coded colors are
migration debt. New work should use app surface/selection/text tokens and must
not spread those local fallbacks. Semantic live, error, and status colors may
remain local when the meaning is explicit.
### Shared EPG Pane
- Header title stays sticky
- Program list is the only scrolling region
- Add bottom padding so the last program is not clipped
- Current program card uses the same selection treatment as selected channels
### Collapsible Live EPG
- Live TV layouts with an internal player render `app-epg-timeline` as the
EPG content, including playlist-specific live pages and the global
favorites/recent live tabs.
- The timeline's own panel bar owns the current-program summary and live date
navigation together; there is no separate wrapper component around it.
- Collapsed state is shared across M3U, Xtream, and Stalker with
`live-epg-panel-state`; missing or invalid values restore to expanded.
- The collapsed panel is a slim current-program strip with a trailing progress
line and an expand button. Date controls stay out of the collapsed strip.
- Do not render the collapsed strip for external MPV/VLC playback; those
layouts keep the full EPG-only panel.
- Keep the EPG content mounted while collapsed so current-program state can
continue updating.
### Collapsible Live Sidebar
- 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 (`/live` with no selected category) follows the
same paginated `All Items` shell as VOD and Series: a widget header with the
total channel count, page-size controls, and page navigation above the shared
`app-grid-list`. Use the grid list's logo-oriented live variant so channel
logos stay contained in 16:9 thumbnails instead of being cropped like
VOD/series posters. Selecting a channel from that root grid starts playback,
selects the channel's category, highlights the active category and channel,
and scrolls the category rail plus virtual channels list to the selected rows
when those rails are visible.
- 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
is `live` (Xtream) or `itv`/`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 reads `isCollapsed`; any toggle calls
`service.toggle()`. Persistence delegates to the existing
`live-sidebar-state` helpers, so the localStorage key stays unchanged and
missing/invalid values restore to expanded.
- A `mat-icon-button` with `chevron_left` lives in the sidebar header and
toggles state. While collapsed, a floating `chevron_right` mini-fab appears
at the left edge of `.content-container` to 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 shared `isTypingInInput` helper.
- The CSS class `.sidebar-collapsed` (channels rail) and
`.context-panel--collapsed` (workspace shell categories rail) both override
the inline width set by the `appResizable` directive with
`width: 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: 0` instead of `width: 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 of
`Cmd/Ctrl+B`.
### EPG Card
- Radius:
`11px` (`.epg-timeline__block` in
`libs/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, 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: column` stacks its links out of the bar and over the header.
The bar scrolls sideways once a portal contributes its sections, and the
settings link is `position: sticky` so 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 call `close()` 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
is `position: fixed` on the sidebar host, which also removes it from the
shell grid, so the phone `workspace-body` stays single-pane. The drawer is
modal for keyboard and screen-reader users: `CdkTrapFocus` captures and
contains Tab focus while open, the shell marks the rail, header, content,
and playback footer `inert` (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 and `focus()` 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 — check
`defaultPrevented`, 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 an `inert` ancestor, since `inert` does not
silence document-level listeners. Any NEW document-level key listener on
routed content must apply the same `closest('[inert]')` guard. The service is root-provided
from `@iptvnator/workspace/shell/util` so 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 with
`EmbeddedMpvOverlayVisibilityService.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 `⌘K` badge, the shortcuts button.
- The `mat-paginator` page-size select, which is the widest part of the
control and the least useful one on a phone. The range and arrows stay.
(Only Stalker catalog routes still render a paginator — Xtream catalogs use
infinite scroll and have none.)
- 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:
1. Check whether `app-channel-list-item` can be reused.
2. Check whether `app-epg-timeline` already provides the correct structure.
3. Check whether `nav-list.scss` already solves the list-selection problem.
4. 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`.
5. 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.
6. Extend tokens first, duplicate styles last.
## Implementation Workflow
When updating IPTVnator UI:
1. Inspect the current shared component first.
2. Reuse the shared structure where possible.
3. Keep selection, progress, and spacing in sync across Xtream, Stalker, and shared portal views.
4. Verify in both light and dark themes.
5. Run the focused component/unit target and the closest Playwright workflow.
6. 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:
1. Shared component reuse was considered first.
2. Light theme and dark theme both look intentional.
3. Selection and progress states match existing IPTVnator patterns.
4. Scroll behavior is correct.
5. The result was checked in the running app for layout-sensitive work.